diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000000..165a4a7745 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,91 @@ +# Agent skills + +This folder is the single home for the skills our coding agents use. +Each skill is a folder with a `SKILL.md` inside — a short instruction +manual that an agent loads only when it needs it. + +One copy serves every tool: + +- **opencode** reads this folder directly. +- **Claude Code** reads it through the `.claude/skills` symlink. +- **Codex** reads it directly. + +To change how the agents behave, edit the `SKILL.md` here. There is no +second copy to keep in sync. + +## How the skills are organized + +**Flows** are the six skills you invoke by name. Each one covers one step +in the life of a change: plan it, review the plan, implement it, review +the code, open the pull request. + +**References** hold the quality standards. A flow's reviewer loads them; +you rarely touch them directly. + +**Procedures** define how one concrete step is done — a plan document, an +issue, a commit. Flows call them, but they also work on their own. + +**Utilities** are small helpers for everyday work: search, file lookup, +JSON, REPL access, and so on. + +## Flows + +| Skill | What it does | When you would say | +|---|---|---| +| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" | +| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" | +| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. On request ("direct"): no issue and no branch, commits on the current branch. | "implement the plan" · "step by step, one commit per task" · "direct, no branch" | +| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" | +| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" | +| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" | + +## References + +| Skill | What it holds | +|---|---| +| [`plan-review-criteria`](skills/plan-review-criteria/SKILL.md) | The plan review rubric: six axes, severity levels, approval standard, output format. The `review-plan` reviewer loads it. | +| [`code-review-criteria`](skills/code-review-criteria/SKILL.md) | The code review rubric: five axes, core principles (DRY, KISS, YAGNI), severity format, verdict. The `review-code` reviewer loads it. | + +## Procedures + +| Skill | What it does | +|---|---| +| [`planner`](skills/planner/SKILL.md) | The spec of a good plan: context, architecture decisions, tasks with acceptance criteria, checkpoints. Used by `make-a-plan`. | +| [`create-issue`](skills/create-issue/SKILL.md) | Creates a GitHub issue that follows Penpot conventions. Used by `implement-plan`; also works on its own. | +| [`create-commit`](skills/create-commit/SKILL.md) | Makes a commit the Penpot way: emoji subject, clear body, `AI-assisted-by` trailer. Used by `implement-plan`; also works alone when you say "commit this". | + +## Utilities + +| Skill | What it does | +|---|---| +| [`bat-cat`](skills/bat-cat/SKILL.md) | Read files in the terminal with syntax highlighting and line numbers. | +| [`fd-find`](skills/fd-find/SKILL.md) | Find files by name or pattern, respecting `.gitignore`. | +| [`ripgrep`](skills/ripgrep/SKILL.md) | Fast content search with regular expressions. | +| [`jq-json-processor`](skills/jq-json-processor/SKILL.md) | Slice, filter, and reshape JSON output. | +| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). | +| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. | +| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. | +| [`local-ci`](skills/local-ci/SKILL.md) | Run CI-style lint, test, and format checks for the modules you touched with `scripts/ci`, and read the logs when they fail. | +| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. | +| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. | +| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. | +| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. | + +## A typical round + +1. `/make-a-plan` — you get a plan and a saved file in `.agents/plans/`. +2. `/review-plan` — a second opinion; approve or request changes. +3. `/implement-plan` — the code gets written and committed. Starting from a base branch, it also opens the GitHub issue and the `issue-NNNN` branch; the plans that follow continue on that same branch. +4. `/review-code` — a reviewer checks the commit. +5. `/create-pr` — the branch goes up as a pull request. + +Every step also works on its own, and you can always say what you want +in plain words — the agents pick the right skill from what you say. + +## Adding or changing a skill + +Create a folder here with a `SKILL.md` inside. The file needs `name` and +`description` in its frontmatter, and a clear "When to use" section so +agents know when to reach for it. Keep one job per skill, and keep the +two families apart: flows are named with a verb first; reference skills +end in `-criteria`. diff --git a/.opencode/skills/bat-cat/SKILL.md b/.agents/skills/bat-cat/SKILL.md similarity index 96% rename from .opencode/skills/bat-cat/SKILL.md rename to .agents/skills/bat-cat/SKILL.md index 61ca8def8f..2d67404725 100644 --- a/.opencode/skills/bat-cat/SKILL.md +++ b/.agents/skills/bat-cat/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"i `cat` with syntax highlighting, line numbers, and Git integration. +## When to use + +- Reading or displaying a file in the terminal — prefer it over plain + `cat`: syntax highlighting, line numbers, git-side indicators. + ## Quick Start ### Basic usage diff --git a/.opencode/skills/code-review/SKILL.md b/.agents/skills/code-review-criteria/SKILL.md similarity index 95% rename from .opencode/skills/code-review/SKILL.md rename to .agents/skills/code-review-criteria/SKILL.md index 7fa581efa0..59abf1c21e 100644 --- a/.opencode/skills/code-review/SKILL.md +++ b/.agents/skills/code-review-criteria/SKILL.md @@ -1,9 +1,9 @@ --- -name: code-review -description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch. +name: code-review-criteria +description: Code review criteria — the five review axes, core principles, severity format, and verdict for reviewing code changes. Loaded by the reviewer subagent of the review-code flow. Not a user-facing flow — to review code, use the review-code flow. --- -# Code Review and Quality +# Code Review Criteria and Quality ## Overview @@ -13,11 +13,10 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef ## When to Use -- Before merging any PR or change -- After completing a feature implementation -- When another agent or model produced code you need to evaluate -- When refactoring existing code -- After any bug fix (review both the fix and the regression test) +- The reviewer subagent of the `review-code` flow loads this skill to perform + the review of a code change. +- To review code, always go through the `review-code` flow — never load this + skill directly for that. This is the criteria reference, not the flow. ## Core Principles diff --git a/.opencode/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md similarity index 100% rename from .opencode/skills/create-commit/SKILL.md rename to .agents/skills/create-commit/SKILL.md diff --git a/.opencode/skills/create-issue/SKILL.md b/.agents/skills/create-issue/SKILL.md similarity index 100% rename from .opencode/skills/create-issue/SKILL.md rename to .agents/skills/create-issue/SKILL.md diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md new file mode 100644 index 0000000000..703609c9a1 --- /dev/null +++ b/.agents/skills/create-pr/SKILL.md @@ -0,0 +1,105 @@ +--- +name: create-pr +description: PR flow — open a new PR for the current task branch (validates base branch, commits, issue and push state) or update an existing PR's title or description to match Penpot conventions. Use it when the user asks to open or create a PR, in any phrasing. +--- + +# Create PR + +Two modes. **Open mode** takes the current task branch to a new, validated +PR. **Update mode** rewrites an existing PR's title or description. Gather +information, validate, and act in one pass. If validation fails, STOP with a +single coherent message that lists every problem and states exactly what +information is missing — never fix or work around problems silently. + +Both modes require an authenticated `gh` CLI (`gh auth status`) and never +push — the user pushes from their own shell. + +## When to use + +- The user asks to open or create a NEW PR for the current task branch, in + any phrasing ("open a PR", "create the pull request", "put this up for + review") — or runs `/create-pr`. → **Open mode**. +- The user asks to fix or update an EXISTING PR's title or description to + match conventions. → **Update mode**. + +If the running agent cannot write (for example, the plan agent), say so and +stop — this skill needs the build agent. + +## Open mode + +### 1. Gather context (read-only) + +- Current branch: `git rev-parse --abbrev-ref HEAD`. +- Target base branch: run `./scripts/detect-target-branch` from the repo root. + It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1). +- Commits: `git log --oneline ..HEAD`. +- Push state (local): `git rev-parse --verify origin/` and compare + with HEAD. It reads the local remote-tracking ref — no network, no SSH. It + reflects the last push or fetch this clone knows about. +- Issue: from the session context, or from the branch name — `issue-NNNN` + maps to issue NNNN; recover its title and body with `gh issue view NNNN`. + +### 2. Validate — stop with one message if anything fails + +Run all checks before reporting, then report every failure together: + +1. **Base branch not usable.** If the script fails (exit 1), or its output — + after stripping an optional `remotes/origin/` prefix — is not one of the + canonical branches (`develop`, `staging`, `main`), stop and ask the user + to re-run with more context — for example, passing the base branch + explicitly in their invocation. An explicit base given by the user + overrides the script's output. +2. **On a base branch.** There is no task branch to merge — say so and stop. +3. **No commits.** The branch has no commits ahead of the base — say so and + stop. +4. **No clear issue.** There is no issue in the session context, and the + branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing) + — say so and stop. Exception: the user's invocation says `no issue` / + `without issue` — then continue without an issue reference. +5. **Branch not pushed.** The remote-tracking ref `origin/` is + missing, or `git rev-parse origin/` differs from HEAD — the + branch was never pushed, or has commits the remote does not have. Never + push yourself; ask the user to push and to run `/create-pr` again + afterwards, then stop. + +### 3. Already-open PR + +Check whether a PR already exists for this branch (`gh pr list --head +`). If one exists, report its URL and stop — do not create a second +one. Title or description fixes belong to Update mode. + +### 4. Write and create the PR + +Write the title and body following `mem:workflow/creating-prs` (title format, +description structure, writing principles) and `mem:workflow/creating-commits` +(commit type emojis). Derive the title and body from the commits and, when +there is one, from the issue body. Reference the issue with `Closes #NNNN`. + +```bash +gh pr create --repo penpot/penpot --title "" --body-file /tmp/pr-body.md +``` + +### 5. Report + +Report the PR URL and stop. + +## Update mode + +1. Identify the PR: the number given by the user, or `gh pr list --head + <branch>`. +2. Write the new title and/or body following `mem:workflow/creating-prs`. +3. Apply and verify: + +```bash +gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md +gh pr view <NUMBER> --repo penpot/penpot --json title,body +``` + +4. Report and stop. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: overrides such as `no issue` / +`without issue`, an explicit base branch (`from origin/staging`), a PR number +for Update mode, and so on. diff --git a/.opencode/skills/fd-find/SKILL.md b/.agents/skills/fd-find/SKILL.md similarity index 95% rename from .opencode/skills/fd-find/SKILL.md rename to .agents/skills/fd-find/SKILL.md index e218ac9bfd..7d5e8fae4f 100644 --- a/.opencode/skills/fd-find/SKILL.md +++ b/.agents/skills/fd-find/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id User-friendly alternative to `find` with smart defaults. +## When to use + +- Locating files or directories by name or pattern — prefer it over + plain `find`: simpler syntax, smart defaults, respects `.gitignore`. + ## Quick Start ### Basic search diff --git a/.agents/skills/implement-plan/SKILL.md b/.agents/skills/implement-plan/SKILL.md new file mode 100644 index 0000000000..807b60064c --- /dev/null +++ b/.agents/skills/implement-plan/SKILL.md @@ -0,0 +1,144 @@ +--- +name: implement-plan +description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each; on request ("direct"), no issue and no branch — the commit lands on the current branch. Use it when the user asks to implement or execute a plan, in any phrasing. +--- + +# Implement Plan + +This flow is run once a plan is ready (for example, from plan mode). Execute +the plan already prepared in the current session context. It never pushes — +the user pushes. + +By default it ends with exactly one commit. When the user asks for it +("step by step"), it commits once per task instead and waits for the +user's confirmation after each one (see *Execution modes*). + +## When to use + +- The user asks to implement or execute a plan, in any phrasing: + "implement the plan", "execute it", "go build it" — or runs + `/implement-plan`. +- A ready, reviewed plan is in the session context or a plan file path + was given (typically after `/make-a-plan` or `/review-plan`). + +Do not use it to produce plans — that is the `make-a-plan` flow. + +## 1. Read the plan first + +Identify the plan to execute — from the file path the user gave, the +arguments, or the session context. Read it completely. Read the required +memories before writing any code: `mem:critical-info` and the core memory +of every module the plan touches, plus the deeper memories they reference +(AGENTS.md governs this). + +## 2. Detect the flow (no questions) + +Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the +mode, and announce it in one line before presenting anything. Detection is +read-only: nothing is created until the user confirms (step 3). + +- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**: + a new GitHub issue and a branch `issue-NNNN` will be created after the + user's confirmation. +- **On any other branch** (a feature branch, typically `issue-NNNN`) → + **continue mode**: the implementation continues on the current branch. + No issue or branch is created. The branch name provides the issue + reference when it follows the `issue-NNNN` pattern. + +Arguments override detection: `standalone`, `continue`, `direct` +(`no branch` / `direct commit`), `no issue` / `without issue`, or an +explicit base such as `from origin/develop`. + +**Direct mode** (`direct`, `no branch`, `direct commit`): no issue and +no branch — the implementation and the commit land on the current branch +as it is, even when it is a base branch. Best for small or tooling-only +changes the user wants committed in place. + +**Standalone while already on a feature branch:** stop and explain that this +would stack branches. Ask the user to re-run with an explicit base, for +example `from origin/develop` — then branch from that base instead of HEAD. + +## 3. Present the checklist and wait + +Before touching the repository, show the user the full picture: + +- **The flow**: whether the GitHub issue and the branch will be created + (standalone mode — give the planned branch name, `issue-NNNN` or + `plan-<slug>`), whether you continue on the current branch + (continue mode — name it), or whether everything lands on the current + branch as it is (direct mode — name it, and say so when it is a base + branch). +- **The execution style**: batch or step-by-step (see *Execution modes*). +- A checklist (todolist) of the plan's tasks, in order. + +Then WAIT for the user's explicit confirmation. Do not start until you +have it. If the plan has no discrete tasks, ask the user how to split +it, or propose running it as a single change. + +## 4. Execute the plan + +**Standalone setup, after the confirmation:** create the issue with the +**`create-issue`** skill, following the *Creating Issues from Draft Body* +flow in `mem:workflow/creating-issues`. Derive the issue title and body +from the plan, capture the new issue's number — call it **NNNN** — and +create the branch from the current HEAD: + +``` +git checkout -b issue-NNNN +``` + +If the arguments say `no issue` / `without issue`, skip the issue and +create a branch named `plan-<slug>` instead, where `<slug>` is the plan +title, lowercase and hyphen-separated. + +If the arguments say `direct` / `no branch` / `direct commit`, skip the +issue and the branch: implement and commit on the current branch as it +is. If it is a base branch, the checklist presentation already said so — +no further confirmation is needed. + +### Batch mode (default) + +Implement every task in one go. Work methodically, keeping changes +focused on what the issue requires. Respect the plan's proposed +parallelization when it applies. + +When the implementation is complete, load the **`create-commit`** skill +and follow its workflow to commit the changes. Provide a brief summary +of what was implemented and why, the issue reference (`issue-NNNN`) when +there is one, and the model name you are running as so the +`AI-assisted-by` trailer is set correctly. + +### Step-by-step mode (on request) + +When the user asks for it — "step by step", "task by task", "one commit +per task" — loop one task at a time: + +- Execute exactly ONE task. +- Commit it now: load the **`create-commit`** skill and follow it — + one commit per task, never two tasks in one commit. Same inputs as + always: what and why, the issue reference, your model name. +- Show the user the result (what changed, files touched, how it was + verified). +- WAIT for the user's confirmation before starting the next task. + +Never batch in this mode: no two tasks in one commit, and no new task +before the user confirms. If a task turns out much bigger than planned, +stop and ask the user before splitting it. + +## When you are done + +End by suggesting the next steps (suggestions, not a required pipeline — any +instruction from me overrides them): + +- `/review-code` — to review the changes just committed; it routes to + `/make-a-plan` by itself if the findings need one. +- `/create-pr` — when the task is done and the branch is ready to merge. + +## User context + +Extra context in the user's invocation (the message that triggered this +skill) plays the role command arguments play elsewhere: `standalone`, +`continue`, `direct` (`no branch` / `direct commit`), `no issue` / +`without issue`, an explicit base such as `from origin/develop`, or +`step by step` / `one commit per task` for the step-by-step execution +mode. Modes combine freely, for example "standalone step by step". diff --git a/.opencode/skills/jq-json-processor/SKILL.md b/.agents/skills/jq-json-processor/SKILL.md similarity index 94% rename from .opencode/skills/jq-json-processor/SKILL.md rename to .agents/skills/jq-json-processor/SKILL.md index 83fe48d7bf..11687a09ff 100644 --- a/.opencode/skills/jq-json-processor/SKILL.md +++ b/.agents/skills/jq-json-processor/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id Process, filter, and transform JSON data with jq. +## When to use + +- Parsing, filtering, or transforming JSON from commands, files, or API + responses — slicing, reshaping, or validating JSON output. + ## Quick Examples ### Basic filtering diff --git a/.agents/skills/local-ci/SKILL.md b/.agents/skills/local-ci/SKILL.md new file mode 100644 index 0000000000..5cda6acf89 --- /dev/null +++ b/.agents/skills/local-ci/SKILL.md @@ -0,0 +1,95 @@ +--- +name: local-ci +description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors. +--- + +# Local CI + +Run the same checks CI runs, locally, for the modules you touched, with +`scripts/ci`. Each task writes a log file; the final summary says what +passed and what failed. + +Full details: `mem:scripts/ci` (file: `.serena/memories/scripts/ci.md`) + +## When to use + +- After implementing or fixing code — verify every module you touched + before declaring the work done. +- When the user asks to run CI, lint, tests, or format checks locally. +- When you changed `common/` — validate its consumers too. + +**Skip:** while exploring, planning, or reading code. + +## Command reference + +Run from the repo root: + +```bash +./scripts/ci [OPTIONS] [MODULES...] +``` + +Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp` +`plugins` `library`, or `--all` for every module. + +With no task flags it runs three tasks per module, in order: **lint**, +**test**, **fmt** (format check; `--fix` formats files instead). + +| Flag | Effect | +|------|--------| +| `--all` | Run every module | +| `--exclude MOD` | Skip one module (repeatable) | +| `--lint` / `--no-lint` | Run only lint / drop lint | +| `--test` / `--no-test` | Run only tests / drop tests | +| `--fmt` / `--no-fmt` | Run only format check / drop it | +| `--fix` | Format files instead of checking (other tasks unaffected) | +| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files | +| `--fail-fast` | Stop at the first failure | +| `--quiet` | Suppress failure output | +| `--dry-run` | Show what would run, execute nothing | +| `--clean` | Delete the `.ci-logs/` directory | + +## Reading failures + +Every task writes its full output to `.ci-logs/<module>-<task>.log`. On +failure the script prints only the last 30 lines. To diagnose a failure, +**read the log file** — never re-run the command piped through filters +(repo rule: redirect to a file first, then read it). The exit code is 1 +when any task failed; the summary lists each failed `module:task` and its +log path. + +## Typical workflows + +```bash +# Verify a module you changed: lint + tests + format check +./scripts/ci frontend + +# Fast pass while iterating: lint only +./scripts/ci --lint frontend + +# Lint + format check, skip the long test suite +./scripts/ci --no-test frontend + +# Format the module without running the test suite +./scripts/ci --fix --no-test frontend + +# Broke delimiters in Clojure/CLJS files: repair first, then lint +./scripts/ci --paren-repair frontend +./scripts/ci --lint frontend + +# Changed common/ — validate its consumers too +./scripts/ci frontend backend exporter + +# Preview what would run, without running it +./scripts/ci --dry-run --all +``` + +## Gotchas + +- Run from the repo root. +- Test tasks are long-running (backend runs `clojure -M:dev:test`); give + the bash call a generous timeout (10–20 minutes) instead of letting it + time out mid-run. +- `mcp` has no lint task — it shows as skipped, not failed. +- `--paren-repair` only fixes delimiters; run lint afterwards to catch + what remains. See `mem:scripts/paren-repair`. +- What to run and how to read test results: `mem:testing`. diff --git a/.agents/skills/make-a-plan/SKILL.md b/.agents/skills/make-a-plan/SKILL.md new file mode 100644 index 0000000000..cd9129f85b --- /dev/null +++ b/.agents/skills/make-a-plan/SKILL.md @@ -0,0 +1,100 @@ +--- +name: make-a-plan +description: Planning flow — research the subject of this session, produce an implementation plan with the planner skill, resolve open questions with the user in plain language, and save the final plan to .agents/plans/. Use it when the user asks to plan, design, or break down a task, in any phrasing. +--- + +# Make a Plan + +Act as a senior software engineer: research the subject of this session in depth and +produce a well-grounded, actionable implementation plan. + +If the running agent cannot write (for example, the plan agent), say so and +stop — this skill needs the build agent to save the plan. + +## When to use + +- The user asks to plan, design, or break down a task, in any phrasing: + "make a plan", "how would we build X", "design an approach for Y" — + or runs `/make-a-plan`. +- The user asks to rework or extend an existing plan (for example, after + review findings) — revise the saved plan file in place. + +Do not use it to execute a plan — that is the `implement-plan` flow. + +## Instructions + +1. **Produce the plan** with the `planner` skill. By default, research the + subject of this session and draft the plan yourself. If I ask for it (for + example, `delegated` in the user context), delegate to the `general` subagent + instead — the delegate must also follow the `planner` skill and receive all + the relevant session context (a review, user feedback, and so on). +2. Before asking me to decide anything, explain the plan and every open question in + plain language. Assume I know only the high-level project goal, not the codebase, + architecture, implementation terms, or the problem this task solves. +3. Once all decisions are answered and the plan is final, save it verbatim to the + announced path under `.agents/plans/` (create the directory if it does not + exist). This step is the flow's explicit authorization to write the plan + file — the only write allowed here. If I later ask for changes, update the + saved file directly. +4. Present me with a clear, self-contained summary of the plan's most relevant points + only after all required decisions have been answered. Write it for someone who knows + only the project's high-level goal and may not know the plan's low-level context. + Explain necessary technical language in plain terms, include the problem being + solved and the proposed outcome, and do not assume that listing technical task names + is enough. + +### Hard rule — read-only while planning + +While this flow runs, act read-only: research with read-only tools only. +Never edit source files, never run builds, tests, linters, or any command that +modifies state, and never commit. The single allowed write is the plan file in +step 3. This rule expires when I approve the plan or move on to another task; +then you act as a normal build agent again. + +When the plan contains open questions, do not show them as bare technical questions or +assume that I understand the technical language or technical words used in the plan. +For each question, first explain: + +- What part of the user problem the decision affects. +- The relevant concept from the beginning, with a small concrete example. +- What each available option would make the system do. +- The practical benefits, costs, risks, and user-visible consequences of each option. +- Which option the planner recommends and why. + +Only after that explanation, use the `question` tool to ask the decision with clear, +non-technical option labels. Put the recommended option first and mark it as +`(Recommended)`. Group related questions when their context is shared, but do not ask a +question whose meaning has not already been explained. + +If I say that I do not understand a question or its choices, do not treat my previous +answer as valid. Explain the concepts again from the high-level project goal, use a more +concrete example, explain the implications, and ask the question again with the +`question` tool. Repeat this until I can make an informed choice. If one answer creates +new design consequences or additional decisions, explain those consequences before +asking any new question. + +Distinguish clearly between requirements already fixed by the roadmap or existing +architecture and choices that actually require my input. Do not ask me to choose an +implementation detail when the plan can resolve it safely without changing the public +behavior. If there are no decisions that require my input, say so and present the +summary. + +IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it +after all possible questions have been answered.** The final summary must explain the +problem being solved, the proposed behavior, the main user-visible workflow, important +constraints and risks, what is deliberately out of scope, and the path where the plan +is saved. Never assume that a short list of task names is enough context. End +the final response by suggesting the next steps, in this order: + +1. `/review-plan` — to get a second opinion on the plan before executing it. +2. `/implement-plan` — to execute the plan from the current session context. + +These are suggestions, not a required pipeline — any instruction from me +overrides them (for example, asking you to implement the plan directly). + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: for example, `delegated` to +hand the research and drafting to the `general` subagent, or corrections and +feedback about a previous plan. diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.agents/skills/nrepl-eval/SKILL.md similarity index 86% rename from .opencode/skills/nrepl-eval/SKILL.md rename to .agents/skills/nrepl-eval/SKILL.md index c84dc803c1..0f7a025cbe 100644 --- a/.opencode/skills/nrepl-eval/SKILL.md +++ b/.agents/skills/nrepl-eval/SKILL.md @@ -10,6 +10,12 @@ Evaluate Clojure (or ClojureScript) code via a running nREPL server using Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`) +## When to use + +- Evaluating Clojure or ClojureScript code against the running nREPL + sessions (backend 6064, frontend 3447) — live inspection, patching, or + debugging. + ## Quick Reference ```bash diff --git a/.opencode/skills/plan-review/SKILL.md b/.agents/skills/plan-review-criteria/SKILL.md similarity index 94% rename from .opencode/skills/plan-review/SKILL.md rename to .agents/skills/plan-review-criteria/SKILL.md index 4386d701cc..649bd1118f 100644 --- a/.opencode/skills/plan-review/SKILL.md +++ b/.agents/skills/plan-review-criteria/SKILL.md @@ -1,9 +1,9 @@ --- -name: plan-review -description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human. +name: plan-review-criteria +description: Plan review criteria — the six review axes, severity rubric, approval standard, and output format for reviewing implementation plans. Loaded by the reviewer subagent of the review-plan flow. Not a user-facing flow — to review a plan, use the review-plan flow. --- -# Plan Review +# Plan Review Criteria ## Overview @@ -13,10 +13,10 @@ Multi-dimensional plan review with quality gates. Every plan gets reviewed befor ## When to Use -- After the planner skill produces a plan -- Before starting implementation on any non-trivial task -- When reviewing a plan written by another agent or a human -- When a plan feels too large, vague, or risky to start +- The reviewer subagent of the `review-plan` flow loads this skill to perform + the review of a plan. +- To review a plan, always go through the `review-plan` flow — never load this + skill directly for that. This is the criteria reference, not the flow. **Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do. @@ -87,7 +87,7 @@ Can an implementer actually execute this? ### 6. Proposed Code Quality *(when the plan includes implementation details)* -If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria: +If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-criteria`: - **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)? - **Readability:** Are proposed names descriptive and consistent with project conventions? @@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked: If the plan includes code snippets, types, or API designs: ``` -- Load code-review skill for criteria +- Load code-review-criteria skill for criteria - Check proposed signatures for edge cases - Verify naming follows project conventions - Confirm abstractions follow existing patterns @@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs: ## See Also - For producing plans, use the `planner` skill -- For reviewing implemented code, use `code-review` — also the criteria source for axis 6 +- For reviewing implemented code, use `code-review-criteria` — also the criteria source for axis 6 - For security-specific concerns, see `security-and-hardening` - For testing strategy guidance, see `testing` diff --git a/.opencode/skills/planner/SKILL.md b/.agents/skills/planner/SKILL.md similarity index 93% rename from .opencode/skills/planner/SKILL.md rename to .agents/skills/planner/SKILL.md index 3598a0dc16..0bc58d7b4a 100644 --- a/.opencode/skills/planner/SKILL.md +++ b/.agents/skills/planner/SKILL.md @@ -1,6 +1,6 @@ --- name: planner -description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md. +description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps. --- # Planner @@ -215,9 +215,9 @@ Add explicit checkpoints with the relevant module commands: ## Constraints -- You are **analysis-only** — never create, edit, or delete source code. -- The only file write you may attempt is the plan itself, saved to - `.opencode/plans/`. +- You are **analysis-only** — never create, edit, or delete source code. The + only file you may write is the plan itself, and only when the command or + user explicitly instructs you to save it. - You do **not** run builds, tests, linters, or any commands that modify state. - You do **not** create git commits or interact with version control. - You do **not** execute shell commands beyond read-only searches (`rg`, `ls`, @@ -228,22 +228,23 @@ Add explicit checkpoints with the relevant module commands: ## Output Format The plan is always delivered in the response so the user sees it regardless -of which agent is running the skill. +of which agent is running the skill. By default you never write the plan file; +announce the path instead. Write the file only when the command or user +explicitly instructs you to save it — and then only that file. -Additionally, save the plan to: +Announce the suggested save path: ``` -.opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md +.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md ``` Use today's date in the user's local timezone. The `<plan-one-line-title>` slug is lowercase, hyphen-separated, and a short summary of the task -(e.g. `add-batch-get-profiles-for-file-comments`). Create the -`.opencode/plans/` directory if it does not exist. +(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly +provides a target file path, announce that path instead of the default. -IMPORTANT: The plan agent has write permission specifically for -`.opencode/plans/` — always attempt the write. If the user explicitly provides -a target file path, use that path instead of the default. +End the response by suggesting the next steps: `/review-plan` to get a second +opinion on the plan and `/implement-plan` to execute it. ### Plan Document Template @@ -374,4 +375,6 @@ Before delivering the plan, confirm: - [ ] Task dependencies are identified and ordered correctly - [ ] No task is XL or larger — break it down instead - [ ] Checkpoints exist after every 2-3 tasks +- [ ] The response states the plan's path (saved or suggested) and suggests + `/review-plan` and `/implement-plan` - [ ] The plan is ready for human review diff --git a/.opencode/skills/refine-prompt/SKILL.md b/.agents/skills/refine-prompt/SKILL.md similarity index 100% rename from .opencode/skills/refine-prompt/SKILL.md rename to .agents/skills/refine-prompt/SKILL.md diff --git a/.agents/skills/resolve-git-conflicts/SKILL.md b/.agents/skills/resolve-git-conflicts/SKILL.md new file mode 100644 index 0000000000..7fcfd53283 --- /dev/null +++ b/.agents/skills/resolve-git-conflicts/SKILL.md @@ -0,0 +1,47 @@ +--- +name: resolve-git-conflicts +description: Conflict resolution flow — understand the local git conflicts, present a resolution plan, and resolve them after the user approves it. Never continues the rebase. Use it when the repo has unresolved conflicts (rebase, merge, cherry-pick) or the user asks to resolve them. +--- + +# Resolve Git Conflicts + +Resolve conflicts in the local repository. The user handles finishing the +rebase themselves — you must **never** run `git rebase --continue`, +`git rebase --skip`, `git merge --continue`, or anything similar. + +## When to use + +- The repository has unresolved conflicts — during a rebase, merge, or + cherry-pick — whether the user asks about them or not. +- The user asks to resolve conflicts, in any phrasing: "fix the merge + conflicts", "resolve these", "what's conflicting here?". + +## Phase 1 — Understand the problem (read-only) + +1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. +2. For each conflicted (unmerged) file, understand the situation **without modifying anything**: + - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). + - Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent. + - Identify what each side changed and why, and how they should be combined. + +## Phase 2 — Present the resolution plan + +3. **Present a clear plan to the user before touching any file.** For each conflicted file, state: + - What each side changed and why. + - Your proposed resolution and the reasoning behind it. + - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context). +4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly. +5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything. + +## Phase 3 — Execute + +6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers. + +## Phase 4 — Stage and verify + +7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution. +8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths. + +## Phase 5 — Report + +9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command. diff --git a/.agents/skills/review-code/SKILL.md b/.agents/skills/review-code/SKILL.md new file mode 100644 index 0000000000..bc50d56721 --- /dev/null +++ b/.agents/skills/review-code/SKILL.md @@ -0,0 +1,73 @@ +--- +name: review-code +description: Code review flow — review a diff, PR, or code change, delegating the review to a subagent that follows the code-review-criteria skill. Use it when the user asks to review code or a PR, in any phrasing. +--- + +# Review Code + +Act as a senior software engineer and perform a thorough code review. + +## When to use + +- The user asks to review code, in any phrasing: "review this diff", + "review the PR", "check my changes", "code review" — or runs + `/review-code`. +- A commit, branch, PR, or diff is ready and the user wants it assessed + before merge. + +## Instructions + +1. **Determine what is being reviewed** from the user context: a working-tree + diff, a commit range, a branch, a PR (number or URL), or specific files. If + the target is ambiguous, ask before reviewing. +2. Delegate the review to the `general` subagent (via the task tool), unless the + user specifies another agent. Include in the prompt the + **`code-review-criteria`** skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not + summarize it and do not act on its findings. +4. Right after the review, suggest how to proceed based on the findings. These + are suggestions — the user decides: + - **Approve (no required changes):** say so — there is nothing to address. + - **Minor findings (nits):** applying them directly as-is is fine once the + review is done — no plan needed. + - **Substantive findings:** suggest `/make-a-plan` to make a plan to address + them. + +### Hard rule — read-only while reviewing + +This flow is read-only **for the duration of the review**: from the moment it +starts until the user considers the review finished (including any feedback, +questions, or clarifications about it). During that period, never fix, +implement, edit files or create commits — not even "obvious" fixes derived from +the findings. Once the user explicitly states the review is done (or moves on to +a different task), this rule no longer applies and you act as a normal build +agent again. + +## Instructions for the subagent + +1. Load the **`code-review-criteria`** skill and follow its process and output + format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and + reading all related testing documentation from memories before reviewing. +3. Return in your final message the COMPLETE review, verbatim, exactly as the + skill instructs it to be produced. Do not summarize it — include the full + structured review. + +### Strong rules for the subagent + +1. Do not invent problems. Every finding must be real and actionable. +2. Read-only: do not modify any file and do not create a commit — reviewing + never writes. +3. Be specific and constructive. "This could be better" is not helpful — explain + why and how. +4. Prioritize by impact. One structural issue outweighs ten nits. +5. Missing tests are an issue, not a suggestion. Report as a severity-tagged + finding — never as a recommendation. +6. Skip generated files, lockfile-only changes, and unrelated modifications + unless they introduce security risks. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: for example, a PR number or +URL, a commit range, specific files, or a different agent to run the review. diff --git a/.agents/skills/review-plan/SKILL.md b/.agents/skills/review-plan/SKILL.md new file mode 100644 index 0000000000..a74cb8469a --- /dev/null +++ b/.agents/skills/review-plan/SKILL.md @@ -0,0 +1,71 @@ +--- +name: review-plan +description: Plan review flow — evaluate an implementation plan before it is executed, delegating the review to a subagent that follows the plan-review-criteria skill. Use it when the user asks to review a plan, in any phrasing. +--- + +# Review Plan + +Act as a senior software engineer and perform a thorough review of an +implementation plan. + +## When to use + +- The user asks to review a plan, in any phrasing: "review this plan", + "does this plan look right?", "second opinion on the plan" — or runs + `/review-plan`. +- A plan was just produced (typically by `/make-a-plan`) and the user + wants it evaluated before executing it. + +## Instructions + +1. **Determine the plan under review** from the session context (for example, a + plan just produced by `/make-a-plan`) or from a plan file path given by the + user (typically under `.agents/plans/`). If a file path is given, read the + file first so the complete plan is in context. +2. Delegate the review to the `general` subagent (via the task tool), unless the + user specifies another agent. Include in the prompt the + **`plan-review-criteria`** skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not + summarize it and do not act on its findings. +4. Right after the review, suggest the next step based on the verdict. These + are suggestions — the user decides, and any instruction overrides them: + - **Approve** → suggest `/implement-plan` to execute it. + - **Request changes** → suggest `/make-a-plan` to make a plan to address the + findings. + +### Hard rule — read-only while reviewing + +This flow is read-only **for the duration of the review**: from the moment it +starts until the user considers the review finished (including any feedback, +questions, or clarifications about it). During that period, never fix, +implement, edit files or create commits — not even "obvious" fixes derived from +the findings. Once the user explicitly states the review is done (or moves on to +a different task), this rule no longer applies and you act as a normal build +agent again. + +## Instructions for the subagent + +1. Load the **`plan-review-criteria`** skill and follow its process and output + format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and + reading all related documentation and testing memories before reviewing. +3. Return in your final message the COMPLETE review, verbatim, exactly as the + skill instructs it to be produced. Do not summarize it — include the full + structured review. + +### Strong rules for the subagent + +1. Do not invent problems. Every finding must be real and actionable. +2. Read-only: do not modify any file and do not create a commit — reviewing + never writes. +3. Be specific and constructive. "This could be better" is not helpful — explain + why and how. +4. Prioritize by impact. One structural issue outweighs ten nits. +5. Judge the plan as the implementer would: every task executable without + guessing, ordering follows the dependency graph, risks named. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: for example, a plan file path +to review, or a different agent to run the review. diff --git a/.opencode/skills/ripgrep/SKILL.md b/.agents/skills/ripgrep/SKILL.md similarity index 95% rename from .opencode/skills/ripgrep/SKILL.md rename to .agents/skills/ripgrep/SKILL.md index 31c3a83d5e..1b028d8c58 100644 --- a/.opencode/skills/ripgrep/SKILL.md +++ b/.agents/skills/ripgrep/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id Fast, smart recursive search. Respects `.gitignore` by default. +## When to use + +- Searching file contents across the repo for regex patterns — the + default code search, respects `.gitignore`. + ## Quick Start ### Basic search diff --git a/.opencode/skills/security-and-hardening/SKILL.md b/.agents/skills/security-and-hardening/SKILL.md similarity index 100% rename from .opencode/skills/security-and-hardening/SKILL.md rename to .agents/skills/security-and-hardening/SKILL.md diff --git a/.opencode/skills/ste/SKILL.md b/.agents/skills/ste/SKILL.md similarity index 95% rename from .opencode/skills/ste/SKILL.md rename to .agents/skills/ste/SKILL.md index a53456ccf0..67e474ef6b 100644 --- a/.opencode/skills/ste/SKILL.md +++ b/.agents/skills/ste/SKILL.md @@ -9,6 +9,13 @@ Apply the ASD-STE100 standard to all prose you produce in this task. Do not anno Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance. +## When to use + +Only when the user explicitly invokes it: they type `/ste`, or say "use +the ste skill" / "apply ASD-STE100". Requests like "simplify this", +"make it clearer", or "shorter sentences" do NOT invoke it — respond +normally unless it is named. + ## Step 0 — Classify the text Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section. diff --git a/.opencode/skills/ste/references/examples.md b/.agents/skills/ste/references/examples.md similarity index 100% rename from .opencode/skills/ste/references/examples.md rename to .agents/skills/ste/references/examples.md diff --git a/.opencode/skills/ste/references/word-substitutions.md b/.agents/skills/ste/references/word-substitutions.md similarity index 100% rename from .opencode/skills/ste/references/word-substitutions.md rename to .agents/skills/ste/references/word-substitutions.md diff --git a/.opencode/skills/taiga/SKILL.md b/.agents/skills/taiga/SKILL.md similarity index 94% rename from .opencode/skills/taiga/SKILL.md rename to .agents/skills/taiga/SKILL.md index e63788698c..a5f32565a1 100644 --- a/.opencode/skills/taiga/SKILL.md +++ b/.agents/skills/taiga/SKILL.md @@ -11,6 +11,12 @@ Fetch information from Taiga public API for the **Penpot** project **No authentication required** — only public project data is accessed. +## When to use + +- The user asks about Penpot issues, user stories, or tasks tracked in + Taiga — fetch them via the public API (project id 345963), no + authentication needed. + ## Prerequisites - `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only) diff --git a/.opencode/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md similarity index 100% rename from .opencode/skills/testing/SKILL.md rename to .agents/skills/testing/SKILL.md diff --git a/.opencode/skills/update-changelog/SKILL.md b/.agents/skills/update-changelog/SKILL.md similarity index 100% rename from .opencode/skills/update-changelog/SKILL.md rename to .agents/skills/update-changelog/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/workflows/build-adhoc.yml b/.github/workflows/build-adhoc.yml new file mode 100644 index 0000000000..327c449fc4 --- /dev/null +++ b/.github/workflows/build-adhoc.yml @@ -0,0 +1,44 @@ +name: _ADHOC + +run-name: >- + _ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }}) + +on: + workflow_dispatch: + inputs: + gh_ref: + description: 'Branch/ref to build in penpot/penpot' + type: string + required: true + nitrate_ref: + description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)' + type: string + required: false + force: + description: 'Rebuild and overwrite even if already built/promoted' + type: boolean + required: false + default: false + +jobs: + build-bundle: + uses: ./.github/workflows/build-bundle.yml + secrets: inherit + with: + gh_ref: ${{ inputs.gh_ref }} + force: ${{ inputs.force }} + + build-docker: + needs: build-bundle + uses: ./.github/workflows/build-docker.yml + secrets: inherit + with: + gh_ref: ${{ inputs.gh_ref }} + force: ${{ inputs.force }} + + build-docker-admin-console: + uses: ./.github/workflows/build-docker-admin-console.yml + secrets: inherit + with: + gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }} + force: ${{ inputs.force }} diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index b31450ac60..493a3e44e8 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -9,6 +9,11 @@ on: type: string required: true default: 'develop' + force: + description: 'Rebuild and overwrite even if this version already exists in S3' + type: boolean + required: false + default: false workflow_call: inputs: gh_ref: @@ -16,6 +21,11 @@ on: type: string required: true default: 'develop' + force: + description: 'Rebuild and overwrite even if this version already exists in S3' + type: boolean + required: false + default: false # Literal group name: under `workflow_call`, `github.workflow` resolves to the # caller's workflow, which put this workflow and the other reusable one called @@ -34,6 +44,8 @@ jobs: outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} + sha: ${{ steps.vars.outputs.sha }} + commit_title: ${{ steps.vars.outputs.commit_title }} exists: ${{ steps.check.outputs.exists }} steps: @@ -48,10 +60,12 @@ jobs: run: | echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT + echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT # The uploaded zip carries its version as S3 metadata. If the # existing object was already built from this same commit, the - # whole build job is skipped. + # whole build job is skipped. `force` bypasses this check entirely. - name: Check if this bundle is already built id: check env: @@ -59,6 +73,16 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | + if [ "${{ inputs.force }}" = "true" ]; then + echo "exists=false" >> $GITHUB_OUTPUT + { + echo "### 🔁 Bundle build forced" + echo "" + echo "\`force: true\` — skipping the S3 version check." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + EXISTING_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ --key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \ @@ -117,6 +141,16 @@ jobs: s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \ --metadata bundle-version=${{ needs.check.outputs.bundle_version }} + - name: Write step summary + run: | + { + echo "### ✅ Bundle built" + echo "" + echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)" + echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}" + echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + } >> "$GITHUB_STEP_SUMMARY" + # ── 3. Single failure notification for the whole workflow ───────────── notify: name: Notify failure diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 961ad1dca9..7346450831 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -1,7 +1,16 @@ name: _DEVELOP +run-name: >- + _DEVELOP (develop @ ${{ github.sha }}) + on: workflow_dispatch: + inputs: + force: + description: 'Rebuild and overwrite even if already built/promoted' + type: boolean + required: false + default: false schedule: - cron: '16 5-20 * * 1-5' @@ -15,6 +24,7 @@ jobs: secrets: inherit with: gh_ref: "develop" + force: ${{ inputs.force || false }} build-docker: needs: build-bundle @@ -22,9 +32,11 @@ jobs: secrets: inherit with: gh_ref: "develop" + force: ${{ inputs.force || false }} build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: gh_ref: "develop" + force: ${{ inputs.force || false }} diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml index b3f8384636..dbc28387b6 100644 --- a/.github/workflows/build-docker-admin-console.yml +++ b/.github/workflows/build-docker-admin-console.yml @@ -13,6 +13,11 @@ on: type: string required: false default: 'develop' + force: + description: 'Rebuild and overwrite even if already built' + type: boolean + required: false + default: false workflow_call: inputs: gh_ref: @@ -24,6 +29,11 @@ on: type: string required: false default: 'develop' + force: + description: 'Rebuild and overwrite even if already built' + type: boolean + required: false + default: false secrets: ORG_WORKFLOW_TOKEN: description: 'Token with Actions write access on penpot-nitrate' @@ -47,6 +57,7 @@ jobs: gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \ -f gh_ref="$GH_REF" \ + -f force="${{ inputs.force }}" \ -f caller_run_id="$DISTINCT_ID" \ -f caller_run_url="$CALLER_URL" diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index b7bb794776..ade22b78d1 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -8,6 +8,11 @@ on: type: string required: true default: 'develop' + force: + description: 'Rebuild and overwrite even if this sha is already promoted' + type: boolean + required: false + default: false workflow_call: inputs: gh_ref: @@ -15,6 +20,11 @@ on: type: string required: true default: 'develop' + force: + description: 'Rebuild and overwrite even if this sha is already promoted' + type: boolean + required: false + default: false # Literal group name: under `workflow_call`, `github.workflow` resolves to the # caller's workflow, which put this workflow and the other reusable one called @@ -42,6 +52,7 @@ jobs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} sha: ${{ steps.vars.outputs.sha }} + commit_title: ${{ steps.vars.outputs.commit_title }} exists: ${{ steps.check.outputs.exists }} steps: @@ -60,6 +71,7 @@ jobs: GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT + echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT BUNDLE_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ @@ -71,7 +83,8 @@ jobs: # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every # image was built AND every branch tag was moved. Marker present - # means there is nothing at all to do for this commit. + # means there is nothing at all to do for this commit. `force` + # bypasses this check entirely. - name: Check if this image set is already built id: check env: @@ -79,6 +92,21 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | + if [ "${{ inputs.force }}" = "true" ]; then + echo "exists=false" >> $GITHUB_OUTPUT + mkdir -p "$BUNDLE_CACHE" + find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true + ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip" + aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" + mv "$ZIP.$$.tmp" "$ZIP" + { + echo "### 🔁 Image set build forced" + echo "" + echo "\`force: true\` — skipping the S3 marker check." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ --key "markers/images-sha-${{ steps.vars.outputs.sha }}" \ @@ -138,7 +166,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - # To avoid the “429 Too Many Requests” error when downloading + # To avoid the "429 Too Many Requests" error when downloading # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry @@ -258,9 +286,16 @@ jobs: run: | echo "${{ github.run_id }}" | aws s3 cp - \ "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}" + + - name: Write step summary + run: | { echo "### ✅ Image set promoted" echo "" + echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)" + echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}" + echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + echo "" echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 1523e4d7df..c2e3dad823 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -1,7 +1,16 @@ name: _STAGING +run-name: >- + _STAGING (staging) + on: workflow_dispatch: + inputs: + force: + description: 'Rebuild and overwrite even if already built/promoted' + type: boolean + required: false + default: false schedule: - cron: '36 5-20 * * 1-5' @@ -15,6 +24,7 @@ jobs: secrets: inherit with: gh_ref: "staging" + force: ${{ inputs.force || false }} build-docker: needs: build-bundle @@ -22,9 +32,11 @@ jobs: secrets: inherit with: gh_ref: "staging" + force: ${{ inputs.force || false }} build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: gh_ref: "staging" + force: ${{ inputs.force || false }} diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index aa6a2b8357..bfa30fb67b 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -1,7 +1,16 @@ name: _TAG +run-name: >- + _TAG (${{ github.ref_name }} @ ${{ github.sha }}) + on: workflow_dispatch: + inputs: + force: + description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)' + type: boolean + required: false + default: false push: tags: - '*' @@ -18,6 +27,7 @@ jobs: secrets: inherit with: gh_ref: ${{ github.ref_name }} + force: ${{ inputs.force || false }} build-docker: needs: build-bundle @@ -25,12 +35,14 @@ jobs: secrets: inherit with: gh_ref: ${{ github.ref_name }} + force: ${{ inputs.force || false }} build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: gh_ref: ${{ github.ref_name }} + force: ${{ inputs.force || false }} notify: name: Notifications diff --git a/.gitignore b/.gitignore index cc695a46e3..722f0e219d 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,7 @@ opencode.json /.playwright-mcp /.devenv/mcp/ /opencode.json -/.opencode/plans +/.agents/plans /.opencode/reports /.opencode/prompts /.ci-logs diff --git a/.opencode/commands/create-pr.md b/.opencode/commands/create-pr.md new file mode 100644 index 0000000000..a6c21f7bb2 --- /dev/null +++ b/.opencode/commands/create-pr.md @@ -0,0 +1,10 @@ +--- +description: Create a PR for the current task branch or update an existing one — loads and follows the create-pr skill +agent: build +--- + +Load the **`create-pr`** skill and follow it as your only instruction. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md index 8ecd1bd537..eaf9d29fc3 100644 --- a/.opencode/commands/implement-plan.md +++ b/.opencode/commands/implement-plan.md @@ -1,39 +1,10 @@ --- -description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill +description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch, or commits on the current branch with "direct" — loads and follows the implement-plan skill agent: build --- -This command is run once a plan is ready (for example, from plan mode). Execute -the plan already prepared in the current session context. Follow these steps in order. +Load the **`implement-plan`** skill and follow it as your only instruction. -## 1. Create the issue +## User input, overrides and additional context -Use the **`create-issue`** skill, following the *Creating Issues from Draft Body* -flow in `mem:workflow/creating-issues`. Derive the issue title and body from the -plan. Capture the new issue's number — call it **NNNN** (needed for the branch -name and the commit reference). - -## 2. Create the branch - -Create and switch to a branch named after the issue: - -``` -git checkout -b issue-NNNN -``` - -(Replace NNNN with the issue number from step 1.) - -## 3. Execute the plan - -Implement the prepared plan from the session context. Work methodically, keeping -changes focused on what the issue requires. Do not commit — the commit happens in -step 4. - -## 4. Commit with the create-commit skill - -After the implementation is complete, load the **`create-commit`** skill and -follow its workflow to commit the changes. Provide a brief summary of what was -implemented and why, the issue reference (`issue-NNNN`), and the model name you -are running as so the `AI-assisted-by` trailer is set correctly. - -Do not push. Pushing is handled separately by the user. +$ARGUMENTS diff --git a/.opencode/commands/make-a-plan.md b/.opencode/commands/make-a-plan.md new file mode 100644 index 0000000000..27a2be5559 --- /dev/null +++ b/.opencode/commands/make-a-plan.md @@ -0,0 +1,10 @@ +--- +description: Investigate the chosen task, produce an implementation plan, and save it — loads and follows the make-a-plan skill +agent: build +--- + +Load the **`make-a-plan`** skill and follow it as your only instruction. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/resolve-git-conflicts.md b/.opencode/commands/resolve-git-conflicts.md index 1b17ca0001..05b13d3a4b 100644 --- a/.opencode/commands/resolve-git-conflicts.md +++ b/.opencode/commands/resolve-git-conflicts.md @@ -1,40 +1,6 @@ --- -description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase +description: Resolve local git conflicts and stage the resolved files; never continues the rebase — loads and follows the resolve-git-conflicts skill agent: build --- -# Fix Git Conflicts - -Resolve conflicts in the local repository. The user handles finishing the -rebase themselves — you must **never** run `git rebase --continue`, -`git rebase --skip`, `git merge --continue`, or anything similar. - -## Phase 1 — Understand the problem (read-only) - -1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. -2. For each conflicted (unmerged) file, understand the situation **without modifying anything**: - - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). - - Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent. - - Identify what each side changed and why, and how they should be combined. - -## Phase 2 — Present the resolution plan - -3. **Present a clear plan to the user before touching any file.** For each conflicted file, state: - - What each side changed and why. - - Your proposed resolution and the reasoning behind it. - - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context). -4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly. -5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything. - -## Phase 3 — Execute - -6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers. - -## Phase 4 — Stage and verify - -7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution. -8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths. - -## Phase 5 — Report - -9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command. +Load the **`resolve-git-conflicts`** skill and follow it as your only instruction. diff --git a/.opencode/commands/review-code.md b/.opencode/commands/review-code.md new file mode 100644 index 0000000000..4bea9ab7ff --- /dev/null +++ b/.opencode/commands/review-code.md @@ -0,0 +1,10 @@ +--- +description: Code review — review a diff, PR, or code change — loads and follows the review-code skill +agent: build +--- + +Load the **`review-code`** skill and follow it as your only instruction. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/review-plan.md b/.opencode/commands/review-plan.md new file mode 100644 index 0000000000..90bc6161a9 --- /dev/null +++ b/.opencode/commands/review-plan.md @@ -0,0 +1,10 @@ +--- +description: Plan review — evaluate an implementation plan before executing it — loads and follows the review-plan skill +agent: build +--- + +Load the **`review-plan`** skill and follow it as your only instruction. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/skills/create-pr/SKILL.md b/.opencode/skills/create-pr/SKILL.md deleted file mode 100644 index 4980048849..0000000000 --- a/.opencode/skills/create-pr/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: create-pr -description: Create or update a GitHub PR following Penpot conventions. ---- - -# Skill: create-pr - -Create or update a GitHub PR. Read and follow: -- `mem:workflow/creating-prs` — title format, description structure, writing principles -- `mem:workflow/creating-commits` — commit type emojis - -## When to Use - -- Creating a new PR from a feature branch -- Updating an existing PR's title or description to match conventions - -## Prerequisites - -- `gh` CLI authenticated (`gh auth status`) - -## Commands - -**Create:** - -```bash -gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md -``` - -**Update:** - -```bash -gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md -``` - -**Verify:** - -```bash -gh pr view <NUMBER> --repo penpot/penpot --json title,body -``` diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 117e8ff464..26fcfe2971 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -14,7 +14,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer) - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type) - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line) +- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace + layout, `corepack use` sweep order, the stamp-missing-field and + ignored-builds gotchas, verification steps) - **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked. +- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request. - You have access to the GitHub CLI `gh` or corresponding MCP tools. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. @@ -70,6 +74,14 @@ module. You can read it from `mem:<MODULE>/core` - `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`. +- `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm + workspaces (root, modules, member packages). Keeps the shared pnpm store + at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and + `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`. +- `scripts/ci` — CI orchestration script: runs lint, tests, and format + checks per module (`frontend backend common render-wasm exporter mcp + plugins library`). Logs go to `.ci-logs/`; read the log file on failure. + See `mem:scripts/ci`. # Dependency graph diff --git a/.serena/memories/scripts/ci.md b/.serena/memories/scripts/ci.md new file mode 100644 index 0000000000..69d3fa6968 --- /dev/null +++ b/.serena/memories/scripts/ci.md @@ -0,0 +1,61 @@ +# CI (scripts/ci) + +`scripts/ci` runs CI-style checks — lint, tests, format — for one or more +monorepo modules and prints a per-task summary. It is the local equivalent +of CI; use it to verify changes before declaring work done. + +## When to use + +- After implementing or fixing code in a module: run its checks before + finishing (AGENTS.md: run the applicable lint and format checks). +- When `common/` changed: validate its consumers too (frontend, backend, + exporter; see the dependency graph in `mem:critical-info`). +- To fix formatting across a module (`--fix`) or repair delimiters + (`--paren-repair`) before linting. + +## How to use (CLI) + +Run from the repo root: + +```bash +./scripts/ci MODULE... # lint + test + fmt per module +./scripts/ci --all --no-test # lint + fmt on all modules +./scripts/ci --lint frontend # lint only +./scripts/ci --fix --no-test frontend # format files, skip tests +./scripts/ci --paren-repair --all # fix delimiters in all Clojure modules +./scripts/ci --dry-run --all # preview what would run +``` + +Modules: `frontend backend common render-wasm exporter mcp plugins library`. + +Flags: + +- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats + instead). +- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` / + `--no-test` / `--no-fmt` drop one task from the default set. +- `--paren-repair` runs only the delimiter repair — it wraps + `scripts/paren-repair` over each module's Clojure/CLJS sources; see + `mem:scripts/paren-repair`. +- `--all` selects every module; `--exclude MOD` drops one (repeatable). +- `--fail-fast` stops at the first failure; `--quiet` suppresses failure + output; `--dry-run` prints commands without running; `--clean` removes + the log directory. + +## Logs and exit codes + +- Full output of every task: `.ci-logs/<module>-<task>.log`. +- On failure the script prints the last 30 lines; the final summary lists + every failed `module:task` with its log path. +- Exit code 0 when all selected tasks passed, 1 otherwise. +- Diagnose failures by reading the log file — never pipe test output + through filters (AGENTS.md hard rule). + +## Notes + +- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`, + `./test`, and `cargo fmt`. +- Test tasks are long-running (backend: `clojure -M:dev:test`); use a + generous timeout when calling it from an agent shell. +- Skill entry point: `.agents/skills/local-ci/SKILL.md`. +- Testing principles and output discipline: `mem:testing`. diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 9aff0b50c1..54a2993fa8 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -351,5 +351,5 @@ gh issue view <NUMBER> --repo penpot/penpot --json title ## See Also - End-to-end orchestration entry point: the `create-issue` skill at - `.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry + `.agents/skills/create-issue/SKILL.md`. The skill is a thin entry point; this memory is the canonical home for all issue-creation rules. diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md new file mode 100644 index 0000000000..9f4ba133df --- /dev/null +++ b/.serena/memories/workflow/updating-pnpm.md @@ -0,0 +1,89 @@ +# Updating pnpm Across All Workspaces + +Canonical procedure. Run it from the repo root with the log redirected to a +file (never pipe tool output through filters). + +## Layout facts + +- The repo has 11 pnpm workspaces, each with its own `pnpm-workspace.yaml` + and `pnpm-lock.yaml`: the repo root plus `backend`, `common`, `docs`, + `exporter`, `frontend`, `library`, `mcp`, `media-processor`, `plugins`, + and `render-wasm`. +- Every package inside a module workspace (for example all `plugins/apps/*` + and `plugins/libs/*` packages) is a plain member of that module's + workspace. Members must not carry their own `pnpm-workspace.yaml` or + `pnpm-lock.yaml`; their dependencies resolve through the parent + workspace's lockfile. +- One shared pnpm store for the whole repo: `<repo>/.pnpm-store`. Every + workspace yaml sets it explicitly: `storeDir: .pnpm-store` at the root, + `storeDir: ../.pnpm-store` in each module. pnpm resolves the value + against the workspace root, so all workspaces land on the same store. + Do not remove these lines: nested workspaces do not inherit settings, + and without them each workspace may resolve a different store. +- The store survives `node_modules` cleans. It is content-addressed and + integrity-verified, so it cannot go stale; staleness lives in + node_modules. Only `scripts/clean-node-modules --store` removes it. +- Every `package.json` (about 35 of them) must carry a `packageManager` field + with the identical `pnpm@<version>+sha512.<hash>` value. Do not let them drift. +- CI pins no pnpm version; workflows rely on corepack reading + `packageManager`. Fixing the fields fixes CI. + +## Procedure + +1. Resolve the target tag first and note the version. Example: + `npm view pnpm dist-tags --json` for `next-12` (latest 12.x). The tag + moves over time; always re-check. +2. List every directory with a `package.json`, excluding `node_modules` + (`fd -H -t f package.json -E node_modules`). This list is the work set; + do not maintain a hand-written list. +3. Run `corepack use pnpm@<tag>` in workspace roots first, then members. + `corepack use` stamps `packageManager` in the nearest package.json and + runs an install. Member runs repeat the workspace install; after the root + run they are quick no-ops. +4. If a run fails, fix the cause (see gotchas) and re-run that directory. + +## Gotchas + +- `corepack use` only updates an existing `packageManager` field. If a + package.json lacks the field, corepack walks up to the nearest ancestor + that has one and stamps that file instead; the member stays unstamped. + After the sweep, assert every package.json carries the field. For a + missing one, insert the identical `pnpm@<version>+sha512.<hash>` string, + then re-run `corepack use pnpm@<tag>` in that directory. +- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes + a placeholder scaffold into its `pnpm-workspace.yaml`: + `allowBuilds: esbuild: set this to true or false` plus + `ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`. + Replace the placeholder and drop the `ignoredBuiltDependencies` entry, + then re-run. +- `plugins/apps/composable-test-suite` once had its own + `pnpm-workspace.yaml` and acted as a nested workspace root. That state is + gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up, + so a nested one silently forks install and lockfile behavior. Do not + reintroduce it. +- Expect metadata-only lockfile diffs when only the pnpm version moves: + the pnpm self-reference entries, plus a new `packageManagerDependencies` + section in lockfiles last written by older pnpm. Large diffs mean + re-resolution; inspect them before accepting. + +## Verification + +- Every `packageManager` field is byte-identical (same version and hash). +- `pnpm --version` in each workspace prints the target version. +- `pnpm install --frozen-lockfile` succeeds in each of the 11 workspaces. +- `git diff` on lockfiles matches the expectations above. + +## Cleaning stale node_modules + +- `scripts/clean-node-modules` removes every workspace `node_modules`: the + repo root, all module workspaces, and all member packages. Use it when + installs misbehave after dependency changes: clean, reinstall, done. +- Flags: `-n/--dry-run` lists without deleting; `--store` also removes the + shared pnpm store at `<repo>/.pnpm-store` (the next install re-downloads + what it held). `external/` (vendored dependency trees with their own + lifecycles) and `.opencode/` are always ignored. +- The script never touches the pnpm store by default, so the reinstall + after cleaning reuses cached packages (zero downloads). +- After cleaning, run `pnpm install` in each workspace root to restore the + development environment; `frontend` postinstall also reinstalls and + builds `plugins-runtime`. diff --git a/AGENTS.md b/AGENTS.md index 06507f18d1..bc2b4c9865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS. - **Never amend a commit that has been pushed** unless the user explicitly asks. If the user pushes, treat that commit as final from the agent's side. +- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is + generated from GitHub milestones during the release process; update it only + via the `update-changelog` skill flow or on explicit user request. - **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.). Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file. This prevents hiding test failures. See `mem:testing` for details. @@ -142,6 +145,6 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend). - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. -- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`. +- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`. - `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`. diff --git a/CHANGES.md b/CHANGES.md index 1618253d1b..1666868044 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## 2.19.0 (Unreleased) +### :rocket: Epics and highlights + +- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237)) +- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807)) +- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958)) + ### :bug: Bugs fixed - Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305)) @@ -11,6 +17,20 @@ - Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454)) - Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156)) - Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199)) +- Fix "Cannot assign to read only property 'toString'" error during text resize (by @makesomethingshit) [#10168](https://github.com/penpot/penpot/issues/10168) (PR: [#11521](https://github.com/penpot/penpot/pull/11521)) +- Fix plugin postMessage channel broadcasting messages to all plugins without origin validation [#10968](https://github.com/penpot/penpot/issues/10968) (PR: [#10970](https://github.com/penpot/penpot/pull/10970)) +- Fix MCP plugin page navigation while connected crashing the workspace (by @makesomethingshit) [#11001](https://github.com/penpot/penpot/issues/11001) (PR: [#11521](https://github.com/penpot/penpot/pull/11521)) +- Fix shortcut search never matching on key combination, only on action label [#11003](https://github.com/penpot/penpot/issues/11003) (PR: [#11081](https://github.com/penpot/penpot/pull/11081)) +- Fix Shift + special character key shortcut capturing the shifted character instead of the physical key [#11004](https://github.com/penpot/penpot/issues/11004) (PR: [#11081](https://github.com/penpot/penpot/pull/11081)) +- Fix reassigning the "Paste" shortcut not updating the UI or taking effect in the workspace [#11005](https://github.com/penpot/penpot/issues/11005) (PR: [#11081](https://github.com/penpot/penpot/pull/11081)) +- Fix font-size dropdown clipping multi-digit values in Firefox (by @0xTHAC0) [#11008](https://github.com/penpot/penpot/issues/11008) (PR: [#11162](https://github.com/penpot/penpot/pull/11162), [#11500](https://github.com/penpot/penpot/pull/11500)) +- Fix exporting shortcuts producing an invalid "toggle-fullscreen" entry that breaks re-import [#11032](https://github.com/penpot/penpot/issues/11032) (PR: [#11081](https://github.com/penpot/penpot/pull/11081)) +- Fix plugin API missing permission checks in tokens, shapes, variants, flows, layouts, and user identity [#11137](https://github.com/penpot/penpot/issues/11137) (PR: [#11139](https://github.com/penpot/penpot/pull/11139)) +- Fix library summary Redis cache keys omitting the tenant [#11407](https://github.com/penpot/penpot/issues/11407) (PR: [#11408](https://github.com/penpot/penpot/pull/11408)) +- Fix active theme name in the inspect tab displaying an id instead of the name [#11437](https://github.com/penpot/penpot/issues/11437) (PR: [#11439](https://github.com/penpot/penpot/pull/11439)) +- Fix triple-click not selecting the full line in text editor v3 [#11483](https://github.com/penpot/penpot/issues/11483) (PR: [#11493](https://github.com/penpot/penpot/pull/11493)) +- Fix pasted text losing formatting on last lines after resizing and adding new lines from the top [#11501](https://github.com/penpot/penpot/issues/11501) (PR: [#11503](https://github.com/penpot/penpot/pull/11503)) +- Fix variant property dropdown appearing empty and throwing an internal error when the component has no sibling variants [#11524](https://github.com/penpot/penpot/issues/11524) (PR: [#11499](https://github.com/penpot/penpot/pull/11499)) ### :sparkles: New features & Enhancements @@ -19,9 +39,22 @@ - Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807)) - Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237)) - Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958)) +- Add support for internal libraries and file sync for Design Tokens [#9334](https://github.com/penpot/penpot/issues/9334) +- Warn self-hosted users when their Penpot version is outdated and surface what they're missing [#10497](https://github.com/penpot/penpot/issues/10497) (PR: [#11411](https://github.com/penpot/penpot/pull/11411)) +- Add dedicated RPC methods for plugin registry operations with permission validation [#10952](https://github.com/penpot/penpot/issues/10952) (PR: [#10957](https://github.com/penpot/penpot/pull/10957)) +- Document MCP and internal resolver environment variables (by @ShreyashAgare26) [#11318](https://github.com/penpot/penpot/issues/11318) (PR: [#11572](https://github.com/penpot/penpot/pull/11572)) +- Add tokens source indicator to assets tab [#11365](https://github.com/penpot/penpot/issues/11365) (PR: [#11439](https://github.com/penpot/penpot/pull/11439)) +- Export multiple fills to SVG [#11466](https://github.com/penpot/penpot/issues/11466) (PR: [#11467](https://github.com/penpot/penpot/pull/11467)) +- Add Penpot-specific board size presets (file thumbnail, template cover, plugin icon/cover) [#11561](https://github.com/penpot/penpot/issues/11561) (PR: [#11565](https://github.com/penpot/penpot/pull/11565)) ## 2.18.0 (Unreleased) +### :rocket: Epics and highlights + +- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354)) +- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146)) +- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275)) + ### :bug: Bugs fixed - Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392)) @@ -360,7 +393,7 @@ ### :rocket: Epics and highlights -- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113)) +- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR: [#9113](https://github.com/penpot/penpot/pull/9113)) - Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358) ### :sparkles: New features & Enhancements diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..70bf134a48 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +Read and follow the instructions in `AGENTS.md`. + +Treat `AGENTS.md` as the canonical project instruction file. diff --git a/backend/package.json b/backend/package.json index c6baf43f73..f29469a28e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index a0b16465e9..dfe9e50563 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.3.4 + version: 12.3.4 + +packages: + + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} + cpu: [x64] + os: [win32] + + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.3.4': + optional: true + + '@pnpm/exe.darwin-x64@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64@12.3.4': + optional: true + + '@pnpm/exe.linux-x64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-x64@12.3.4': + optional: true + + '@pnpm/exe.win32-arm64@12.3.4': + optional: true + + '@pnpm/exe.win32-x64@12.3.4': + optional: true + + pnpm@12.3.4: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 + +--- lockfileVersion: '9.0' settings: diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml index b3fbd9192b..3ebe73f82d 100644 --- a/backend/pnpm-workspace.yaml +++ b/backend/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +storeDir: ../.pnpm-store + minimumReleaseAgeExclude: - brace-expansion@5.0.8 || 5.0.9 diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index b3fdc80d7e..4581e23c73 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -1037,7 +1037,7 @@ provider (prepare-organization-sso-provider cfg sso) _info (get-info cfg provider state code) session (session/get-session request) - exp (ct/in-future {:minutes 15})] + exp (ct/in-future {:hours 4})] (when (and session organization-id) (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 6f69047382..d338bb4813 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -58,6 +58,7 @@ :objects-storage-fs-directory "assets" :auth-token-cookie-name "auth-token" + :auth-token-cookie-max-age-absolute (ct/duration {:days 30}) :assets-path "/internal/assets/" :smtp-default-reply-to "Penpot <no-reply@example.com>" @@ -206,6 +207,7 @@ [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] + [:auth-token-cookie-max-age-absolute {:optional true} ::ct/duration] [:registration-domain-whitelist {:optional true} [::sm/set :string]] [:email-verify-threshold {:optional true} ::ct/duration] diff --git a/backend/src/app/graph/arrow.clj b/backend/src/app/graph/arrow.clj index 55be1b8b64..f1e4715ba2 100644 --- a/backend/src/app/graph/arrow.clj +++ b/backend/src/app/graph/arrow.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.arrow "Bulk Ladybug ingest through in-memory Arrow. diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj index 277b1ae177..10e206941c 100644 --- a/backend/src/app/graph/debug.clj +++ b/backend/src/app/graph/debug.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.debug "In-memory Ladybug sessions for the debug graph console." diff --git a/backend/src/app/graph/ingest.clj b/backend/src/app/graph/ingest.clj index af0644ee4d..2e6d9bed1f 100644 --- a/backend/src/app/graph/ingest.clj +++ b/backend/src/app/graph/ingest.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.ingest "Penpot file -> Ladybug graph projection." diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj index 81d117c26e..44c4bd1f64 100644 --- a/backend/src/app/graph/ladybug.clj +++ b/backend/src/app/graph/ladybug.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.ladybug "Ladybug access layer for graph-backed Penpot. diff --git a/backend/src/app/graph/meta.clj b/backend/src/app/graph/meta.clj index 129babd1fa..074ff6461f 100644 --- a/backend/src/app/graph/meta.clj +++ b/backend/src/app/graph/meta.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.meta "`GraphMeta`: the graph's own account of who built it and from what. diff --git a/backend/src/app/graph/projection/document.clj b/backend/src/app/graph/projection/document.clj index 9d0eec6851..c513f3e13a 100644 --- a/backend/src/app/graph/projection/document.clj +++ b/backend/src/app/graph/projection/document.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.projection.document "Project a Penpot file-data map into Ladybug nodes and structural edges. diff --git a/backend/src/app/graph/projection/transforms.clj b/backend/src/app/graph/projection/transforms.clj index dc87392983..e37da3d477 100644 --- a/backend/src/app/graph/projection/transforms.clj +++ b/backend/src/app/graph/projection/transforms.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.projection.transforms "Derived graph links: edges a reader could compute from the projected diff --git a/backend/src/app/graph/report.clj b/backend/src/app/graph/report.clj index f026ff3a52..41c332013d 100644 --- a/backend/src/app/graph/report.clj +++ b/backend/src/app/graph/report.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.report (:require diff --git a/backend/src/app/graph/schema.clj b/backend/src/app/graph/schema.clj index 0aace14bea..63a84b577e 100644 --- a/backend/src/app/graph/schema.clj +++ b/backend/src/app/graph/schema.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.schema "Ladybug DDL facade for the graph-backed Penpot vertical slice. diff --git a/backend/src/app/graph/schema/contract.clj b/backend/src/app/graph/schema/contract.clj index 684a12dd2e..284ece55e8 100644 --- a/backend/src/app/graph/schema/contract.clj +++ b/backend/src/app/graph/schema/contract.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.schema.contract "Deliberate choices in Penpot's graph schema, recorded as data. diff --git a/backend/src/app/graph/schema/nodes.clj b/backend/src/app/graph/schema/nodes.clj index c81802415d..be88fb7d7c 100644 --- a/backend/src/app/graph/schema/nodes.clj +++ b/backend/src/app/graph/schema/nodes.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.schema.nodes "Single source of truth for graph node tables. diff --git a/backend/src/app/graph/schema/projection.clj b/backend/src/app/graph/schema/projection.clj index 4d0e969329..0302c722bd 100644 --- a/backend/src/app/graph/schema/projection.clj +++ b/backend/src/app/graph/schema/projection.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.schema.projection "Derive Ladybug node column schemas from Penpot Malli sources. diff --git a/backend/src/app/graph/schema/types.clj b/backend/src/app/graph/schema/types.clj index a905d92dcc..a2787a7b89 100644 --- a/backend/src/app/graph/schema/types.clj +++ b/backend/src/app/graph/schema/types.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.schema.types "Map Malli schemas to Ladybug column types. diff --git a/backend/src/app/graph/schema/values.clj b/backend/src/app/graph/schema/values.clj index e93b74f499..12988dfbc3 100644 --- a/backend/src/app/graph/schema/values.clj +++ b/backend/src/app/graph/schema/values.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.schema.values "Shape a Penpot value into the plain data its Ladybug column type wants. diff --git a/backend/src/app/graph/stats.clj b/backend/src/app/graph/stats.clj index 06a4eb2330..19c5dc67d5 100644 --- a/backend/src/app/graph/stats.clj +++ b/backend/src/app/graph/stats.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.stats (:require diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj index cb32fe2d47..d69405e5fb 100644 --- a/backend/src/app/graph/sync.clj +++ b/backend/src/app/graph/sync.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.graph.sync "Incremental Ladybug graph updates from Penpot file-change events." diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 914dfc169c..288fdc4396 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -36,6 +36,9 @@ ;; Default age for automatic session renewal (def default-renewal-max-age (ct/duration {:hours 6})) +;; Default absolute maximum session duration +(def default-cookie-max-age-absolute (ct/duration {:days 30})) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PROTOCOLS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -169,15 +172,19 @@ (defn- assign-token [cfg session] - (let [claims {:iss "authentication" - :aud "penpot" - :sid (:id session) - :iat (:modified-at session) - :uid (:profile-id session) - :sso-provider-id (:sso-provider-id session) - :sso-session-id (:sso-session-id session)} - header {:kid 1 :ver 1} - token (tokens/generate cfg claims header)] + (let [absolute-max-age (cf/get :auth-token-cookie-max-age-absolute default-cookie-max-age-absolute) + claims {:iss "authentication" + :aud "penpot" + :sid (:id session) + :iat (:modified-at session) + :uid (:profile-id session) + :sso-provider-id (:sso-provider-id session) + :sso-session-id (:sso-session-id session)} + claims (if (:created-at session) + (assoc claims :exp (ct/plus (:created-at session) absolute-max-age)) + claims) + header {:kid 1 :ver 1} + token (tokens/generate cfg claims header)] (assoc session :token token))) (defn create-fn @@ -353,15 +360,23 @@ or (updated_at is null and created_at < ?::timestamptz)") +(def ^:private + sql:delete-expired-v2 + "DELETE FROM http_session_v2 + WHERE created_at < ?::timestamptz") + (defn- collect-expired-tasks [{:keys [::db/conn ::tasks/max-age]}] (let [threshold (ct/minus (ct/now) max-age) - result (-> (db/exec-one! conn [sql:delete-expired threshold threshold]) - (db/get-update-count))] + result-legacy (-> (db/exec-one! conn [sql:delete-expired threshold threshold]) + (db/get-update-count)) + result-v2 (-> (db/exec-one! conn [sql:delete-expired-v2 threshold]) + (db/get-update-count))] (l/dbg :task "gc" :hint "clean http sessions" - :deleted result) - result)) + :deleted-legacy result-legacy + :deleted-v2 result-v2) + (+ result-legacy result-v2))) (defmethod ig/init-key ::tasks/gc [_ {:keys [::tasks/max-age] :as cfg}] diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj index 989f59ef36..4d6b8eb8c6 100644 --- a/backend/src/app/rpc/commands/plugins.clj +++ b/backend/src/app/rpc/commands/plugins.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.plugins (:require diff --git a/backend/src/app/storage/pending_gc.clj b/backend/src/app/storage/pending_gc.clj index 7b8f04ff13..df161f40c9 100644 --- a/backend/src/app/storage/pending_gc.clj +++ b/backend/src/app/storage/pending_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.pending-gc "A maintenance task that reclaims storage objects created in 'pending' diff --git a/backend/src/app/tasks/demo_purge.clj b/backend/src/app/tasks/demo_purge.clj index 429816c053..a77fb36e09 100644 --- a/backend/src/app/tasks/demo_purge.clj +++ b/backend/src/app/tasks/demo_purge.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.demo-purge "Task handler for delayed demo profile deletion. Submitted at demo diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 82696144db..7064c8f40c 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -17,7 +17,6 @@ [app.http.client :as http] [app.main :as-alias main] [app.setup :as-alias setup] - [app.util.blob :as blob] [app.util.json :as json] [integrant.core :as ig] [promesa.exec :as px])) @@ -248,20 +247,16 @@ :props (or (some-> props db/decode-transit-pgobject) {}) :context (or (some-> context db/decode-transit-pgobject) {})})) -(defn- encode-batch - "Encode a sequence of event maps into a fressian+zstd base64 string - suitable for JSON transport." - ^String [events] - (blob/encode-str events {:version 4})) - (defn send-event-batch "Send a single batch of events to the telemetry endpoint. Returns - true on success." + true on success. The events are sent as a plain vector of event + maps; the JSON encoder handles UUID and temporal types natively and + the receiver coerces them back to proper types." [{:keys [::setup/props] :as cfg} batch] (let [payload {:type :telemetry-events :version (:full cf/version) :instance-id (:instance-id props) - :events (encode-batch batch)} + :events (vec batch)} request {:method :post :uri (cf/get :telemetry-uri) :headers {"content-type" "application/json"} diff --git a/backend/test/backend_tests/demo_test.clj b/backend/test/backend_tests/demo_test.clj index da1cc342c9..66b8c04ddf 100644 --- a/backend/test/backend_tests/demo_test.clj +++ b/backend/test/backend_tests/demo_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.demo-test (:require diff --git a/backend/test/backend_tests/graph_binder_gate_test.clj b/backend/test/backend_tests/graph_binder_gate_test.clj index 508bf9d60d..84ad8c598c 100644 --- a/backend/test/backend_tests/graph_binder_gate_test.clj +++ b/backend/test/backend_tests/graph_binder_gate_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.graph-binder-gate-test "Binder gate for the incremental-sync statement templates. diff --git a/backend/test/backend_tests/graph_sync_parity_test.clj b/backend/test/backend_tests/graph_sync_parity_test.clj index c7eff4c34e..a0a07d4365 100644 --- a/backend/test/backend_tests/graph_sync_parity_test.clj +++ b/backend/test/backend_tests/graph_sync_parity_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.graph-sync-parity-test "Cold projection and incremental sync are two implementations of one mapping, diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index 6ec83924be..e5f3f48863 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -277,6 +277,70 @@ (t/is (= (:id session) (:sid claims))) (t/is (= (:id profile) (:uid claims))))) +(t/deftest session-token-contains-exp-claim + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 1) + session (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "user agent"}) + (#'session/assign-token cfg)) + claims (tokens/decode cfg (:token session)) + exp (:exp claims)] + (t/is (some? exp) "session token should contain :exp claim") + (t/is (ct/inst? exp) "exp should be an instant"))) + +(t/deftest session-token-exp-based-on-created-at + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 1) + session (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "user agent"}) + (#'session/assign-token cfg)) + claims (tokens/decode cfg (:token session)) + expected-exp (ct/plus (:created-at session) (ct/duration {:days 30}))] + (t/is (some? (:exp claims)) "session token should contain :exp claim") + (t/is (= (inst-ms (:exp claims)) + (inst-ms expected-exp)) + "exp should equal created-at + 30 days"))) + +(t/deftest session-token-past-exp-is-rejected + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 1) + session (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "user agent"}) + (#'session/assign-token cfg)) + claims (tokens/decode cfg (:token session)) + ;; Manually create a token with exp in the past + past-claims (assoc claims :exp (ct/minus (ct/now) (ct/duration {:days 1}))) + header {:kid 1 :ver 1} + past-token (tokens/generate cfg past-claims header)] + (t/is (nil? (session/decode-token cfg past-token)) + "token with exp in the past should be rejected"))) + +(t/deftest session-renewal-preserves-original-exp + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 1) + handler (-> (fn [req] req) + (#'session/wrap-authz {::session/manager manager}) + (#'mw/wrap-auth {:bearer (partial session/decode-token cfg) + :cookie (partial session/decode-token cfg)})) + session (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "user agent"}) + (#'session/assign-token cfg)) + original-exp (:exp (tokens/decode cfg (:token session))) + ;; Force renewal by setting modified-at to 7 hours ago + old-session (assoc session :modified-at (ct/minus (ct/now) (ct/duration {:hours 7}))) + response (handler (make-dummy-request {:cookies {"auth-token" (:token old-session)}})) + {:keys [token claims]} (get response ::http/auth-data) + new-exp (:exp claims)] + (t/is (some? original-exp) "original token should have :exp") + (t/is (some? new-exp) "renewed token should have :exp") + (t/is (= (inst-ms original-exp) + (inst-ms new-exp)) + "renewed token should preserve original :exp, not extend it"))) + (t/deftest parse-request-illegal-argument-exception ;; clojure.data.json raises IllegalArgumentException (case ;; fall-through) on several kinds of malformed input. The diff --git a/backend/test/backend_tests/passwords_test.clj b/backend/test/backend_tests/passwords_test.clj index 75a880a5de..9aa9e80f44 100644 --- a/backend/test/backend_tests/passwords_test.clj +++ b/backend/test/backend_tests/passwords_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.passwords-test (:require diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj index 3bda13fc61..b3561ade93 100644 --- a/backend/test/backend_tests/rpc_demo_test.clj +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-demo-test (:require diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj index 9850d90ba9..cb895d4395 100644 --- a/backend/test/backend_tests/rpc_plugins_test.clj +++ b/backend/test/backend_tests/rpc_plugins_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-plugins-test (:require diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index e12af0553b..a44f6efe74 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -12,7 +12,6 @@ [app.db :as db] [app.loggers.audit :as audit] [app.tasks.telemetry :as telemetry] - [app.util.blob :as blob] [app.util.json :as json] [backend-tests.helpers :as th] [clojure.test :as t] @@ -59,11 +58,6 @@ :cnt long)) -(defn- decode-event-batch - "Decode the base64+fressian+zstd event-batch sent to the mock." - [b64-str] - (blob/decode-str b64-str)) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; STATS / REPORT STRUCTURE TESTS (existing behaviour, extended) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -245,21 +239,19 @@ (t/is (not (contains? ev :ip-addr))))))))) (t/deftest test-batch-encoding-is-decodable - ;; Verify that encode-batch produces a blob that round-trips back - ;; through blob/decode to the original data. + ;; Events are sent as a plain vector of raw event maps (no blob + ;; encoding): every batch must JSON round-trip unchanged, because + ;; the receiver coerces types from the plain JSON representation. (let [events [{:name "navigate" :type "action" :source "telemetry" :tracked-at (ct/now)} {:name "create-file" :type "action" :source "telemetry" :tracked-at (ct/now)}] - ;; Call the private fn through the ns-mapped var - encode (ns-resolve 'app.tasks.telemetry 'encode-batch) - encoded (encode events) - decoded (decode-event-batch encoded)] - (t/is (string? encoded)) - (t/is (seq decoded)) - (t/is (= (count events) (count decoded))) - (t/is (= "navigate" (:name (first decoded)))) - (t/is (= "create-file" (:name (second decoded)))))) + encoded (json/encode-str {:events (vec events)}) + decoded (json/decode encoded)] + (t/is (vector? (:events decoded))) + (t/is (= (count events) (count (:events decoded)))) + (t/is (= "navigate" (:name (first (:events decoded))))) + (t/is (= "create-file" (:name (second (:events decoded))))))) (t/deftest test-multiple-batches-when-many-events ;; Lower batch-size to 1 so that 3 events produce 3 separate @@ -787,9 +779,13 @@ (t/is (= "telemetry-events" (name (:type body)))) (t/is (string? (:version body))) (t/is (some? (:instance-id body))) - ;; :events is a base64-encoded blob - (t/is (string? (:events body))) - (t/is (pos? (count (:events body)))))))))) + ;; :events is a plain vector of raw event maps + (t/is (vector? (:events body))) + (t/is (pos? (count (:events body)))) + (doseq [ev (:events body)] + (t/is (string? (:name ev))) + (t/is (string? (:source ev))) + (t/is (string? (:tracked-at ev)))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TASK BRANCH COVERAGE diff --git a/common/package.json b/common/package.json index acc9432642..4269da7ba6 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 77f173a685..ef70c46056 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/common/pnpm-workspace.yaml b/common/pnpm-workspace.yaml index a1c031fc33..7b3a5d3edf 100644 --- a/common/pnpm-workspace.yaml +++ b/common/pnpm-workspace.yaml @@ -1 +1,3 @@ +storeDir: ../.pnpm-store + minimumReleaseAge: 0 diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index 58cd5d970f..96e71a5fcb 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -72,6 +72,13 @@ [:map {:title "PlainColorAttrs"} [:color schema:hex-color]]) +(def schema:image-transform + [:map {:title "ImageTransform" :closed true} + [:x {:optional true} ::sm/safe-number] + [:y {:optional true} ::sm/safe-number] + [:width {:optional true} ::sm/safe-number] + [:height {:optional true} ::sm/safe-number]]) + (def schema:image [:map {:title "ImageColor" :closed true} [:width [::sm/int {:min 0 :gen/gen sg/int}]] @@ -79,7 +86,8 @@ [:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text] [:id ::sm/uuid] [:name {:optional true} ::sm/text] - [:keep-aspect-ratio {:optional true} :boolean]]) + [:keep-aspect-ratio {:optional true} :boolean] + [:transform {:optional true} schema:image-transform]]) (def image-attrs "A set of attrs that corresponds to image data type" diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 32f806568f..2f8ca65a6f 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -119,12 +119,15 @@ (defn write-image-fill [offset buffer opacity image] - (let [image-id (get image :id) - image-width (get image :width) - image-height (get image :height) - alpha (mth/floor (* opacity 0xff)) - keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00) - flags (bit-or keep-aspect-ratio 0x00)] + (let [image-id (get image :id) + image-width (get image :width) + image-height (get image :height) + alpha (mth/floor (* opacity 0xff)) + keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00) + transform (get image :transform) + has-transform? (some? transform) + transform-flag (if has-transform? 0x02 0x00) + flags (bit-or keep-aspect-ratio transform-flag)] (buf/write-byte buffer (+ offset 0) 0x03) (buf/write-uuid buffer (+ offset 4) image-id) (buf/write-byte buffer (+ offset 20) alpha) @@ -132,6 +135,17 @@ (buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use) (buf/write-int buffer (+ offset 24) image-width) (buf/write-int buffer (+ offset 28) image-height) + (if has-transform? + (do + (buf/write-float buffer (+ offset 32) (double (get transform :x 0.0))) + (buf/write-float buffer (+ offset 36) (double (get transform :y 0.0))) + (buf/write-float buffer (+ offset 40) (double (get transform :width 1.0))) + (buf/write-float buffer (+ offset 44) (double (get transform :height 1.0)))) + (do + (buf/write-float buffer (+ offset 32) 0.0) + (buf/write-float buffer (+ offset 36) 0.0) + (buf/write-float buffer (+ offset 40) 1.0) + (buf/write-float buffer (+ offset 44) 1.0))) (+ offset FILL-U8-SIZE))) (defn- write-metadata @@ -208,28 +222,36 @@ :type type}}) 3 ;; image fill - (let [id (buf/read-uuid dbuffer (+ doffset 4)) - alpha (buf/read-unsigned-byte dbuffer (+ doffset 20)) - opacity (mth/precision (/ alpha 0xff) 2) - flags (buf/read-unsigned-byte dbuffer (+ doffset 21)) - ratio (boolean (bit-and flags 0x01)) - width (buf/read-int dbuffer (+ doffset 24)) - height (buf/read-int dbuffer (+ doffset 28)) - mtype (buf/read-short mbuffer (+ moffset 2)) - mtype (case mtype - 0x01 "image/jpeg" - 0x02 "image/png" - 0x03 "image/gif" - 0x04 "image/webp" - 0x05 "image/svg+xml")] + (let [id (buf/read-uuid dbuffer (+ doffset 4)) + alpha (buf/read-unsigned-byte dbuffer (+ doffset 20)) + opacity (mth/precision (/ alpha 0xff) 2) + flags (buf/read-unsigned-byte dbuffer (+ doffset 21)) + ratio (not (zero? (bit-and flags 0x01))) + has-tf (not (zero? (bit-and flags 0x02))) + width (buf/read-int dbuffer (+ doffset 24)) + height (buf/read-int dbuffer (+ doffset 28)) + transform (when has-tf + {:x (buf/read-float dbuffer (+ doffset 32)) + :y (buf/read-float dbuffer (+ doffset 36)) + :width (buf/read-float dbuffer (+ doffset 40)) + :height (buf/read-float dbuffer (+ doffset 44))}) + mtype (buf/read-short mbuffer (+ moffset 2)) + mtype (case mtype + 0x01 "image/jpeg" + 0x02 "image/png" + 0x03 "image/gif" + 0x04 "image/webp" + 0x05 "image/svg+xml")] {:fill-opacity opacity - :fill-image {:id id - :width width - :height height - :mtype mtype - :keep-aspect-ratio ratio - ;; FIXME: we are not encodign the name, looks useless - :name "sample"}}))] + :fill-image (cond-> {:id id + :width width + :height height + :mtype mtype + :keep-aspect-ratio ratio + ;; FIXME: we are not encodign the name, looks useless + :name "sample"} + (some? transform) + (assoc :transform transform))}))] (if refs? (let [ref-file (buf/read-uuid mbuffer (+ moffset 4)) diff --git a/common/src/app/common/types/path/fit.cljc b/common/src/app/common/types/path/fit.cljc index 486822a79f..7c31359f7d 100644 --- a/common/src/app/common/types/path/fit.cljc +++ b/common/src/app/common/types/path/fit.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.fit "Curve fitting helpers." diff --git a/common/src/app/common/types/path/selection.cljc b/common/src/app/common/types/path/selection.cljc index 826047431b..203ed0e11d 100644 --- a/common/src/app/common/types/path/selection.cljc +++ b/common/src/app/common/types/path/selection.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.selection "Transforms selected path nodes and handlers." diff --git a/common/src/app/common/types/tokens_status.cljc b/common/src/app/common/types/tokens_status.cljc index c2953cc4c1..c341ee27e4 100644 --- a/common/src/app/common/types/tokens_status.cljc +++ b/common/src/app/common/types/tokens_status.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.tokens-status (:require diff --git a/common/test/common_tests/files_migrations_0026_test.cljc b/common/test/common_tests/files_migrations_0026_test.cljc index 92dde088e8..cc68368ee0 100644 --- a/common/test/common_tests/files_migrations_0026_test.cljc +++ b/common/test/common_tests/files_migrations_0026_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-migrations-0026-test (:require diff --git a/common/test/common_tests/geom_image_bounds_resize_test.cljc b/common/test/common_tests/geom_image_bounds_resize_test.cljc new file mode 100644 index 0000000000..a67fd67e13 --- /dev/null +++ b/common/test/common_tests/geom_image_bounds_resize_test.cljc @@ -0,0 +1,275 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.geom-image-bounds-resize-test + (:require + #?(:clj [clojure.test :refer [deftest is testing]] + :cljs [cljs.test :refer-macros [deftest is testing]]) + [app.common.math :as mth] + [app.common.schema :as sm] + [app.common.types.color :as clr] + [app.common.types.fills :as fills] + [app.common.types.fills.impl :as fills.impl] + [app.common.uuid :as uuid])) + +(deftest test-image-transform-schema + (testing "validates image with transform" + (let [img {:id (uuid/custom 1) + :width 400 + :height 300 + :mtype "image/png" + :keep-aspect-ratio true + :transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}] + (is (sm/validate clr/schema:image img)))) + + (testing "validates image without transform" + (let [img {:id (uuid/custom 1) + :width 400 + :height 300 + :mtype "image/png" + :keep-aspect-ratio true}] + (is (sm/validate clr/schema:image img)))) + + (testing "validates fill with image transform" + (let [fill {:fill-opacity 0.8 + :fill-image {:id (uuid/custom 1) + :width 400 + :height 300 + :mtype "image/png" + :keep-aspect-ratio true + :transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}] + (is (sm/validate fills/schema:fill fill))))) + +(deftest test-image-fill-buffer-roundtrip + (testing "roundtrip image fill without transform" + (let [fill-vec [{:fill-opacity 0.9 + :fill-image {:id (uuid/custom 1) + :width 800 + :height 600 + :mtype "image/jpeg" + :keep-aspect-ratio true + :name "sample"}}] + coerced (fills/from-plain fill-vec) + plain (into [] coerced)] + (is (= 1 (count plain))) + (is (= 0.9 (:fill-opacity (first plain)))) + (is (= 800 (-> plain first :fill-image :width))) + (is (= 600 (-> plain first :fill-image :height))) + (is (true? (-> plain first :fill-image :keep-aspect-ratio))) + (is (nil? (-> plain first :fill-image :transform))))) + + (testing "roundtrip image fill with transform" + (let [fill-vec [{:fill-opacity 0.75 + :fill-image {:id (uuid/custom 2) + :width 1920 + :height 1080 + :mtype "image/webp" + :keep-aspect-ratio false + :name "sample" + :transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}] + coerced (fills/from-plain fill-vec) + plain (into [] coerced) + tf (-> plain first :fill-image :transform)] + (is (= 1 (count plain))) + (is (= 0.75 (:fill-opacity (first plain)))) + (is (= 1920 (-> plain first :fill-image :width))) + (is (= 1080 (-> plain first :fill-image :height))) + (is (false? (-> plain first :fill-image :keep-aspect-ratio))) + (is (some? tf)) + (is (mth/close? 0.25 (double (:x tf)))) + (is (mth/close? -0.15 (double (:y tf)))) + (is (mth/close? 1.5 (double (:width tf)))) + (is (mth/close? 2.0 (double (:height tf))))))) + +(defn compute-bounds-resize-transform + "Mathematical model for independent image bounds resizing" + [{:keys [width height handler center? sx sy transform]}] + (let [w-new (* width sx) + h-new (* height sy) + [dx dy] (if ^boolean center? + [(/ (* width (- 1.0 sx)) 2.0) + (/ (* height (- 1.0 sy)) 2.0)] + [(case handler + (:left :bottom-left :top-left) (* width (- 1.0 sx)) + 0.0) + (case handler + (:top :top-left :top-right) (* height (- 1.0 sy)) + 0.0)]) + nx0 (get transform :x 0.0) + ny0 (get transform :y 0.0) + nw0 (get transform :width 1.0) + nh0 (get transform :height 1.0) + nx' (/ (- (* nx0 width) dx) w-new) + ny' (/ (- (* ny0 height) dy) h-new) + nw' (/ nw0 sx) + nh' (/ nh0 sy)] + {:transform {:x nx' :y ny' :width nw' :height nh'} + :rendered-pixel-rect {:x (* nx' w-new) + :y (* ny' h-new) + :width (* nw' w-new) + :height (* nh' h-new)}})) + +(deftest test-handle-anchoring-mathematics + (testing "Right handle crop (shrinking width to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 1.0 (-> res :transform :height))) + ;; Rendered pixel content remains 200x100 starting at (0, 0) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Left handle crop (shrinking width to 50% from left)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})] + (is (mth/close? -1.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 1.0 (-> res :transform :height))) + ;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!) + (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))) + + (testing "Top handle crop (shrinking height to 50% from top)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? -1.0 (-> res :transform :y))) + (is (mth/close? 1.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + ;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Top-Left handle crop (shrinking both dimensions to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})] + (is (mth/close? -1.0 (-> res :transform :x))) + (is (mth/close? -1.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Center resize (Alt modifier)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})] + (is (mth/close? -0.5 (-> res :transform :x))) + (is (mth/close? -0.5 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? -25.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Bottom handle crop (shrinking height to 50% from bottom)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 1.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Top-Right handle crop (shrinking both dimensions to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? -1.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Bottom-Left handle crop (shrinking both dimensions to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})] + (is (mth/close? -1.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Expanding bounds beyond original size (empty space exposure)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 0.5 (-> res :transform :width))) + (is (mth/close? 1.0 (-> res :transform :height))) + ;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))) + +(deftest test-sequential-resize-operations + (testing "Sequential crops: crop right then crop left" + ;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1} + ;; Step 1: Crop right handle from 200 to 150 (sx = 0.75) + (let [step1 (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0}) + tf1 (:transform step1)] + (is (mth/close? 0.0 (:x tf1))) + (is (mth/close? (/ 1.0 0.75) (:width tf1))) + ;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3) + (let [step2 (compute-bounds-resize-transform + {:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1}) + tf2 (:transform step2)] + ;; The final 100x100 container has bitmap with width 200px + (is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width))) + ;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px + (is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x)))))) + + (testing "Bounds resize followed by standard proportional scaling" + ;; Step 1: Bounds resize crops width from 200 to 100 + (let [step1 (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0}) + tf1 (:transform step1)] + (is (mth/close? 2.0 (:width tf1))) + (is (mth/close? 1.0 (:height tf1))) + + ;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x) + ;; During standard scale, normalized transform tf1 is kept constant! + (let [scaled-w (* 100.0 2.0) + scaled-h (* 100.0 2.0) + rendered-w (* (:width tf1) scaled-w) + rendered-h (* (:height tf1) scaled-h)] + ;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame! + (is (mth/close? 400.0 rendered-w)) + (is (mth/close? 200.0 rendered-h)))))) + +(deftest test-proportion-lock-invariance + (testing "Shape proportion-lock attribute remains unchanged" + (let [shape {:id (uuid/custom 10) + :type :rect + :width 200 + :height 100 + :proportion-lock true + :fills [{:fill-image {:id (uuid/custom 1) + :width 800 + :height 600 + :keep-aspect-ratio true}}]} + ;; Simulate bounds resize interaction + has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape))) + mod-pressed? true + bounds-resize? (and has-img? mod-pressed?) + lock-during-drag (if bounds-resize? false (:proportion-lock shape))] + ;; During drag, lock is bypassed (unless Shift is pressed) + (is (false? lock-during-drag)) + ;; Shape's persistent setting is completely preserved + (is (true? (:proportion-lock shape)))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index ab075151ee..d1ae2bbce5 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -29,6 +29,7 @@ [common-tests.geom-flex-layout-test] [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] + [common-tests.geom-image-bounds-resize-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] [common-tests.geom-modifiers-test] @@ -108,6 +109,7 @@ 'common-tests.geom-flex-layout-test 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test + 'common-tests.geom-image-bounds-resize-test 'common-tests.geom-line-test 'common-tests.geom-modif-tree-test 'common-tests.geom-modifiers-test diff --git a/common/test/common_tests/types/tokens_status_test.cljc b/common/test/common_tests/types/tokens_status_test.cljc index d2ebb81dd3..a677f82cf9 100644 --- a/common/test/common_tests/types/tokens_status_test.cljc +++ b/common/test/common_tests/types/tokens_status_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.tokens-status-test (:require diff --git a/docs/img/enterprise/enterprise-access-admin-console.webp b/docs/img/enterprise/enterprise-access-admin-console.webp new file mode 100644 index 0000000000..2c0d98e28b Binary files /dev/null and b/docs/img/enterprise/enterprise-access-admin-console.webp differ diff --git a/docs/img/enterprise/enterprise-admin-console.webp b/docs/img/enterprise/enterprise-admin-console.webp new file mode 100644 index 0000000000..91fc8cc728 Binary files /dev/null and b/docs/img/enterprise/enterprise-admin-console.webp differ diff --git a/docs/img/enterprise/enterprise-membership-w.webp b/docs/img/enterprise/enterprise-membership-w.webp new file mode 100644 index 0000000000..8ebad02478 Binary files /dev/null and b/docs/img/enterprise/enterprise-membership-w.webp differ diff --git a/docs/img/enterprise/enterprise-module-sso.webp b/docs/img/enterprise/enterprise-module-sso.webp new file mode 100644 index 0000000000..48d12cd322 Binary files /dev/null and b/docs/img/enterprise/enterprise-module-sso.webp differ diff --git a/docs/img/enterprise/enterprise-organization-hierarchy-w.webp b/docs/img/enterprise/enterprise-organization-hierarchy-w.webp new file mode 100644 index 0000000000..921358f22a Binary files /dev/null and b/docs/img/enterprise/enterprise-organization-hierarchy-w.webp differ diff --git a/docs/img/objects/line-arrow-tools.webp b/docs/img/objects/line-arrow-tools.webp new file mode 100644 index 0000000000..4615e65cb8 Binary files /dev/null and b/docs/img/objects/line-arrow-tools.webp differ diff --git a/docs/package.json b/docs/package.json index 7d498ee015..208cd18089 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^9.2.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 5938203566..3527b910e8 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml index bd2aa47620..b9b659dcc5 100644 --- a/docs/pnpm-workspace.yaml +++ b/docs/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + minimumReleaseAgeExclude: - undici@7.28.0 || 7.29.0 - js-yaml@3.15.0 || 4.3.0 diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 1541f1d3b8..76426c22e5 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -660,6 +660,48 @@ PENPOT_INTERNAL_URI: http://penpot-frontend:8080 `http://penpot-frontend:8080` used in the docker-compose is a good default and it is recommended to keep it unchanged. +### MCP + +The MCP server lets AI agents read and edit Penpot files. It runs as a separate +`penpot-mcp` container, and the frontend proxies the requests to it. Enable it with +the corresponding flag: + +```bash +PENPOT_FLAGS: [...] enable-mcp +``` + +With the flag enabled, the frontend container uses these variables to locate the MCP +server: + +```bash +# Frontend +PENPOT_MCP_URI: http://penpot-mcp:4401 +PENPOT_MCP_URI_WS: http://penpot-mcp:4402 +``` + +- `PENPOT_MCP_URI`: The URI of the MCP server, used for the streamable HTTP and SSE + endpoints. +- `PENPOT_MCP_URI_WS`: The URI of the MCP server used for the websocket connection. + +The defaults match the service name used in the official `docker-compose.yaml`. Change +them only if your MCP service has a different name or listens on other ports. Both +variables are ignored when the `enable-mcp` flag is not set. + +### Internal resolver + +The frontend container resolves the backend, exporter and MCP service names with the +DNS servers listed in its `/etc/resolv.conf`. If that autodetection does not work for +your setup, set the resolver explicitly: + +```bash +# Frontend +PENPOT_INTERNAL_RESOLVER: 127.0.0.11 +``` + +- `PENPOT_INTERNAL_RESOLVER`: The DNS server nginx uses to resolve the internal service + names. Defaults to the nameservers found in `/etc/resolv.conf`. `127.0.0.11` is the + embedded Docker DNS server; use the address of your own resolver on other setups. + ## Other flags There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant @@ -670,6 +712,9 @@ for the user: - <code class="language-bash">enable-backend-api-doc</code>: Enables the <code class="language-bash">/api/doc</code> endpoint that lists all rpc methods available on backend - <code class="language-bash">disable-login-with-password</code>: allows disable password based login form +- <code class="language-bash">enable-mcp</code>: Enables the MCP server integration, so AI agents can + read and edit Penpot files. It also makes the frontend proxy the MCP endpoints to the + <code class="language-bash">penpot-mcp</code> service. Check the [MCP section][8] to get more detail. - <code class="language-bash">enable-prepl-server</code>: enables PREPL server, used by manage.py and other additional tools to communicate internally with Penpot backend. Check the [CLI section][5] to get more detail. @@ -693,3 +738,4 @@ __Since version 2.0.0__ [5]: /technical-guide/getting-started/docker#using-the-cli-for-administrative-tasks [6]: /technical-guide/integration/#webhooks [7]: /technical-guide/integration/#access-tokens +[8]: /mcp/ diff --git a/docs/user-guide/account-teams/comments.njk b/docs/user-guide/account-teams/comments.njk index 4d5a2a769b..1cf4752094 100644 --- a/docs/user-guide/account-teams/comments.njk +++ b/docs/user-guide/account-teams/comments.njk @@ -1,6 +1,6 @@ --- title: Comments -order: 4 +order: 6 desc: Learn how to import and export files in Penpot, the free, open-source design tool. Discover file formats, backups, sharing, and library management. --- diff --git a/docs/user-guide/account-teams/enterprise-plan.njk b/docs/user-guide/account-teams/enterprise-plan.njk new file mode 100644 index 0000000000..c9a82c81c4 --- /dev/null +++ b/docs/user-guide/account-teams/enterprise-plan.njk @@ -0,0 +1,35 @@ +--- +title: Enterprise plan +order: 2 +desc: Learn how the Enterprise plan works in Penpot. Discover its features and how to use it within your organization. +--- + +<h1 id="enterprise">Enterprise plan</h1> +<p class="main-paragraph">Penpot Enterprise gives organizations the tools to govern how design work happens across their teams: from creating a structured org and managing members, to applying fine-grained permissions and configuration through the Admin Console.</p> + +<h3 id="what-is-penpot-enterprise">What is Penpot Enterprise? +</h3> +<p>Penpot Enterprise is the plan that unlocks organizational governance features. While Penpot remains free and unlimited as an open-source platform, Enterprise adds a layer of control on top: the ability to create Organizations, manage teams under it, and apply configuration settings that define what members can and cannot do.</p> +<p>The organization owner is the user who creates the organization. They have exclusive access to the Admin Console and are responsible for configuring Modules.</p> +<p>The key concepts you'll work with:</p> +<ul> + <li><strong><a href="/user-guide/account-teams/organizations/">Organization</a>:</strong> the top-level structure that groups one or more Teams under a shared governance layer.</li> + <li><strong><a href="/user-guide/account-teams/organizations/#the-admin-console">Admin Console</a>:</strong> the back-office interface where the org owner manages settings, teams and members.</li> + <li><strong><a href="/user-guide/account-teams/organizations/#modules-and-controls">Modules</a>:</strong> the paid, configurable capabilities applied to an organization. Each Module consists of individual Controls (specific settings or restrictions).</li> +</ul> + +<h3 id="subscribing-to-enterprise">Subscribing to Enterprise + <a class="direct-link" href="#subscribing-to-enterprise">#</a> +</h3> +<p>To create an organization, you first need to upgrade to the <a href="https://penpot.app/pricing" target="_blank">Enterprise plan</a>. Click "Create Organization" to begin. Once the subscription process is complete, you'll be redirected to the Admin Console to finish creating your organization.</p> + +<h3 id="frequently-asked-questions"> Frequently asked questions + <a class="direct-link" href="#frequently-asked-questions">#</a> +</h3> + + <h4>Can I have multiple organizations?</h4> + <p>Yes. You can create more than one organization under a single Enterprise subscription and manage them from the Admin Console.</p> + + <h4>What happens if I cancel my Enterprise subscription?</h4> + <p>Your organizations are deleted but not their teams. Governance settings no longer apply to any of them.</p> + diff --git a/docs/user-guide/account-teams/index.njk b/docs/user-guide/account-teams/index.njk index e03ed12133..d1d228d3ad 100644 --- a/docs/user-guide/account-teams/index.njk +++ b/docs/user-guide/account-teams/index.njk @@ -13,7 +13,19 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial <p>Access your account settings and manage personal access tokens</p> </a> </li> - <li> + <li> + <a href="/user-guide/account-teams/enterprise-plan/"> + <h2>Enterprise plan →</h2> + <p>Learn how the Enterprise plan works and what it includes</p> + </a> + </li> + <li> + <a href="/user-guide/account-teams/organizations/"> + <h2>Organizations →</h2> + <p>Create and manage organizations, the Admin Console, and SSO</p> + </a> + </li> + <li> <a href="/user-guide/account-teams/teams"> <h2>Teams →</h2> <p>Create and manage your teams</p> diff --git a/docs/user-guide/account-teams/organizations.njk b/docs/user-guide/account-teams/organizations.njk new file mode 100644 index 0000000000..4c9d8ef777 --- /dev/null +++ b/docs/user-guide/account-teams/organizations.njk @@ -0,0 +1,227 @@ +--- +title: Organizations +order: 3 +desc: Learn how Organizations work in Penpot Enterprise, creating one, the Admin Console, membership, Modules and Controls, and Single Sign-On (SSO). +--- + +<h1 id="organizations">Organizations</h1> +<p class="main-paragraph">An organization is the governance layer Penpot Enterprise adds on top of your teams. This section covers how to create and manage one: the Admin Console, membership, and the Modules and Controls used to configure it, including Single Sign-On (SSO).</p> + +<h3 id="creating-an-organization">Creating an organization + <a class="direct-link" href="#creating-an-organization">#</a> +</h3> +<p>Creating an organization is as easy as giving it a name.</p> +<p>Once the organization is created, you'll be taken to the Admin Console. At this point, the organization has one member: you, the owner.</p> + +<h3 id="the-admin-console">The Admin Console + <a class="direct-link" href="#the-admin-console">#</a> +</h3> +<p>The Admin Console is the admin interface for your organization. Only the organization owner can access it.</p> +<p>You can reach the Admin Console directly at <code>/admin-console</code>, or from any team dashboard in Penpot by opening the organization navigation menu and clicking the Admin Console link. If you own more than one organization, you can switch between them from within the Admin Console.</p> + +<figure> + <a href="/img/enterprise/enterprise-access-admin-console.webp" target="_blank"> + <img alt="Accessing the Admin Console" src="/img/enterprise/enterprise-access-admin-console.webp"/> + </a> +</figure> + +<p>The Admin Console includes:</p> + +<ol> + <li><strong>Switch organization menu: </strong> To navigate between the organizations you own.</li> + <li><strong>Organization settings: </strong> To change basic settings, such as renaming it or deleting it.</li> + <li><strong>A "Go to files" button </strong> that returns you to a team dashboard.</li> + <li><strong>Your user avatar</strong>, that expands into a full user menu.</li> + <li><strong>Module: </strong> The control itself to configure.</li> +</ol> + +<figure> + <a href="/img/enterprise/enterprise-admin-console.webp" target="_blank"> + <img alt="Admin Console" src="/img/enterprise/enterprise-admin-console.webp"/> + </a> +</figure> + +<h3 id="organizations-and-teams">Organizations and teams + <a class="direct-link" href="#organizations-and-teams">#</a> +</h3> +<p>An organization groups one or more Teams under a shared governance structure. Teams continue to work just as they do in standard Penpot, with the added layer that the org owner can apply configuration that affects all members across the organization's teams.</p> +<h4>How teams relate to organizations</h4> + +<p>Teams inside an organization inherit the governance settings applied at the org level via the Admin Console. The structure is:</p> + +<figure> + <img alt="organization-hierarchy" src="/img/enterprise/enterprise-organization-hierarchy-w.webp"/> +</figure> + +<p>Team members work within projects and files as usual. What changes under Enterprise is the org owner's ability to restrict or govern that work from the Admin Console.</p> +<p>Within each team, the <a href="https://help.penpot.app/user-guide/account-teams/teams/#teams-members" target="_blank">standard Penpot roles</a> apply.</p> + +<h3 id="managing-organization-membership">Managing organization membership + <a class="direct-link" href="#managing-organization-membership">#</a> +</h3> +<p>At launch, an organization has a single member: the owner. Additional members are brought in by being part of a team added to the organization, being invited to teams within the organization, or being directly invited to the organization by the owner.</p> +<p>The Admin Console provides a unified view of all members across teams within the organization.</p> + +<figure> + <a href="/img/enterprise/enterprise-membership-w.webp" target="_blank"> + <img alt="Admin-Console Members page" src="/img/enterprise/enterprise-membership-w.webp"/> + </a> +</figure> + +<h3 id="modules-and-controls">Modules and Controls + <a class="direct-link" href="#modules-and-controls">#</a> +</h3> + +<p>Modules are the configurable governance capabilities available to Enterprise organizations. Each Module is made up of one or more Controls: the individual settings that define who can do what, and where.</p> +<p>Modules are configured from the Admin Console and apply organization-wide. </p> + +<table cellspacing="0" cellpadding="1" border="1" width="100%"> + <thead> + <tr> + <th valign="top" class="cellrowborder">Modules</th> + <th valign="top" class="cellrowborder" style="text-align: center;">What it does</th> + </tr> + </thead> + <tbody> + <tr> + <td>Single Sign-On (SSO)</td> + <td>Requires all org members to authenticate through your corporate identity provider before accessing the organization's teams and files.</td> + </tr> + <tr> + <td>Advanced permissions</td> + <td>Defines who can create, view, edit, administer, or share teams, projects, and files. Also controls who can invite new members to teams.</td> + </tr> + </tbody> +</table> + +<h3 id="module-advanced-permissions">Module: Advanced permissions + <a class="direct-link" href="#module-advanced-permissions">#</a> +</h3> + + <p>Advanced Permissions gives the organization owner fine-grained control over what members can do across all teams in the organization. Rather than relying on the default Penpot team roles alone, this module lets you restrict or open up specific actions at the organization level.</p> + + <h4>What it controls</h4> + + <p>The Advanced Permissions module is made up of individual Controls. Each Control governs a specific action, and each has a set of options to choose from. The selected option becomes the rule for the entire organization.</p> + + <h4>How to configure it</h4> + + <p>Advanced Permissions is configured from the Admin Console. Changes apply to all teams within the organization immediately.</p> + <ol> + <li>Open the Admin Console.</li> + <li>Select Advanced Permissions from the left sidebar.</li> + <li>For each Control, select the option that fits your governance policy.</li> + <li>Changes take effect right away. There is no publish or save step.</li> + </ol> + + <h4>How it relates to team roles</h4> + + <p>Advanced Permissions works on top of the standard Penpot team roles (Viewer, Editor, Admin, Owner). It does not replace them. Think of it as a ceiling: even if a member's team role would normally allow an action, an Advanced Permissions Control can prevent it organization-wide.</p> + <p>For example, if “New team members” is set to "Organization members only," a team owner who would normally be able to invite anyone will find that option restricted to people who are already part of the organization.</p> + + <h4>Default behavior</h4> + + <p>When an organization is first created, all controls are configured with the most permissive setting; the same setting is used by all teams that are not part of any organization. No behavior changes until you actively configure a Control.</p> + +<h3 id="module-single-sign-on">Module: Single Sign-On (SSO) + <a class="direct-link" href="#module-single-sign-on">#</a> +</h3> + + <p>Single Sign-On lets you require all members of your organization to authenticate through your corporate identity provider (IdP) before accessing any of the organization's teams and files.</p> + <p>SSO applies to teams and files only. The Admin Console is always accessible without SSO, so you can always reach your configuration to adjust or deactivate it, even if your own directory entry changes.</p> + + <h4>Configuring your identity provider</h4> + <p>Before setting up SSO in Penpot, you need to register Penpot as an application in your identity provider. The steps vary by provider, but you will always need to set the following <strong>callback URL</strong> in your IdP configuration:</p> + <p><strong><code>https://<your-penpot-domain>/api/auth/oidc/callback</code></strong></p> + <p>Your IdP will then give you a <strong>Client ID</strong> and <strong>Client Secret</strong> to use in Penpot. Once you have those:</p> + <ol> + <li>Open the Admin Console.</li> + <li>Select <strong>SSO Config</strong>.</li> + <li>Choose your provider and fill in the fields.</li> + </ol> + + <figure> + <a href="/img/enterprise/enterprise-module-sso.webp" target="_blank"> + <img alt="SSO configuration" src="/img/enterprise/enterprise-module-sso.webp"/> + </a> + </figure> + + <h5>Generic authentication (OpenID Connect)</h5> + <p>Use this option for any identity provider that supports the OIDC protocol, such as Okta, Keycloak, or Auth0.</p> + <ul> + <li><strong>Issuer / Authority URL:</strong> base URL of your OIDC provider, used to autodiscover endpoints</li> + <li><strong>Client ID:</strong> client identifier assigned by your provider</li> + <li><strong>Client Secret:</strong> client secret assigned by your provider</li> + </ul> + + <h5>Azure Active Directory (OpenID Connect)</h5> + <ul> + <li><strong>Issuer / Authority URL:</strong> <code>https://login.microsoftonline.com/<your-tenant-id>/v2.0/</code></li> + <li><strong>Client ID:</strong> Application (client) ID from your Azure app registration</li> + <li><strong>Client Secret:</strong> client secret value from your Azure app registration</li> + </ul> + + <h5>Google (OAuth)</h5> + <ul> + <li><strong>Client ID:</strong> client identifier from your Google Cloud OAuth 2.0 credentials</li> + <li><strong>Client Secret:</strong> client secret from your Google Cloud OAuth 2.0 credentials</li> + </ul> + + <p>When all fields are filled, click <strong>Activate SSO</strong>. Penpot will run a test connection against your IdP. If the connection fails, your draft is kept and no changes are applied.</p> + <p>If the test passes, a confirmation dialog will appear. It will warn you that members not in your directory will lose access to the organization's teams once SSO is active. Review your member list if needed, then confirm. SSO becomes active immediately.</p> + + <h4>What happens to existing sessions</h4> + <p>When SSO is activated, any member who is currently inside one of the organization's teams is cut off immediately and sent through the SSO login. This does not log them out of Penpot entirely. They can still reach teams that do not belong to your organization without re-authenticating.</p> + <p>SSO sessions last 4 hours. When a session expires, members are routed through the SSO login again. If they are still in the directory, they are signed back in immediately.</p> + + <h4>Editing an active configuration</h4> + <p>While SSO is active, you can edit any field. As soon as you make a change, <strong>Apply changes</strong> and <strong>Discard changes</strong> buttons appear. Discarding restores every field to the current live configuration. Applying runs the same test connection as the initial setup, without a confirmation dialog. If the connection fails, your live configuration is not touched. Changes may take up to 5 minutes to apply for members who are currently working in a file.</p> + + <h4>Deactivating SSO</h4> + <p>Click <strong>Deactivate SSO</strong> and confirm. The configuration is preserved as a draft so you can reactivate it later without re-entering your credentials. Members are notified by email the first time SSO is activated. If you deactivate and reactivate within 24 hours, the notification is not re-sent.</p> + + <h4>For your members</h4> + <p>Members do not need to do anything to prepare. When they next try to access the organization's teams, they will be asked to authenticate through your IdP. If they are already signed in through that provider, the step is skipped automatically.</p> + <p>Org membership still requires an invitation from you. Being in the directory alone does not grant access to Penpot or to your organization.</p> + <p>If a member is not in your directory, they remain an org member but cannot enter the organization's teams until they are added. A single email is sent to all current members and pending invitees when SSO is first activated, explaining what changed and who to contact if they cannot get in.</p> + +<h3 id="frequently-asked-questions"> Frequently asked questions + <a class="direct-link" href="#frequently-asked-questions">#</a> +</h3> + + <h4>Can a team belong to more than one organization?</h4> + <p>No. A team belongs to a single organization.</p> + + <h4>Can non-owners access the Admin Console?</h4> + <p>No. Access to the Admin Console is currently exclusive to the organization owner.</p> + + <h4>What is the organization name used for?</h4> + <p>It's the human-readable name used to identify your organization in the UI, in emails, and in URLs. It is not your official billing name. You can change it at any time without affecting navigation or functionality.</p> + + <h4>Do Advanced Permissions replace the standard Penpot team roles?</h4> + <p>No. Advanced Permissions work on top of the existing roles (Viewer, Editor, Admin, Owner). They add an organization-wide ceiling on what any role can do, but they do not change how roles work within a team.</p> + + <h4>What is the default behavior when I first create an organization?</h4> + <p>All Controls are set to their most permissive option. Nothing changes until you actively configure a Control.</p> + + <h4>What happens to existing team admins if I change a Control that restricts their permissions?</h4> + <p>The restriction applies immediately. An admin who could previously perform an action will no longer be able to do so as soon as the Control is changed, with no grace period.</p> + + <h4>Is this the same as the authentication providers in Penpot's self-hosting configuration?</h4> + <p>No. Penpot's self-hosted configuration lets server administrators enable login methods (Google, GitHub, GitLab, OIDC) at the instance level, so users can sign in to Penpot itself with those providers. That is a server-level setting managed by whoever runs the infrastructure.</p> + <p>The SSO module in Enterprise is different in scope and purpose. It is configured by you, the organization owner, from the Admin Console, and it governs access to your organization's teams and files specifically. It does not change how users log in to Penpot as a platform, only whether they need to pass through your corporate identity provider to reach your organization's content.</p> + + <h4>Does SSO affect the Admin Console?</h4> + <p>No. The Admin Console is always accessible without SSO, regardless of your configuration. This ensures you can always reach your settings to adjust or deactivate SSO, even if something changes on the directory side.</p> + + <h4>What happens if my identity provider goes down while SSO is active?</h4> + <p>Current sessions continue until they expire. The next time a member tries to authenticate through your IdP and the provider is unreachable, the login will fail. There is no automatic bypass. Since the Admin Console is outside SSO, you can still reach your configuration to deactivate SSO if needed.</p> + + <h4>Can I use the same identity provider for more than one organization?</h4> + <p>Yes. Two different organizations, and the Penpot instance itself, can share the same IdP. A successful SSO login never grants org membership on its own, so there is no risk of cross-organization access. Membership always requires an explicit invitation.</p> + + <h4>What if a member is not in my directory?</h4> + <p>They remain an org member and keep their Penpot account, but they cannot enter the organization's teams until they are added to the directory. We send them an email when SSO is first activated explaining the situation and telling them to contact you.</p> + + <h4>Does accepting an invitation automatically give someone access to my org's teams?</h4> + <p>Only if they are also in your directory. An invitee can register and accept the invitation, but if they are not in the directory, they become an org member without being able to enter the teams. Acceptance is never blocked on that basis.</p> diff --git a/docs/user-guide/account-teams/projects-files.njk b/docs/user-guide/account-teams/projects-files.njk index 02f1afd38c..b4befb2ad8 100644 --- a/docs/user-guide/account-teams/projects-files.njk +++ b/docs/user-guide/account-teams/projects-files.njk @@ -1,6 +1,6 @@ --- title: Projects and Files -order: 3 +order: 5 desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items. --- @@ -56,6 +56,10 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro </figure> <p>When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.</p> +<h3 id="file-view">Grid and list view</h3> +<p>In the dashboard header you can switch how files are shown: a thumbnail <strong>Grid view</strong> or a compact <strong>List view</strong>. The choice is kept in the browser and applies both to the team files view and inside a project.</p> +<p>List view shows the file name, whether the file is a shared library, the last modification time, and the same options menu as the file cards.</p> + <h3 id="edit-file">Edit a file</h3> <p>To rename a file, right-click on the file card in the dashboard and select <strong>Rename</strong>, or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.</p> diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk index 550cf81390..64c39376d3 100644 --- a/docs/user-guide/account-teams/teams.njk +++ b/docs/user-guide/account-teams/teams.njk @@ -1,6 +1,6 @@ --- title: Teams -order: 2 +order: 4 desc: Manage teams and roles with Penpot's collaboration features! Learn how to manage teams, roles (Viewer, Editor, Admin, Owner), send invites and use webhooks. --- diff --git a/docs/user-guide/designing/color-stroke.njk b/docs/user-guide/designing/color-stroke.njk index 012b45b264..bd1cab3000 100644 --- a/docs/user-guide/designing/color-stroke.njk +++ b/docs/user-guide/designing/color-stroke.njk @@ -122,8 +122,14 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien <img alt="Multiple strokes" src="/img/styling/stroke-multiple.webp"/> </figure> +<h2 id="stroke-to-path">Stroke to path</h2> +<p>A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.</p> +<p><strong>Stroke to path</strong> turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.</p> +<p>To convert a stroke, select a layer that has one, open the layer menu, and choose <strong>Stroke to path</strong>. Penpot creates a new path from each stroke (named with " (stroke)") and removes the strokes from the original layer.</p> +<p class="advice">Stroke to path is currently available when <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL rendering</a> is enabled.</p> + <h2 id="stroke-caps">Stroke Caps</h2> -<p>Ever needed an arrow to point something? You can style the ends of any open paths selecting different styles for each end of an open path.</p> +<p>Ever needed an arrow to point to something? You can style the ends of any open paths selecting different styles for each end of an open path. You can also start from the <a href="/user-guide/designing/layers/#lines-arrows">Arrow tool</a>, which already applies a triangle cap.</p> <figure> <video title="Stroke cap" muted="" playsinline="" controls="" width="100%" poster="/img/styling/stroke-cap.webp" height="auto"> <source src="/img/styling/stroke-cap.mp4" type="video/mp4"> diff --git a/docs/user-guide/designing/layers.njk b/docs/user-guide/designing/layers.njk index 2c55a7a7ca..b46bc11f90 100644 --- a/docs/user-guide/designing/layers.njk +++ b/docs/user-guide/designing/layers.njk @@ -140,7 +140,7 @@ Penpot allows you to decide if the fill of an artboard will be shown in exports, <h3 id="rectangles-ellipses">Rectangles and ellipses</h3> <p>Rectangle and ellipses are two basic “primitive” geometric shapes that are useful when starting a design.</p> -<p>The shortcut keys are <kbd>E</kbd> for ellipses and <kbd>R</kbd> for rectangles.</p> +<p>The shortcut keys are <kbd>E</kbd> for ellipses and <kbd>R</kbd> for rectangles. Both tools live in the shapes flyout of the toolbar, together with line and arrow.</p> <p>To find out more about how to edit and modify these shapes go to <a href="/user-guide/designing/layers/#layer-actions">Layer basics</a>.</p> <figure> <video title="Rectangles and ellipses" muted="" playsinline="" controls="" width="auto" poster="/img/objects/rectangles-ellipses.webp" height="auto"> @@ -151,13 +151,24 @@ a design.</p> <h3 id="text">Text</h3> <p>Text layers are how you add copy to your designs in Penpot. If you want to go deeper into fonts, typography and advanced text options, check the dedicated <a href="/user-guide/designing/text-typo/">Text & Typography</a> section.</p> +<h3 id="lines-arrows">Lines and arrows</h3> +<p>Use the Line and Arrow tools to draw a two-point path in one drag. They live in the shapes flyout of the toolbar, together with rectangle and ellipse.</p> +<ul> + <li><strong>Line</strong> (<kbd>L</kbd>): click and drag. Hold <kbd>Shift/⇧</kbd> to snap the angle in 15 degree increments.</li> + <li><strong>Arrow</strong>: the same gesture, with a triangle arrow cap on the end. Pick it from the shapes flyout. You can change or remove the cap from the <a href="/user-guide/designing/color-stroke/#stroke-caps">stroke caps</a>.</li> +</ul> +<p>The result is a regular path, so you can edit its nodes and stroke like any other path.</p> +<figure> + <img src="/img/objects/line-arrow-tools.webp" alt="Line and Arrow tools in the shapes flyout of the toolbar" /> +</figure> + <h3 id="curves">Curves (freehand)</h3> <p>The curve tool allows a path to be created directly in a freehand mode. -Select the curve tool by clicking on the icon at the toolbar or pressing <kbd>Shift/⇧</kbd> + <kbd>c</kbd>. +Select the curve tool from the free-draw flyout in the toolbar or press <kbd>Shift/⇧</kbd> + <kbd>C</kbd>.</p> <p>The path created will contain a lot of points, but it is edited the same way as any other curve.</p> <h3 id="paths">Paths (bezier)</h3> -<p>A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path you have to select the path tool by clicking on the icon at the toolbar or pressing <kbd>P</kbd>. Then you have two ways to create the path:</p> +<p>A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press <kbd>P</kbd>. Then you have two ways to create the path:</p> <ol> <li><strong>Click</strong> to create a new corner node.</li> <li><strong>Click and drag</strong> to create a curved node.</li> @@ -211,7 +222,7 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd> <h2 id="layer-actions">Layer actions</h2> <h3 id="creating-layers">Create</h3> -<p>To create a layer you have to select the type of layer by clicking the selected tool (board, rectangle, ellipse, text, image, path or curve) at the toolbar. Then you usually have to click and drag your mouse on the viewport. </p> +<p>To create a layer, pick a tool from the toolbar. Board, text, and image stay on the toolbar. Rectangle, ellipse, line, and arrow are in the shapes flyout. Path and curve are in the free-draw flyout. Then you usually click and drag on the viewport.</p> <p>Hold <kbd>Shift/⇧</kbd> while creating an ellipse or a rectangle to maintain equal width and height.</p> <figure> <video title="Layers create" muted="" playsinline="" controls="" width="auto" poster="/img/layers/layers-create.webp" height="auto"> diff --git a/docs/user-guide/designing/text-typo.njk b/docs/user-guide/designing/text-typo.njk index 585a11b7cb..3317821ff2 100644 --- a/docs/user-guide/designing/text-typo.njk +++ b/docs/user-guide/designing/text-typo.njk @@ -32,7 +32,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe <img src="/img/objects/text-options.webp" alt="text options"> </figure> <ol> - <li><strong>Font family.</strong> Penpot includes by default the <a href="https://fonts.google.com/" target=”_blank”>Google Fonts</a> cataloge. You can also <a href="/user-guide/designing/text-typo/#custom-fonts">install your own fonts</a>.</li> + <li><strong>Font family.</strong> Penpot includes by default the <a href="https://fonts.google.com/" target=”_blank”>Google Fonts</a> cataloge. You can also <a href="/user-guide/designing/text-typo/#custom-fonts">install your own fonts</a>. In the font selector, each family name is shown in its own typeface so you can compare fonts before applying one. The name is the preview. While a font loads, you may briefly see the interface font instead. The preview uses a default variant, not every weight or style of the family.</li> <li><strong>Font size.</strong></li> <li><strong>Font type.</strong></li> <li><strong>Line height</strong> (in pixels).</li> diff --git a/docs/user-guide/designing/workspace-basics.njk b/docs/user-guide/designing/workspace-basics.njk index e87ff0d740..c0547d4cbc 100644 --- a/docs/user-guide/designing/workspace-basics.njk +++ b/docs/user-guide/designing/workspace-basics.njk @@ -43,6 +43,15 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools, </video> </figure> +<h4 id="select-delete-pages">Select and delete several pages</h4> +<p>In the Pages panel you can select more than one page and delete them in a single action.</p> +<ul> + <li>Click a page to open it. This selects only that page.</li> + <li><kbd>Shift/⇧</kbd> + click to select a range of pages. This does not change the page you are viewing.</li> + <li><kbd>Ctrl/⌘</kbd> + click to add or remove one page from the selection. This does not navigate.</li> +</ul> +<p>Right-click a selected page and choose <strong>Delete pages</strong>. Confirm to remove all selected pages in one step. A file always keeps at least one page. Page separators cannot be included in the selection.</p> + <h4 id="page-separators">Page separators</h4> <p>You can add visual dividers in the page list to group pages without extra structure. Create an empty page, then rename it to <code>---</code>. The page appears as a horizontal line in the list.</p> <p>A page only becomes a separator if its name is <code>---</code> and the page has no content on the canvas. A page with content that is named <code>---</code> stays a normal page. Separators cannot be opened or selected; you can reorder or delete them like other pages.</p> diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index 76b323ec31..b06057a9b9 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -681,8 +681,8 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface </tr> <tr> <td>Curve</td> - <td style="text-align: center;"><kbd>Ctrl</kbd><kbd>C</kbd></td> - <td style="text-align: center;"><kbd>⌘</kbd><kbd>C</kbd></td> + <td style="text-align: center;"><kbd>Shift</kbd><kbd>C</kbd></td> + <td style="text-align: center;"><kbd>⇧</kbd><kbd>C</kbd></td> </tr> <tr> <td>Ellipse</td> @@ -694,6 +694,11 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface <td style="text-align: center;"><kbd>Shift</kbd><kbd>K</kbd></td> <td style="text-align: center;"><kbd>⇧</kbd><kbd>K</kbd></td> </tr> + <tr> + <td>Line</td> + <td style="text-align: center;"><kbd>L</kbd></td> + <td style="text-align: center;"><kbd>L</kbd></td> + </tr> <tr> <td>Path</td> <td style="text-align: center;"><kbd>P</kbd></td> diff --git a/docs/user-guide/first-steps/the-interface.njk b/docs/user-guide/first-steps/the-interface.njk index 570087d6e8..c670c0f6c6 100644 --- a/docs/user-guide/first-steps/the-interface.njk +++ b/docs/user-guide/first-steps/the-interface.njk @@ -41,9 +41,9 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics, <ol> <li><strong>Viewport:</strong> An infinite canvas where you can design without limits.</li> - <li><strong>Toolbar: </strong> This is where you’ll find all the tools to quickly and easily create different types of layers: board, rectangle, ellipse, text, graphic, path, and free drawing. <a href="/user-guide/designing/layers/">Learn more about layers.</a></li> + <li><strong>Toolbar: </strong> Tools to create layers. Board, text, and image stay on the toolbar. Shapes (rectangle, ellipse, line, and arrow) and free-draw tools (path and curve) are grouped in flyouts. Open a flyout to pick a tool, or use its shortcut. <a href="/user-guide/designing/layers/">Learn more about layers.</a></li> <li><strong>Main menu:</strong> From the main menu, you can customize your workspace. Manage the visibility of grids, rulers, and panels. Enable or disable snapping and dynamic alignment. Add or remove the file as a Shared Library. You’ll also find help resources here.</li> - <li><strong>Pages:</strong> A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages as needed.</li> + <li><strong>Pages:</strong> A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages, and select several pages at once to delete them. <a href="/user-guide/designing/workspace-basics/#layer-basics">More about pages.</a></li> <li><strong>Layers:</strong> Layers are the different objects you can place in the design viewport. <a href="/user-guide/designing/workspace-basics/#layer-basics">More about Layers panel.</a></li> <li><strong>Rulers:</strong> Rulers provide coordinates to help you design. You can also drag guides from them.</li> <li><strong>Color palette:</strong> The color palette gives you quick access to a visible library of colors. Use the menu to easily switch between libraries. <a href="/user-guide/designing/color-stroke/#color-palette">Learn more about the color palette.</a>.</li> @@ -137,7 +137,7 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics, <li><strong>User area:</strong> This must be you! Access your <a href="/user-guide/account-teams/your-account"> profile settings</a>, Penpot tutorials, the Penpot Community and more. You can also find here a way to leave us feedback. We’d love to read your thoughts :). </li> <li><strong>Comments notifications:</strong> Here you will be able to see if you have unread comments inside the files of the team. There's also a button to mark all notifications as read.</li> <li><strong>Create project:</strong> Create as many projects as you need to organize your designs.</li> - <li><strong>File card:</strong> Basic information about a file at plain sight. A preview, update info or if it’s added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete).</li> + <li><strong>File card:</strong> Basic information about a file at plain sight. A preview, update info or if it’s added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete). You can also switch the files area between a thumbnail grid and a compact list. <a href="/user-guide/account-teams/projects-files/#file-view">More about grid and list view.</a></li> <li><strong>Libraries & Templates module:</strong> A curated selection of Libraries & Templates files ready to import.</li> </ol> diff --git a/exporter/package.json b/exporter/package.json index d17104e0b5..61ee7849a9 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 02f1974302..660f44b821 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/exporter/pnpm-workspace.yaml b/exporter/pnpm-workspace.yaml index ac94ff8d53..3a65caa2ac 100644 --- a/exporter/pnpm-workspace.yaml +++ b/exporter/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + allowBuilds: core-js-pure: false minimumReleaseAgeExclude: diff --git a/exporter/src/app/auth.cljs b/exporter/src/app/auth.cljs index d6ac0cf042..0bcfb27199 100644 --- a/exporter/src/app/auth.cljs +++ b/exporter/src/app/auth.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth "Resolves the caller's session cookie to a real profile id. diff --git a/exporter/src/app/handlers/export.cljs b/exporter/src/app/handlers/export.cljs index a7b309f652..65f5a7bcd6 100644 --- a/exporter/src/app/handlers/export.cljs +++ b/exporter/src/app/handlers/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.export "Handle export jobs" diff --git a/exporter/src/app/handlers/jobs.cljs b/exporter/src/app/handlers/jobs.cljs index eca18e8cca..08c687a3d7 100644 --- a/exporter/src/app/handlers/jobs.cljs +++ b/exporter/src/app/handlers/jobs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.jobs "REST surface for export jobs, under `/api/export/jobs`. diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs index 2630c847c7..e3497b2861 100644 --- a/exporter/src/app/jobs.cljs +++ b/exporter/src/app/jobs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.jobs "Export job model and lifecycle. diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs index efad44d99f..320689d90d 100644 --- a/exporter/src/app/jobs/scheduler.cljs +++ b/exporter/src/app/jobs/scheduler.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.jobs.scheduler "Admission control for export jobs. diff --git a/exporter/src/app/jobs/store.cljs b/exporter/src/app/jobs/store.cljs index 502c2d0676..3cfb27b951 100644 --- a/exporter/src/app/jobs/store.cljs +++ b/exporter/src/app/jobs/store.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.jobs.store "Redis persistence for export jobs. diff --git a/exporter/src/app/jobs/utils.cljs b/exporter/src/app/jobs/utils.cljs index 37480bb706..1aca381616 100644 --- a/exporter/src/app/jobs/utils.cljs +++ b/exporter/src/app/jobs/utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.jobs.utils "Temp file ownership for export jobs. diff --git a/exporter/src/app/router.cljs b/exporter/src/app/router.cljs index 991ab60a24..7b763edc8a 100644 --- a/exporter/src/app/router.cljs +++ b/exporter/src/app/router.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.router "Method + path dispatch. diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index cd6cbc09ea..0160a89a4a 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -25,6 +25,7 @@ [app.common.uuid :as uuid] ;; Required for side effects: binds the generated enums. [app.wasm.enums] + [cuerdas.core :as str] [promesa.core :as p] [shadow.esm :refer [dynamic-import]])) @@ -190,6 +191,22 @@ (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) false))))) +(defn store-image-url! + "Registers the public URL an image was loaded from. The SVG export emits + linked `<image href>` from these, and falls back to Skia base64 when missing, + so this should run for every media id the scene references (including + already-cached images). + + Does NOT call `mem/free`, for the same reason as `store-font-url!`." + [image-id url] + (when (and (some? url) (not (str/blank? url))) + (let [bytes (js/Buffer.from url "utf-8") + ptr (mem/alloc (.-byteLength bytes)) + quart (uuid/get-u32 image-id)] + (mem/write-buffer ptr (mem/get-heap-u8) bytes) + (h/call wasm/internal-module "_store_image_url" + (aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3))))) + (defn store-image! "Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into the WASM image store via `_store_image`. Buffer layout matches the Rust reader: diff --git a/exporter/src/app/wasm/pool.cljs b/exporter/src/app/wasm/pool.cljs index 64f8c9d05e..e9e8ded681 100644 --- a/exporter/src/app/wasm/pool.cljs +++ b/exporter/src/app/wasm/pool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm.pool "Pool of headless render workers. diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index 6993c53bba..77e53a9e6b 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm.render "Headless render pipeline: renders exports with the render-wasm Skia pipeline, @@ -369,13 +369,18 @@ "Fetches and stores every image the scene references (shape, stroke and text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, the image store is not reset per request, so already-held images are skipped - and repeated exports of a file reuse them." + and repeated exports of a file reuse them. + + Always registers a public media URL for each id so SVG export can emit linked + `<image href>` even when the encoded bytes were already cached." [scene params] (let [all-ids (images/scene-image-ids scene) new-ids (remove wasm/image-cached? all-ids)] (l/dbg :hint "wasm render: provisioning images" :total (count all-ids) :cached (- (count all-ids) (count new-ids))) + (doseq [image-id all-ids] + (wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id)))) (->> new-ids (map (fn [image-id] (->> (fetch-file-media-bytes image-id params) diff --git a/exporter/src/app/wasm/worker.cljs b/exporter/src/app/wasm/worker.cljs index c58f0f9cb2..5e5aeec5fc 100644 --- a/exporter/src/app/wasm/worker.cljs +++ b/exporter/src/app/wasm/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm.worker "Render worker entry point. diff --git a/exporter/test/exporter_tests/export_shapes_test.cljs b/exporter/test/exporter_tests/export_shapes_test.cljs index d512cabd78..23a682a295 100644 --- a/exporter/test/exporter_tests/export_shapes_test.cljs +++ b/exporter/test/exporter_tests/export_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.export-shapes-test "Chunking of the browser backend." diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs index 899c0f350f..c8baa9b9fd 100644 --- a/exporter/test/exporter_tests/jobs_test.cljs +++ b/exporter/test/exporter_tests/jobs_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.jobs-test "Job state machine. Runs without redis: a store write with no connection is diff --git a/exporter/test/exporter_tests/scheduler_test.cljs b/exporter/test/exporter_tests/scheduler_test.cljs index 48c3791632..04349480bf 100644 --- a/exporter/test/exporter_tests/scheduler_test.cljs +++ b/exporter/test/exporter_tests/scheduler_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.scheduler-test "Admission control. A headless job leases one render worker for its whole run, diff --git a/exporter/test/exporter_tests/wasm_pool_test.cljs b/exporter/test/exporter_tests/wasm_pool_test.cljs index 4bf85eaa82..80837626e2 100644 --- a/exporter/test/exporter_tests/wasm_pool_test.cljs +++ b/exporter/test/exporter_tests/wasm_pool_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.wasm-pool-test "Worker leasing, against a stub pool: `with-worker` must give the worker back diff --git a/frontend/.storybook/vitest.setup.ts b/frontend/.storybook/vitest.setup.ts deleted file mode 100644 index f914b38bef..0000000000 --- a/frontend/.storybook/vitest.setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { setProjectAnnotations } from '@storybook/react-vite'; -import * as projectAnnotations from './preview'; - -// This is an important step to apply the right configuration when testing your stories. -// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations -setProjectAnnotations([projectAnnotations]); \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index c319bcab55..e39dbf1443 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 682dfeb8d0..e9f6c0ffd7 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index ece42204ef..509d95b0ed 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,7 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json index 4689f02e86..cc8330b50e 100644 --- a/frontend/packages/tokenscript/package.json +++ b/frontend/packages/tokenscript/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 131a0e6d85..bd2351e197 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json b/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json new file mode 100644 index 0000000000..dde4a18d20 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json @@ -0,0 +1,208 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "text-editor/v2", + "render-wasm/v1", + "text-editor-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 3", + "~:revn": 1, + "~:modified-at": "~m1788780032767", + "~:vern": 0, + "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node", + "0017-fix-layout-flex-dir", + "0018-remove-unneeded-objects-from-components", + "0019-fix-missing-swap-slots", + "0020-sync-component-id-with-near-main", + "0021-fix-shape-svg-attrs", + "0022-normalize-component-root-and-resync", + "0023-repair-token-themes-with-inexistent-sets", + "0024b-fix-stroke-cap-placement", + "0025-repair-empty-text-content", + "0026-fix-svg-raw-shapes-uuids" + ] + }, + "~:version": 67, + "~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0", + "~:created-at": "~m1788779992563", + "~:backend": "db", + "~:data": { + "~:pages": [ + "~u77d38721-22c1-81f4-8008-9a2a3e7ce675" + ], + "~:pages-index": { + "~u77d38721-22c1-81f4-8008-9a2a3e7ce675": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a57981d1d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-15\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",300.0000008940697,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",300.0000008940697,\"~:y1\",0,\"~:x2\",320.0000011920929,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58059cfd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-35\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",700.0000020861626,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",700.0000020861626,\"~:y1\",0,\"~:x2\",720.0000023841858,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a576c177d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-9\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",180.0000005364418,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",180.0000005364418,\"~:y1\",0,\"~:x2\",200.00000083446503,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57d786dd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-26\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",520.0000015497208,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",520.0000015497208,\"~:y1\",0,\"~:x2\",540.000001847744,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57b171fc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-19\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",380.00000113248825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",380.00000113248825,\"~:y1\",0,\"~:x2\",400.0000014305115,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a578122dc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-12\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",240.00000071525574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",240.00000071525574,\"~:y1\",0,\"~:x2\",260.00000101327896,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58193cbf": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-39\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",780.0000023245811,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",780.0000023245811,\"~:y1\",0,\"~:x2\",800.0000026226044,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5814337e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-38\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",760.0000022649765,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",760.0000022649765,\"~:y1\",0,\"~:x2\",780.0000025629997,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57e7221e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-29\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",580.0000017285347,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",580.0000017285347,\"~:y1\",0,\"~:x2\",600.0000020265579,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a580f6e3e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-37\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",740.0000022053719,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",740.0000022053719,\"~:y1\",0,\"~:x2\",760.0000025033951,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a577afc79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-11\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",220.0000006556511,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",220.0000006556511,\"~:y1\",0,\"~:x2\",240.00000095367432,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57ec0a79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-30\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",600.0000017881393,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",600.0000017881393,\"~:y1\",0,\"~:x2\",620.0000020861626,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57f5d458": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-32\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",640.0000019073486,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",640.0000019073486,\"~:y1\",0,\"~:x2\",660.0000022053719,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5833c5bb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-44\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",880.0000026226044,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",880.0000026226044,\"~:y1\",0,\"~:x2\",900.0000029206276,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5772831b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-10\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",200.00000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",200.00000059604645,\"~:y1\",0,\"~:x2\",220.00000089406967,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5849a83a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-48\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",960.000002861023,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",960.000002861023,\"~:y1\",0,\"~:x2\",980.0000031590462,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57c1477a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-22\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",440.0000013113022,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",440.0000013113022,\"~:y1\",0,\"~:x2\",460.0000016093254,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a581f6595": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-40\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",800.0000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",800.0000023841858,\"~:y1\",0,\"~:x2\",820.000002682209,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5722f855": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-0\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",20.000000298023224,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5853f075": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-50\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1000.0000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1000.0000029802322,\"~:y1\",0,\"~:x2\",1020.0000032782555,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58713434": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5878a273\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a58713434\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",259.99999046325684,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a579e7ad4": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-16\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",320.0000009536743,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",320.0000009536743,\"~:y1\",0,\"~:x2\",340.00000125169754,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a586a1217": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",39.99999809265137,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",39.99999809265137,\"~:y1\",40.000003814697266,\"~:x2\",219.99999332427979,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a58713434\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a588ce9f6": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58944011\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",179.99999523162842,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^=\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:strokes\",[],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^:\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a587ff716": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5885fde3\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a587ff716\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:strokes\",[],\"~:x\",259.9999752044678,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",259.9999752044678,\"~:y1\",0,\"~:x2\",519.9999656677246,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5894fa56": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-text-child\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",819.999960899353,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",819.999960899353,\"~:y1\",40.000003814697266,\"~:x2\",999.9999561309814,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a589b7051\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a589b7051": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58a8f6a4\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"110\",\"~:font-weight\",\"400\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.25]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"],[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\",\"^N\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"blurred-text-child\",\"~:width\",117,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",790,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",324]],[\"^R\",[\"^ \",\"~:x\",790,\"~:y\",324]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a589b7051\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:position-data\",[[\"^ \",\"~:y\",197.22000122070312,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",142.44000244140625,\"^N\",\"Aa\"],[\"^ \",\"~:y\",329.2200012207031,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^Z\",\"ltr\",\"^M\",\"sourcesanspro\",\"^[\",142.44000244140625,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:x\",790,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",790,\"~:y\",60,\"^P\",117,\"^[\",264,\"~:x1\",790,\"~:y1\",60,\"~:x2\",907,\"~:y2\",324]],\"~:flip-x\",null,\"^[\",264,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58248d30": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-41\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",820.0000024437904,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",820.0000024437904,\"~:y1\",0,\"~:x2\",840.0000027418137,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5800e610": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-34\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",680.0000020265579,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",680.0000020265579,\"~:y1\",0,\"~:x2\",700.0000023245811,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a585a7293": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-51\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1020.0000030398369,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1020.0000030398369,\"~:y1\",0,\"~:x2\",1040.00000333786,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a573259d2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-1\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",20.000000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",20.000000059604645,\"~:y1\",0,\"~:x2\",40.00000035762787,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57b6beb2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-20\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",400.0000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",400.0000011920929,\"~:y1\",0,\"~:x2\",420.0000014901161,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5886910d": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-child-exact\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a584428ad": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-47\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",940.0000028014183,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",940.0000028014183,\"~:y1\",0,\"~:x2\",960.0000030994415,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5749400d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-4\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",80.00000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",80.00000023841858,\"~:y1\",0,\"~:x2\",100.0000005364418,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a578dcf0d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-14\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",280.000000834465,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",280.000000834465,\"~:y1\",0,\"~:x2\",300.00000113248825,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5864694c": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",53,\"~:name\",\"backdrop\",\"~:width\",1060.0000033974648,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",1060.0000033974648,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",320.0000047683716,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a5750f0ef": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-5\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",100.00000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",100.00000029802322,\"~:y1\",0,\"~:x2\",120.00000059604645,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a573b052e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-2\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",40.00000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",40.00000011920929,\"~:y1\",0,\"~:x2\",60.00000041723251,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a582f24ee": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-43\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",860.0000025629997,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",860.0000025629997,\"~:y1\",0,\"~:x2\",880.000002861023,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57cd8cae": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-24\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",480.0000014305115,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",480.0000014305115,\"~:y1\",0,\"~:x2\",500.0000017285347,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5765bd49": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-8\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",160.00000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",160.00000047683716,\"~:y1\",0,\"~:x2\",180.00000077486038,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a587927c9": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-off-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",299.99998569488525,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",299.99998569488525,\"~:y1\",40.000003814697266,\"~:x2\",479.9999809265137,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a587ff716\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a5829b348": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-42\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",840.0000025033951,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",840.0000025033951,\"~:y1\",0,\"~:x2\",860.0000028014183,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a583a7c6b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-45\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",900.000002682209,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",900.000002682209,\"~:y1\",0,\"~:x2\",920.0000029802322,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a580a33cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-36\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",720.0000021457672,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",720.0000021457672,\"~:y1\",0,\"~:x2\",740.0000024437904,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a574272cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-3\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",60.000000178813934,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",60.000000178813934,\"~:y1\",0,\"~:x2\",80.00000047683716,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57bc32eb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-21\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",420.00000125169754,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",420.00000125169754,\"~:y1\",0,\"~:x2\",440.00000154972076,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a575f6f84": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-7\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",140.0000004172325,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",140.0000004172325,\"~:y1\",0,\"~:x2\",160.00000071525574,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57dcb9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-27\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",540.0000016093254,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",540.0000016093254,\"~:y1\",0,\"~:x2\",560.0000019073486,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57f0d9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-31\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",620.000001847744,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",620.000001847744,\"~:y1\",0,\"~:x2\",640.0000021457672,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57e26c66": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-28\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",560.00000166893,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",560.00000166893,\"~:y1\",0,\"~:x2\",580.0000019669533,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57a5a180": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-17\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",340.00000101327896,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",340.00000101327896,\"~:y1\",0,\"~:x2\",360.0000013113022,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57d2c500": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-25\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",500.0000014901161,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",500.0000014901161,\"~:y1\",0,\"~:x2\",520.0000017881393,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a583f7ca0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-46\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",920.0000027418137,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",920.0000027418137,\"~:y1\",0,\"~:x2\",940.0000030398369,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a578826e0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-13\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",260.0000007748604,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",260.0000007748604,\"~:y1\",0,\"~:x2\",280.0000010728836,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57abfe20": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-18\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",360.0000010728836,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",360.0000010728836,\"~:y1\",0,\"~:x2\",380.00000137090683,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a584e9da3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-49\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",980.0000029206276,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",980.0000029206276,\"~:y1\",0,\"~:x2\",1000.0000032186508,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a585f48c3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-52\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1040.0000030994415,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1040.0000030994415,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57faee43": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-33\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",660.0000019669533,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",660.0000019669533,\"~:y1\",0,\"~:x2\",680.0000022649765,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57590dc2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-6\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.00000035762787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",120.00000035762787,\"~:y1\",0,\"~:x2\",140.0000006556511,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57c81962": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-23\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",460.00000137090683,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",460.00000137090683,\"~:y1\",0,\"~:x2\",480.00000166893005,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]" + } + }, + "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce675", + "~:name": "bg-blur-clip" + } + }, + "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/data/render-wasm/get-file-text-span-decoration.json b/frontend/playwright/data/render-wasm/get-file-text-span-decoration.json new file mode 100644 index 0000000000..b3e4ebe3c1 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-text-span-decoration.json @@ -0,0 +1,2339 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "text-span-decoration", + "~:revn": 7, + "~:modified-at": "~m1749629891313", + "~:vern": 0, + "~:id": "~u1d0f6a4c-0000-8000-8006-000000000001", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content", + "0004-clean-shadow-and-colors", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-opacity" + ] + }, + "~:version": 67, + "~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07", + "~:created-at": "~m1749629823499", + "~:data": { + "~:pages": [ + "~u1d0f6a4c-0000-8000-8006-000000000002" + ], + "~:pages-index": { + "~u1d0f6a4c-0000-8000-8006-000000000002": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u1d0f6a4c-0000-8000-8006-000000000003", + "~u1d0f6a4c-0000-8000-8006-000000000004" + ] + } + }, + "~u1d0f6a4c-0000-8000-8006-000000000003": { + "~:y": 100.0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "span-decoration-root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "plain " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "under" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " mid " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "struck" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " tail" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-same-fill", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "plain " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#1A7FDA", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "under" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " mid " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#DA1A1A", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "struck" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " tail" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-mixed-fill", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "a " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "one" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " b " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "two" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " c" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-two-underlines", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "x " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "one" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " y " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "cut" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " z " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u5", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "two" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-underline-strike-underline", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "When three bodies orbit " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "each" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " other, the " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "resulting" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w5", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "dynamical" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w6", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " system is " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w7", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "chaotic" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w8", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " for most initial " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w9", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "conditions" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-wrapped", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "m " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "one" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " n " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "cut" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " o" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-two-fills", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "span decorations", + "~:width": 800.0, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 100.0, + "~:y": 100.0 + } + }, + { + "~#point": { + "~:x": 900.0, + "~:y": 100.0 + } + }, + { + "~#point": { + "~:x": 900.0, + "~:y": 700.0 + } + }, + { + "~#point": { + "~:x": 100.0, + "~:y": 700.0 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~u1d0f6a4c-0000-8000-8006-000000000003", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 100.0, + "~:selrect": { + "~#rect": { + "~:x": 100.0, + "~:y": 100.0, + "~:width": 800.0, + "~:height": 600.0, + "~:x1": 100.0, + "~:y1": 100.0, + "~:x2": 900.0, + "~:y2": 700.0 + } + }, + "~:flip-x": null, + "~:height": 600.0, + "~:flip-y": null + }, + "~u1d0f6a4c-0000-8000-8006-000000000004": { + "~:y": 760.0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "span-decoration-root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "plain " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "under" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " mid " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "struck" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "sf4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " tail" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-same-fill", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "plain " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#1A7FDA", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "under" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " mid " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#DA1A1A", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "struck" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "mf4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " tail" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-mixed-fill", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "a " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "one" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " b " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "two" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "t4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " c" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-two-underlines", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "x " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "one" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " y " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "cut" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " z " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "u5", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "two" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-underline-strike-underline", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "When three bodies orbit " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "each" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " other, the " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "resulting" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w5", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "dynamical" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w6", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " system is " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w7", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "chaotic" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w8", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " for most initial " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "w9", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "conditions" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-wrapped", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v0", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "m " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v1", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "one" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v2", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " n " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v3", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "cut" + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "v4", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#2E7D32", + "~:fill-opacity": 0.5 + }, + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": " o" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "p-two-fills", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "span decorations with shadow", + "~:width": 800.0, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 100.0, + "~:y": 760.0 + } + }, + { + "~#point": { + "~:x": 900.0, + "~:y": 760.0 + } + }, + { + "~#point": { + "~:x": 900.0, + "~:y": 1360.0 + } + }, + { + "~#point": { + "~:x": 100.0, + "~:y": 1360.0 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~u1d0f6a4c-0000-8000-8006-000000000004", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 100.0, + "~:selrect": { + "~#rect": { + "~:x": 100.0, + "~:y": 760.0, + "~:width": 800.0, + "~:height": 600.0, + "~:x1": 100.0, + "~:y1": 760.0, + "~:x2": 900.0, + "~:y2": 1360.0 + } + }, + "~:flip-x": null, + "~:height": 600.0, + "~:flip-y": null, + "~:shadow": [ + { + "~:id": "~u1d0f6a4c-0000-8000-8006-0000000000f1", + "~:style": "~:drop-shadow", + "~:color": { + "~:color": "#DA1A1A", + "~:opacity": 1 + }, + "~:offset-x": 8, + "~:offset-y": 8, + "~:blur": 0, + "~:spread": 0, + "~:hidden": false + } + ] + } + }, + "~:id": "~u1d0f6a4c-0000-8000-8006-000000000002", + "~:name": "Page 1" + } + }, + "~:id": "~u1d0f6a4c-0000-8000-8006-000000000001", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index a85b70887e..909f494d5d 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -608,4 +608,21 @@ test("Renders background blur under strokes on rects, paths and texts", async ({ await workspace.waitForFirstRenderWithoutUI(); await expect(workspace.canvas).toHaveScreenshot(); -}); \ No newline at end of file +}); + +test("Renders background blur clipped by a board with clip content", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-background-blur-clip.json"); + + await workspace.goToWorkspace({ + id: "77d38721-22c1-81f4-8008-9a2a3e7ce674", + pageId: "77d38721-22c1-81f4-8008-9a2a3e7ce675", + pageName: "bg-blur-clip", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png new file mode 100644 index 0000000000..6f5057acfb Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index 4122d7e307..b4d4c8373b 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -274,6 +274,25 @@ test("Renders a file with different text leaves decoration", async ({ await expect(workspace.canvas).toHaveScreenshot(); }); +// Both paragraphs decorate the same spans; the first one paints every span with +// the same fill, which used to collapse the decorated spans into their +// neighbours and drop their underline / line-through. +test("Renders text spans decorated independently of their fill", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-text-span-decoration.json"); + + await workspace.goToWorkspace({ + id: "1d0f6a4c-0000-8000-8006-000000000001", + pageId: "1d0f6a4c-0000-8000-8006-000000000002", + }); + + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot(); +}); + test("Renders a file with different text shadows combinations", async ({ page, }) => { diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png index 101315c965..794396f670 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png index 906a1e5b76..a0ab6eb273 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png index 70ba1f8cbf..673065e4c6 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png index 646da5cdb5..076a83fb09 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png index 9ef0018566..76ea3820c4 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png index 3d9abd0bae..b3d708819e 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png index 5725d511bb..821478ccb7 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png index 4ce228ad4d..691fe62d70 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png index f0ae6555ba..40e7f3bdc1 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png index ded0cc7465..e7121ff76e 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png index 2a83a06ac5..6243bdc685 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png index a5f78ca8f7..09a4b78740 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-text-spans-decorated-independently-of-their-fill-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-text-spans-decorated-independently-of-their-fill-1.png new file mode 100644 index 0000000000..098a61acd1 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-text-spans-decorated-independently-of-their-fill-1.png differ diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 01866c4118..64aa97b44e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 2c3a2c026f..c30ce8c1ea 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + packages: - "packages/draft-js" - "packages/mousetrap" diff --git a/frontend/src/app/main/constants.cljs b/frontend/src/app/main/constants.cljs index 4aeaa0461e..1c401c9558 100644 --- a/frontend/src/app/main/constants.cljs +++ b/frontend/src/app/main/constants.cljs @@ -299,7 +299,21 @@ :height 1152} {:name "YouTube thumb" :width 1280 - :height 720}]) + :height 720} + + {:name "PENPOT"} + {:name "File thumbnail" + :width 300 + :height 200} + {:name "Template cover" + :width 1390 + :height 781} + {:name "Plugin icon" + :width 400 + :height 400} + {:name "Plugin cover" + :width 1390 + :height 724}]) (def max-input-length 255) diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index c156536de5..2b28a07acb 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -19,6 +19,7 @@ [app.main.data.team :as dtm] [app.main.repo :as rp] [app.util.i18n :as i18n :refer [tr]] + [app.util.storage :as storage] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -531,6 +532,35 @@ (update [_ state] (update state :comments-local dissoc :expanded)))) +(def ^:private hide-resolved-comments-storage-key + :app.main.data.comments/hide-resolved-comments?) + +(defn- load-hide-resolved-comments? + [] + (= true (get @storage/user hide-resolved-comments-storage-key))) + +(defn- persist-hide-resolved-comments! + [hide?] + (swap! storage/user assoc hide-resolved-comments-storage-key hide?)) + +(defn merge-persisted-filters + "Merge persisted hide-resolved preference into comments local state." + [local] + (let [local (or local {})] + (if (contains? local :show) + local + (assoc local :show (if (load-hide-resolved-comments?) + :pending + :all))))) + +(defn initialize-comments-filters + "Load persisted comment filter preferences into `:comments-local`." + [] + (ptk/reify ::initialize-comments-filters + ptk/UpdateEvent + (update [_ state] + (update state :comments-local merge-persisted-filters)))) + (defn update-filters [{:keys [mode show list] :as params}] (ptk/reify ::update-filters @@ -546,7 +576,12 @@ (assoc :show show) (some? list) - (assoc :list list))))))) + (assoc :list list))))) + + ptk/EffectEvent + (effect [_ _ _] + (when (some? show) + (persist-hide-resolved-comments! (= :pending show)))))) (defn update-options [params] diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 4ad40beade..7e6de36796 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -77,7 +77,8 @@ (if (nil? lstate) default-local-state lstate))) - (assoc-in [:viewer-local :share-id] share-id))) + (assoc-in [:viewer-local :share-id] share-id) + (update :comments-local dcmt/merge-persisted-filters))) ptk/WatchEvent (watch [_ state _] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 245df6008f..aa8ddcd7c8 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -403,7 +403,8 @@ (assoc :recent-fonts (:recent-fonts storage/user)) (assoc :current-file-id file-id) (assoc :workspace-presence {}) - (update :workspace-global dissoc :default-font))) + (update :workspace-global dissoc :default-font) + (update :comments-local dcmt/merge-persisted-filters))) ptk/WatchEvent (watch [_ state stream] diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index d4e7f587f8..433d9fea6f 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -333,13 +333,16 @@ [:stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-per-side :stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left :stroke-cap-start - :stroke-cap-end]) + :stroke-cap-end + :hidden]) ;; FIXME: this function initializes an empty stroke, maybe we can move ;; it to common.types diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index b78add32b7..47d97b4249 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -99,7 +99,9 @@ :layout-item-margin-type :layout-grid-cells :layout-grid-columns - :layout-grid-rows}) + :layout-grid-rows + :fills + :fill-image}) ;; -- temporary modifiers ------------------------------------------- @@ -703,9 +705,9 @@ #_:clj-kondo/ignore (defn set-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis subtree-ids-by-id selection-rect-cache] - :or {ignore-constraints false ignore-snap-pixel false} + :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil} :as params}] (let [modif-tree (without-nil-ids modif-tree)] (ptk/reify ::set-wasm-modifiers @@ -756,7 +758,7 @@ root-modifiers :else - (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)] (if (seq propagated) propagated root-modifiers)))] (when wasm-ready? (wasm.api/set-modifiers modifiers)) @@ -831,10 +833,8 @@ ;; primaries and descendants would snap back to their ;; pre-drag positions on drop. ;; - ;; Skipped when `snap-pixel?` is on: WASM applies - ;; per-shape pixel correction (different scale/translate - ;; per descendant) which we can't replicate cheaply on - ;; the CLJS side. + ;; Only without `snap-pixel?`: the delta that lands + ;; the shape on the pixel grid is known to WASM alone. (reduce (fn [acc [id data]] (let [t (:transform data) @@ -864,7 +864,7 @@ geometry-entries)) :else - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis))) ignore-tree (calculate-ignore-tree-wasm transforms objects) diff --git a/frontend/src/app/main/data/workspace/path/clipboard.cljs b/frontend/src/app/main/data/workspace/path/clipboard.cljs index 003b555e6b..5d1e0f5bab 100644 --- a/frontend/src/app/main/data/workspace/path/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/path/clipboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.clipboard (:require diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 1908884677..1056c085e0 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -124,7 +124,8 @@ (defn- await-font-faces "Waits for missing WASM faces, then resizes the affected texts." [stream face-keys ids] - (let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))] + (let [resize-opts {:stack-undo? true :undo-transation? false} + resize-stream (->> (rx/from ids) (rx/map #(dwwt/resize-wasm-text % resize-opts)))] (if (empty? face-keys) resize-stream (->> (rx/merge wasm.fonts/font-stored-stream diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index ebc6e79d9e..b83d09842a 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -12,6 +12,7 @@ [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.component :as ctc] + [app.common.types.shape-tree :as ctt] [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.helpers :as dsh] @@ -23,6 +24,7 @@ [app.main.render :as render] [app.main.repo :as rp] [app.util.queue :as q] + [app.util.storage :as storage] [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.v2.core :as rx] @@ -289,13 +291,60 @@ (mapcat get-frame-ids-cached)) changes)))) +;; Board thumbnails used to render text shapes without position-data as +;; nothing instead of falling back to the foreignObject renderer (see +;; frame-imposter in app.main.render), so any board thumbnail cached before +;; that fix may be missing its text. The backend doesn't tell the client +;; when a fetched thumbnail was generated, so we can't tell stale apart from +;; fresh by inspecting it; instead each board thumbnail with text content is +;; regenerated at most once per browser, tracked via local-storage so repeat +;; visits (once healed) don't keep re-rendering it. +(def ^:private healed-storage-key ::healed-text-thumbnails) + +(defn- frame-has-text? + [objects frame-id] + (->> (cfh/get-children-with-self objects frame-id) + (some cfh/text-shape?) + (some?))) + +(defn- unhealed-text-thumbnail? + [state object-id] + (and (some? (dm/get-in state [:thumbnails object-id :uri])) + (not (contains? (get @storage/global healed-storage-key) object-id)))) + +(defn- mark-thumbnail-healed! + [object-id] + (swap! storage/global update healed-storage-key (fnil conj #{}) object-id)) + +(defn- heal-stale-text-thumbnails + "Emits an `update-thumbnail` for every board on the page that has text + content and hasn't already been healed (see `healed-storage-key`) in this + browser." + [state file-id page-id] + (let [objects (-> (dsh/lookup-file-data state file-id) + (dsh/get-page page-id) + :objects) + frame-ids (ctt/get-root-frames-ids objects) + xf (comp + (filter #(frame-has-text? objects %)) + (keep + (fn [frame-id] + (let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")] + (when (unhealed-text-thumbnail? state object-id) + [frame-id object-id])))))] + (->> (rx/from (eduction xf frame-ids)) + (rx/map + (fn [[frame-id object-id]] + (mark-thumbnail-healed! object-id) + (update-thumbnail file-id page-id frame-id "frame" "heal-stale-text-thumbnails")))))) + (defn watch-state-changes "Watch the state for changes inside frames. If a change is detected will force a rendering of the frame data so the thumbnail can be updated." [file-id page-id] (ptk/reify ::watch-state-changes ptk/WatchEvent - (watch [_ _ stream] + (watch [_ state stream] (let [stopper-s (rx/filter (fn [event] (as-> (ptk/type event) type @@ -330,6 +379,10 @@ (rx/tap #(l/trc :hint "buffer initialized")))] (->> (rx/merge + ;; Heal boards with text whose cached thumbnail may predate the + ;; text-position fix (see heal-stale-text-thumbnails). + (heal-stale-text-thumbnails state file-id page-id) + ;; Perform instant thumbnail cleaning of affected frames ;; and interrupt any ongoing update-thumbnail process ;; related to current frame-id diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index aa43f69c1d..15990f2de3 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -149,10 +149,15 @@ ;; -- Resize -------------------------------------------------------- +(defn- shape-has-image-fill? + [shape] + (boolean (or (some :fill-image (:fills shape)) + (:fill-image shape)))) + (defn start-resize "Enter mouse resize mode, until mouse button is released." [handler ids shape] - (letfn [(resize [shape initial layout objects [point lock? center? point-snap]] + (letfn [(resize [shape initial layout objects [point lock? center? bounds-resize? point-snap]] (let [selrect (dm/get-prop shape :selrect) width (dm/get-prop selrect :width) height (dm/get-prop selrect :height) @@ -235,7 +240,59 @@ (not (mth/close? (dm/get-prop scalev :x) 1)) change-height? - (not (mth/close? (dm/get-prop scalev :y) 1))] + (not (mth/close? (dm/get-prop scalev :y) 1)) + + ;; Calculate independent image bounds resize transform + sx (dm/get-prop scalev :x) + sy (dm/get-prop scalev :y) + w-new (* width sx) + h-new (* height sy) + + bounds-resize? (and ^boolean bounds-resize? + (pos? w-new) + (pos? h-new)) + + [dx dy] (if ^boolean center? + [(/ (* width (- 1.0 sx)) 2.0) + (/ (* height (- 1.0 sy)) 2.0)] + [(case handler + (:left :bottom-left :top-left) (* width (- 1.0 sx)) + 0.0) + (case handler + (:top :top-left :top-right) (* height (- 1.0 sy)) + 0.0)]) + + new-fills + (when (and bounds-resize? (seq (:fills shape))) + (mapv (fn [fill] + (if-let [img-fill (:fill-image fill)] + (let [tf (get img-fill :transform) + nx0 (get tf :x 0.0) + ny0 (get tf :y 0.0) + nw0 (get tf :width 1.0) + nh0 (get tf :height 1.0) + nx' (/ (- (* nx0 width) dx) w-new) + ny' (/ (- (* ny0 height) dy) h-new) + nw' (/ nw0 sx) + nh' (/ nh0 sy)] + (assoc-in fill [:fill-image :transform] + {:x nx' :y ny' :width nw' :height nh'})) + fill)) + (:fills shape))) + + new-fill-image + (when (and bounds-resize? (some? (:fill-image shape))) + (let [img-fill (:fill-image shape) + tf (get img-fill :transform) + nx0 (get tf :x 0.0) + ny0 (get tf :y 0.0) + nw0 (get tf :width 1.0) + nh0 (get tf :height 1.0) + nx' (/ (- (* nx0 width) dx) w-new) + ny' (/ (- (* ny0 height) dy) h-new) + nw' (/ nw0 sx) + nh' (/ nh0 sy)] + (assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))] (cond-> (ctm/empty) (some? displacement) @@ -258,18 +315,30 @@ (and new-grow-type (not= new-grow-type (dm/get-prop shape :grow-type))) (ctm/change-property :grow-type new-grow-type) + (and bounds-resize? (some? new-fills)) + (ctm/change-property :fills new-fills) + + (and bounds-resize? (some? new-fill-image)) + (ctm/change-property :fill-image new-fill-image) + ^boolean scale-text (ctm/scale-content (dm/get-prop scalev :x))))) ;; Unifies the instantaneous proportion lock modifier ;; activated by Shift key and the shapes own proportion ;; lock flag that can be activated on element options. - (normalize-proportion-lock [[point shift? alt?]] - (let [proportion-lock? (:proportion-lock shape)] + (normalize-proportion-lock [[point shift? alt? mod?]] + (let [has-img? (shape-has-image-fill? shape) + bounds-resize? (and has-img? (boolean mod?)) + proportion-lock? (:proportion-lock shape) + lock? (if bounds-resize? + (boolean shift?) + (or ^boolean proportion-lock? + ^boolean shift?))] [point - (or ^boolean proportion-lock? - ^boolean shift?) - alt?]))] + lock? + alt? + bounds-resize?]))] (reify ptk/UpdateEvent (update [_ state] @@ -297,10 +366,10 @@ resize-events-stream (->> ms/mouse-position (rx/filter some?) - (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt) + (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt ms/mouse-position-mod) (rx/map normalize-proportion-lock) (rx/switch-map - (fn [[point _ _ :as current]] + (fn [[point _ _ _ :as current]] (->> (snap/closest-snap-point page-id shapes objects layout zoom focus point) (rx/map #(conj current %))))) (rx/map #(resize shape initial-position layout objects %)) @@ -592,12 +661,14 @@ (rx/merge (->> angle-stream (rx/sample mconst/rotation-sample-time) - (rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center))) + (rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center) + :ignore-snap-pixel true)) (rx/take-until stopper)) (->> angle-stream (rx/take-until stopper) (rx/last) - (rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center))))) + (rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center) + :ignore-snap-pixel true)))) (rx/of (finish-transform))) @@ -638,7 +709,9 @@ modif-tree (dwm/build-modif-tree ids objects get-modifier)] - (rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options)))) + (rx/of (dwm/apply-wasm-modifiers modif-tree + :ignore-touched (:ignore-touched options) + :ignore-snap-pixel true))) (let [page-id (or (:page-id options) (:current-page-id state)) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 04495ad0f2..435c8082c4 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -79,20 +79,26 @@ (defn resize-wasm-text "Resize a single text shape (auto-width/auto-height) by id. - No-op if the id is not a text shape or is :fixed." - [id] - (ptk/reify ::resize-wasm-text - ptk/WatchEvent - (watch [_ state _] - (let [objects (dsh/lookup-page-objects state) - shape (get objects id) - resize-stream - (if (and (some? shape) - (cfh/text-shape? shape) - (not= :fixed (:grow-type shape))) - (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape))) - (rx/empty))] - (wrf/with-pending :text-resize [id] resize-stream))))) + No-op if the id is not a text shape or is :fixed. + `opts` are forwarded to `apply-wasm-modifiers`, so a caller whose undo + transaction is already closed when the resize lands can still get the + geometry into the right undo entry." + ([id] + (resize-wasm-text id nil)) + ([id opts] + (ptk/reify ::resize-wasm-text + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shape (get objects id) + apply-opts (or opts {}) + resize-stream + (if (and (some? shape) + (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape) apply-opts)) + (rx/empty))] + (wrf/with-pending :text-resize [id] resize-stream)))))) (defn- merge-resize-debounce-opts [prev {:keys [undo-group undo-id skip-component-sync?]}] diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 5c185f83c4..4a45630454 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -266,16 +266,17 @@ [{:keys [objects frame vbox x y width height background]}] (let [shape-wrapper (shape-wrapper-factory objects)] [:& (mf/provider muc/render-thumbnails) {:value false} - [:svg {:view-box vbox - :width (ust/format-precision width viewbox-decimal-precision) - :height (ust/format-precision height viewbox-decimal-precision) - :version "1.1" - :xmlns "http://www.w3.org/2000/svg" - :xmlnsXlink "http://www.w3.org/1999/xlink" - :fill "none"} - (when (some? background) - [:rect {:x x :y y :width width :height height :fill background}]) - [:& shape-wrapper {:shape frame}]]])) + [:& (mf/provider muc/is-render?) {:value true} + [:svg {:view-box vbox + :width (ust/format-precision width viewbox-decimal-precision) + :height (ust/format-precision height viewbox-decimal-precision) + :version "1.1" + :xmlns "http://www.w3.org/2000/svg" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :fill "none"} + (when (some? background) + [:rect {:x x :y y :width width :height height :fill background}]) + [:& shape-wrapper {:shape frame}]]]])) ;; Component that serves for render frame thumbnails, mainly used in ;; the viewer and inspector diff --git a/frontend/src/app/main/ui/components/editable_select.cljs b/frontend/src/app/main/ui/components/editable_select.cljs index 470f95025c..3c9474a98f 100644 --- a/frontend/src/app/main/ui/components/editable_select.cljs +++ b/frontend/src/app/main/ui/components/editable_select.cljs @@ -13,7 +13,7 @@ [app.common.uuid :as uuid] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.numeric-input :as deprecated-input] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.timers :as timers] @@ -182,7 +182,10 @@ [:span {:class (stl/css :dropdown-button) :on-click toggle-dropdown} - deprecated-icon/arrow] + [:> icon* {:icon-id i/arrow-down + :size "m" + :aria-hidden true + :class (stl/css :dropdown-icon)}]] [:& dropdown {:show (or is-open? false) :on-close close-dropdown} @@ -196,9 +199,12 @@ [:li {:key (str element-id "-" index) :class (stl/css-case :dropdown-element true - :is-selected (= (dm/str value) current-value)) + :is-selected (= (dm/str value) (dm/str current-value))) :data-value value :on-click select-item} [:span {:class (stl/css :label)} label] [:span {:class (stl/css :check-icon)} - deprecated-icon/tick]])))]]])) + [:> icon* {:icon-id i/tick + :aria-hidden true + :size "s" + :class (stl/css :check-tick)}]]])))]]])) diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 490e549c0a..7257ee14f2 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -4,87 +4,126 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -// FIXME: we need this import for %asset-element -@use "refactor/basic-rules.scss" as deprecated; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @use "ds/spacing.scss" as *; +@use "ds/typography.scss" as *; .editable-select { - @extend %asset-element; + @include use-typography("body-small"); + + --editable-select-background-color: var(--color-background-tertiary); margin: 0; - border: $b-1 solid var(--input-border-color); + border: $b-1 solid var(--color-background-tertiary); position: relative; display: flex; - height: $sz-32; - width: 100%; + align-items: center; + inline-size: 100%; + block-size: $sz-32; padding: var(--sp-s); border-radius: $br-8; cursor: pointer; + background-color: var(--editable-select-background-color); + color: var(--color-foreground-primary); - .dropdown-button { - display: flex; - place-content: center; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); - } - } - - .custom-select-dropdown { - @extend %dropdown-wrapper; - - width: max-content; - max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token - .separator { - margin: 0; - height: $sz-12; - } - - .dropdown-element { - @extend %dropdown-element-base; - - color: var(--menu-foreground-color-rest); - - .label { - flex-grow: 1; - width: 100%; - } - - .check-icon { - display: flex; - place-content: center; - - svg { - @extend %button-icon-small; - - visibility: hidden; - stroke: var(--icon-foreground); - } - } - - &.is-selected { - color: var(--menu-foreground-color); - - .check-icon svg { - stroke: var(--menu-foreground-color); - visibility: visible; - } - } - - &:hover { - background-color: var(--menu-background-color-hover); - color: var(--menu-foreground-color-hover); - - .check-icon svg { - stroke: var(--menu-foreground-color-hover); - } - } - } + &:hover { + --editable-select-background-color: var(--color-background-quaternary); } } + +.dropdown-button { + display: flex; + place-content: center; +} + +.dropdown-icon { + color: var(--color-foreground-secondary); +} + +.custom-select-dropdown { + position: absolute; + inset-block-start: $sz-32; + inset-inline-start: 0; + inline-size: 100%; + min-inline-size: px2rem(70); + max-block-size: px2rem(320); // TODO: when this gets addressed in the DS, use a token + padding: var(--sp-xxs); + margin: 0; + margin-block-start: px2rem(1); + border: $b-2 solid var(--color-background-quaternary); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + overflow: hidden auto; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); +} + +.separator { + margin: 0; + block-size: $sz-12; +} + +.dropdown-element { + --dropdown-element-color: var(--color-foreground-secondary); + --dropdown-element-icon-color: var(--color-foreground-secondary); + + display: flex; + align-items: center; + gap: var(--sp-s); + block-size: $sz-32; + padding-block: 0; + padding-inline: var(--sp-s); + border-radius: $br-6; + cursor: pointer; + color: var(--dropdown-element-color); + + @include use-typography("body-small"); + + &.is-selected { + --dropdown-element-color: var(--color-foreground-primary); + --dropdown-element-icon-color: var(--color-foreground-primary); + + .check-tick { + visibility: visible; + } + } + + &:hover { + --dropdown-element-color: var(--color-foreground-primary); + --dropdown-element-icon-color: var(--color-foreground-primary); + + background-color: var(--color-background-quaternary); + } +} + +.label, +.check-icon { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.label { + flex-grow: 1; + inline-size: 100%; +} + +.check-icon { + display: flex; + place-content: center; +} + +.check-tick { + display: flex; + justify-content: center; + align-items: center; + fill: none; + stroke-width: 1.33px; + visibility: hidden; + color: var(--dropdown-element-icon-color); +} diff --git a/frontend/src/app/main/ui/shapes/fills.cljs b/frontend/src/app/main/ui/shapes/fills.cljs index 3b7c29da0b..41238e7608 100644 --- a/frontend/src/app/main/ui/shapes/fills.cljs +++ b/frontend/src/app/main/ui/shapes/fills.cljs @@ -119,31 +119,43 @@ (if (:fill-image value) (let [uri (cf/resolve-file-media (:fill-image value)) keep-ar? (-> value :fill-image :keep-aspect-ratio) + tf (-> value :fill-image :transform) + img-x (if (some? tf) (* (get tf :x 0) width) 0) + img-y (if (some? tf) (* (get tf :y 0) height) 0) + img-w (if (some? tf) (* (get tf :width 1) width) width) + img-h (if (some? tf) (* (get tf :height 1) height) height) image-props #js {:id (dm/str "fill-image-" render-id "-" fill-index) :href (get embed uri uri) :preserveAspectRatio (if keep-ar? "xMidYMid slice" "none") - :width width - :height height + :x img-x + :y img-y + :width img-w + :height img-h :key (dm/str fill-index) :opacity (:fill-opacity value)}] [:> :image image-props]) [:> :rect props]))) (when ^boolean has-image? - [:g - ;; We add this shape to add a padding so the patter won't repeat - ;; Issue: https://tree.taiga.io/project/penpot/issue/5583 - [:rect {:x 0 - :y 0 - :width (* width no-repeat-padding) - :height (* height no-repeat-padding) - :fill "none"}] - [:image {:href uri - :preserveAspectRatio "none" - :x 0 - :y 0 - :width width - :height height}]])]])]))) + (let [tf (-> image :transform) + img-x (if (some? tf) (* (get tf :x 0) width) 0) + img-y (if (some? tf) (* (get tf :y 0) height) 0) + img-w (if (some? tf) (* (get tf :width 1) width) width) + img-h (if (some? tf) (* (get tf :height 1) height) height)] + [:g + ;; We add this shape to add a padding so the patter won't repeat + ;; Issue: https://tree.taiga.io/project/penpot/issue/5583 + [:rect {:x 0 + :y 0 + :width (* width no-repeat-padding) + :height (* height no-repeat-padding) + :fill "none"}] + [:image {:href uri + :preserveAspectRatio "none" + :x img-x + :y img-y + :width img-w + :height img-h}]]))]])]))) (mf/defc fills {::mf/wrap-props false} diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index 91ab7f82f2..aac174d880 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -95,6 +95,16 @@ [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:li {:class (stl/css-case + :dropdown-element true + :selected (= :mentions cmode)) + :data-value "mentions" + :on-click update-mode} + [:span {:class (stl/css :label)} (tr "labels.show-mentions")] + (when (= :mentions cmode) + [:span {:class (stl/css :icon)} + deprecated-icon/tick])] + [:li {:class (stl/css :separator)}] [:li {:class (stl/css-case diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss index 7fcd840b9e..c254f0b9ba 100644 --- a/frontend/src/app/main/ui/viewer/comments.scss +++ b/frontend/src/app/main/ui/viewer/comments.scss @@ -4,6 +4,7 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; @use "refactor/common-refactor.scss" as deprecated; // COMMENT DROPDOWN ON HEADER @@ -92,7 +93,12 @@ } .separator { - height: deprecated.$s-8; + position: relative; + block-size: var(--sp-xs); + inline-size: calc(100% + var(--sp-s)); + border-top: $b-1 solid var(--color-background-quaternary); + left: calc(-1 * var(--sp-xs)); + margin-top: var(--sp-s); } // FLOATING COMMENT diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index 685ac5c0df..b94930789d 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; @use "refactor/common-refactor.scss" as deprecated; .comments-section { @@ -112,7 +113,12 @@ } .separator { - height: deprecated.$s-12; + position: relative; + block-size: var(--sp-xs); + inline-size: calc(100% + var(--sp-s)); + border-top: $b-1 solid var(--color-background-quaternary); + left: calc(-1 * var(--sp-xs)); + margin-top: var(--sp-s); } .comments-section-content { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs index 608a25c537..ea4857a40a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs @@ -12,16 +12,18 @@ [app.main.data.workspace.grid :as dw] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.editable-select :refer [editable-select]] - [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.select :refer [select]] [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.controls.numeric-input :refer [numeric-input*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]] [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]] + [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as kbd] [okulary.core :as l] [rumext.v2 :as mf])) @@ -33,6 +35,49 @@ :separator 18 12 10 8 6 4 3 2]) +(mf/defc default-options-toggle* + "Toggle button that opens the reset/save-as-default menu for a grid's + params. Shared by the square, column and row grid-type layouts, each of + which places it at a different point in their own layout." + [{:keys [show disabled on-toggle]}] + [:button {:class (stl/css-case :show-more-options true + :selected show) + :on-click on-toggle + :disabled disabled} + [:> icon* {:icon-id i/menu + :size "m" + :aria-hidden true + :class (stl/css :show-options-icon)}]]) + +(mf/defc default-options-dropdown* + "Dropdown menu with the reset/save-as-default options for a grid's params. + Shared by the square, column and row grid-type layouts, each of which + places it at a different point in their own layout (its `:class` controls + the panel's positioning, which differs per layout)." + [{:keys [class show on-close on-use-default on-set-as-default]}] + (let [handle-key-down + (fn [action] + (fn [event] + (when (or (kbd/enter? event) (kbd/space? event)) + (dom/prevent-default event) + (action))))] + [:& dropdown {:show show + :on-close on-close} + [:ul {:class class + :role "menu"} + [:li {:class (stl/css :option-btn) + :role "menuitem" + :tab-index 0 + :on-click on-use-default + :on-key-down (handle-key-down on-use-default)} + (tr "workspace.options.grid.params.use-default")] + [:li {:class (stl/css :option-btn) + :role "menuitem" + :tab-index 0 + :on-click on-set-as-default + :on-key-down (handle-key-down on-set-as-default)} + (tr "workspace.options.grid.params.set-default")]]])) + (mf/defc grid-options* {::mf/wrap [mf/memo]} [{:keys [shape-id index grid frame-width frame-height default-grid-params]}] @@ -151,7 +196,10 @@ [:button {:class (stl/css-case :show-options true :selected open?) :on-click toggle-advanced-options} - deprecated-icon/menu] + [:> icon* {:icon-id i/menu + :size "m" + :aria-hidden true + :class (stl/css :show-options-icon)}]] [:div {:class (stl/css :type-select-wrapper)} [:& select {:class (stl/css :grid-type-select) @@ -163,11 +211,10 @@ (if (= type :square) [:div {:class (stl/css :grid-size) :title (tr "workspace.options.size")} - [:> deprecated-input/numeric-input* {:min 0.01 - :value (or (:size params) "") - :no-validate true - :class (stl/css :numeric-input) - :on-change (handle-change :params :size)}]] + [:> numeric-input* {:min 0.01 + :value (or (:size params) "") + :inner-class (stl/css :numeric-input) + :on-change (handle-change :params :size)}]] [:div {:class (stl/css :editable-select-wrapper)} [:& editable-select {:value (:size params) @@ -204,22 +251,14 @@ :origin :guides :on-change handle-change-color :on-detach handle-detach-color}] - [:button {:class (stl/css-case :show-more-options true - :selected show-more-options?) - :on-click toggle-more-options} - deprecated-icon/menu]] - (when show-more-options? - [:div {:class (stl/css :second-row)} - [:button {:class (stl/css-case :btn-options true - :disabled is-default) - :disabled is-default - :on-click handle-use-default} - [:span (tr "workspace.options.grid.params.use-default")]] - [:button {:class (stl/css-case :btn-options true - :disabled is-default) - :disabled is-default - :on-click handle-set-as-default} - [:span (tr "workspace.options.grid.params.set-default")]]])]) + [:> default-options-toggle* {:show show-more-options? + :disabled is-default + :on-toggle toggle-more-options}]] + [:> default-options-dropdown* {:class (stl/css :second-row) + :show show-more-options? + :on-close close-more-options + :on-use-default handle-use-default + :on-set-as-default handle-set-as-default}]]) (when (or (= :column type) (= :row type)) [:div {:class (stl/css :column-row)} @@ -252,49 +291,39 @@ :title (if (= :row type) (tr "workspace.options.grid.params.height") (tr "workspace.options.grid.params.width"))} - [:span {:class (stl/css :icon-text)} - (if (= :row type) - "H" - "W")] - [:> deprecated-input/numeric-input* {:placeholder "Auto" - :on-change handle-change-item-length - :is-nillable true - :class (stl/css :numeric-input) - :value (or (:item-length params) "")}]] + [:> numeric-input* {:placeholder "Auto" + :on-change handle-change-item-length + :nillable true + :icon (if (= :row type) i/character-h i/character-w) + :inner-class (stl/css :numeric-input) + :value (or (:item-length params) "")}]] [:div {:class (stl/css :gutter) :title (tr "workspace.options.grid.params.gutter")} - [:span {:class (stl/css-case :icon true - :rotated (= type :row))} - deprecated-icon/gap-horizontal] - [:> deprecated-input/numeric-input* {:placeholder "0" - :on-change (handle-change :params :gutter) - :is-nillable true - :class (stl/css :numeric-input) - :value (or (:gutter params) 0)}]] + [:> numeric-input* {:placeholder "0" + :on-change (handle-change :params :gutter) + :nillable true + :icon (if (= type :row) i/gap-vertical i/gap-horizontal) + :inner-class (stl/css :numeric-input) + :value (or (:gutter params) 0)}]] [:div {:class (stl/css :margin) :title (tr "workspace.options.grid.params.margin")} - [:span {:class (stl/css-case :icon true - :rotated (= type :column))} - deprecated-icon/grid-margin] - [:> deprecated-input/numeric-input* {:placeholder "0" - :on-change (handle-change :params :margin) - :is-nillable true - :class (stl/css :numeric-input) - :value (or (:margin params) 0)}]] + [:> numeric-input* {:placeholder "0" + :on-change (handle-change :params :margin) + :nillable true + :icon (if (= type :column) i/margin-left-right i/margin-top-bottom) + :inner-class (stl/css :numeric-input) + :value (or (:margin params) 0)}]] - [:button {:class (stl/css-case :show-more-options true - :selected show-more-options?) - :on-click toggle-more-options - :disabled is-default} - deprecated-icon/menu] - (when show-more-options? - [:div {:class (stl/css :more-options)} - [:button {:class (stl/css :option-btn) - :on-click handle-use-default} (tr "workspace.options.grid.params.use-default")] - [:button {:class (stl/css :option-btn) - :on-click handle-set-as-default} (tr "workspace.options.grid.params.set-default")]])]])])])) + [:> default-options-toggle* {:show show-more-options? + :disabled is-default + :on-toggle toggle-more-options}] + [:> default-options-dropdown* {:class (stl/css :more-options) + :show show-more-options? + :on-close close-more-options + :on-use-default handle-use-default + :on-set-as-default handle-set-as-default}]]])])])) (defn- check-frame-grid-props [old-props new-props] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss index 8f97f4dc42..ba80378e2a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss @@ -4,9 +4,24 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; +// Shared by every disabled-looking control in the .hidden state below. +@mixin hidden-control { + cursor: default; + pointer-events: none; + box-sizing: border-box; + color: var(--color-foreground-secondary); + stroke: var(--color-foreground-secondary); + background-color: transparent; +} + .element-set { @include sidebar.option-grid-structure; } @@ -16,15 +31,17 @@ } .title-spacing-board-grid { - padding-left: deprecated.$s-2; + padding-inline-start: var(--sp-xxs); margin: 0; } .element-set-content { - @include deprecated.flex-column; - + display: flex; + flex-direction: column; + gap: var(--sp-xs); grid-column: span 8; - margin: deprecated.$s-4 0 deprecated.$s-8 0; + margin-block: var(--sp-xs) var(--sp-s); + margin-inline: 0; } .grid-title { @@ -35,266 +52,388 @@ grid-column: span 6; display: flex; align-items: center; - gap: deprecated.$s-1; - border-radius: deprecated.$br-8; - background-color: var(--input-details-color); + gap: px2rem(1); + border-radius: $br-8; + background-color: var(--color-background-primary); +} - .show-options { - @extend %button-secondary; +.show-options { + --show-options-background-color: var(--color-background-tertiary); + --show-options-border-color: var(--color-background-tertiary); + --show-options-color: var(--color-foreground-secondary); - height: deprecated.$s-32; - width: deprecated.$s-28; - border-radius: deprecated.$br-8 0 0 deprecated.$br-8; - box-sizing: border-box; - border: deprecated.$s-1 solid var(--input-border-color); + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-32; + inline-size: $sz-28; + border-radius: $br-8 0 0 $br-8; + box-sizing: border-box; + border: $b-1 solid var(--show-options-border-color); + background-color: var(--show-options-background-color); + color: var(--show-options-color); - svg { - @extend %button-icon; - } + &:focus-visible { + outline: none; - &.selected { - @extend %button-icon-selected; - } + --show-options-border-color: var(--color-accent-primary); } - .type-select-wrapper { - flex-grow: 1; - width: deprecated.$s-96; - padding: 0; - border-radius: 0; - height: deprecated.$s-32; + &:hover { + --show-options-background-color: var(--color-background-quaternary); + --show-options-border-color: var(--color-background-quaternary); + --show-options-color: var(--color-accent-primary); - .grid-type-select { - border-radius: 0; - height: 100%; - box-sizing: border-box; - border: deprecated.$s-1 solid var(--input-border-color); - - &:hover { - border: deprecated.$s-1 solid var(--input-border-color-hover); - } - } + text-decoration: none; } - .grid-size { - @extend %asset-element; + &:active { + outline: none; - width: deprecated.$s-60; - margin: 0; - padding: 0; - padding-left: deprecated.$s-8; - border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; - - .numeric-input { - @extend %input-base; - @include deprecated.body-small-typography; - } + --show-options-background-color: var(--color-background-secondary); + --show-options-border-color: var(--color-background-quaternary); + --show-options-color: var(--color-accent-primary); } - .editable-select-wrapper { - @extend %asset-element; + &[disabled], + &:disabled { + --show-options-background-color: var(--color-background-quaternary); + --show-options-border-color: var(--color-background-quaternary); + --show-options-color: var(--color-foreground-disabled); - width: deprecated.$s-60; - margin: 0; - padding: 0; - position: relative; - border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; - - .column-select { - height: deprecated.$s-32; - border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; - box-sizing: border-box; - border: deprecated.$s-1 solid var(--input-border-color); - - .numeric-input { - @extend %input-base; - @include deprecated.body-small-typography; - - margin: 0; - padding: 0; - } - - span { - @include deprecated.flex-center; - - svg { - @extend %button-icon; - } - } - } + cursor: unset; } - &.hidden { - .show-options { - @include deprecated.hidden-element; + &.selected { + outline: none; - border: deprecated.$s-1 solid var(--input-border-color-disabled); - } - - .type-select-wrapper, - .editable-select-wrapper { - @include deprecated.hidden-element; - - .column-select, - .grid-type-select { - @include deprecated.hidden-element; - - border: deprecated.$s-1 solid var(--input-border-color-disabled); - } - - .column-select { - @include deprecated.hidden-element; - - border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; - - .numeric-input { - @include deprecated.hidden-element; - } - } - } - - .grid-size { - @include deprecated.hidden-element; - - border: deprecated.$s-1 solid var(--input-border-color-disabled); - - .icon { - stroke: var(--input-foreground-color-disabled); - } - - .numeric-input { - color: var(--input-foreground-color-disabled); - } - } - - .actions { - .hidden-btn, - .lock-btn { - background-color: transparent; - - svg { - stroke: var(--input-foreground-color-disabled); - } - } - } + --show-options-background-color: var(--color-background-quaternary); + --show-options-border-color: var(--color-background-quaternary); + --show-options-color: var(--color-accent-primary); } } -.actions { - @include deprecated.flex-row; +.type-select-wrapper { + flex-grow: 1; + inline-size: $sz-96; + padding: 0; + border-radius: 0; + block-size: $sz-32; +} +.type-select-wrapper .grid-type-select { + --grid-type-select-border-color: var(--color-background-tertiary); + + border-radius: 0; + block-size: 100%; + box-sizing: border-box; + border: $b-1 solid var(--grid-type-select-border-color); + + &:hover { + --grid-type-select-border-color: var(--color-background-quaternary); + } +} + +.grid-size { + @include use-typography("body-small"); + + display: flex; + align-items: center; + block-size: $sz-32; + inline-size: px2rem(60); + margin: 0; + padding: 0; + padding-inline-start: var(--sp-s); + border-radius: 0 $br-8 $br-8 0; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); + + &:hover { + background-color: var(--color-background-quaternary); + color: var(--color-foreground-primary); + } +} + +.grid-size .numeric-input { + --input-height: #{$sz-28}; + + flex-grow: 1; +} + +.editable-select-wrapper { + inline-size: px2rem(60); + margin: 0; + padding: 0; + position: relative; +} + +.editable-select-wrapper .column-select { + block-size: $sz-32; + border-radius: 0 $br-8 $br-8 0; + box-sizing: border-box; + border: $b-1 solid var(--color-background-tertiary); +} + +.column-select .numeric-input { + @include use-typography("body-small"); + + margin: 0; + padding: 0; + border: none; + background: none; + outline: none; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + block-size: $sz-28; + inline-size: 100%; + flex-grow: 1; + border-radius: $br-8; + color: var(--color-foreground-primary); + + &[disabled] { + opacity: 0.5; + pointer-events: none; + } +} + +.hidden .show-options { + @include hidden-control; + + border: $b-1 solid var(--color-background-quaternary); +} + +.hidden .type-select-wrapper, +.hidden .editable-select-wrapper { + @include hidden-control; +} + +.hidden .column-select, +.hidden .grid-type-select { + @include hidden-control; + + border: $b-1 solid var(--color-background-quaternary); +} + +.hidden .column-select { + border-radius: 0 $br-8 $br-8 0; +} + +.hidden .column-select .numeric-input { + @include hidden-control; +} + +.hidden .grid-size { + @include hidden-control; + + border: $b-1 solid var(--color-background-quaternary); +} + +.hidden .grid-size .numeric-input { + --input-fg-color: var(--color-foreground-secondary); +} + +.actions { + display: flex; + align-items: center; + gap: var(--sp-xs); grid-column: span 2; } .grid-advanced-options { - @include deprecated.flex-column; - - margin-top: deprecated.$s-4; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-start: var(--sp-xs); } .column-row, .square-row { - @include deprecated.flex-column; - + display: flex; + flex-direction: column; + gap: var(--sp-xs); position: relative; } .advanced-row { position: relative; display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); +} - .orientation-select-wrapper { - width: deprecated.$s-92; - padding: 0; +.orientation-select-wrapper { + inline-size: px2rem(92); + padding: 0; +} + +.color-wrapper { + inline-size: px2rem(156); +} + +.show-more-options { + --show-more-options-background-color: transparent; + --show-more-options-border-color: transparent; + --show-more-options-color: var(--color-foreground-secondary); + + background: none; + cursor: pointer; + display: grid; + place-content: center; + block-size: $sz-32; + inline-size: $sz-32; + border-radius: $br-8; + background-color: var(--show-more-options-background-color); + border: $b-2 solid var(--show-more-options-border-color); + color: var(--show-more-options-color); + + &:focus-visible { + outline: none; + border: $b-1 solid var(--color-accent-primary); + + --show-more-options-background-color: var(--color-background-tertiary); + --show-more-options-color: var(--color-foreground-primary); } - .color-wrapper { - width: deprecated.$s-156; + &:hover { + --show-more-options-background-color: var(--color-background-quaternary); + --show-more-options-border-color: var(--color-background-quaternary); + --show-more-options-color: var(--color-accent-primary); } - .show-more-options { - @extend %button-tertiary; + &:active { + outline: none; - height: deprecated.$s-32; - width: deprecated.$s-32; - - svg { - @extend %button-icon; - } - - &.selected { - @extend %button-icon-selected; - } + --show-more-options-background-color: var(--color-background-secondary); + --show-more-options-border-color: transparent; + --show-more-options-color: var(--color-accent-primary); } - .height { - @extend %input-element; - @include deprecated.body-small-typography; + &[disabled], + &:disabled { + --show-more-options-color: var(--color-foreground-disabled); - .icon-text { - padding-top: deprecated.$s-1; - } + cursor: unset; + pointer-events: none; } - .gutter, - .margin { - @extend %input-element; - @include deprecated.body-small-typography; + &.selected { + outline: none; - .icon { - &.rotated svg { - transform: rotate(90deg); - } - } + --show-more-options-background-color: var(--color-background-quaternary); + --show-more-options-border-color: var(--color-background-quaternary); + --show-more-options-color: var(--color-accent-primary); + } +} + +.show-more-options svg { + stroke: var(--show-more-options-color); +} + +.height, +.gutter, +.margin { + --grid-param-input-color: var(--color-foreground-secondary); + --grid-param-input-background-color: var(--color-background-tertiary); + --grid-param-input-border-color: var(--color-background-tertiary); + + @include use-typography("body-small"); + + display: flex; + align-items: center; + block-size: $sz-32; + border-radius: $br-8; + background-color: var(--grid-param-input-background-color); + border: $b-1 solid var(--grid-param-input-border-color); + color: var(--grid-param-input-color); + + &:hover { + --grid-param-input-color: var(--color-foreground-primary); + --grid-param-input-background-color: var(--color-background-quaternary); + --grid-param-input-border-color: var(--color-background-quaternary); } - .more-options { - @include deprecated.menu-shadow; - @include deprecated.flex-column; + &:active, + &:focus, + &:focus-within { + --grid-param-input-color: var(--color-foreground-primary); + --grid-param-input-background-color: var(--color-background-primary); + --grid-param-input-border-color: var(--color-accent-primary); + } - position: absolute; - top: calc(deprecated.$s-2 + deprecated.$s-28); - right: 0; - width: deprecated.$s-156; - max-height: deprecated.$s-300; - padding: deprecated.$s-2; - margin: 0 0 deprecated.$s-40 0; - margin-top: deprecated.$s-4; - border-radius: deprecated.$br-8; - z-index: deprecated.$z-index-4; - overflow-y: auto; - background-color: var(--menu-background-color); + &:focus, + &:focus-within { + --grid-param-input-background-color: var(--color-background-tertiary); + } +} - .option-btn { - @include deprecated.button-style; +.height .numeric-input, +.gutter .numeric-input, +.margin .numeric-input { + --input-height: #{$sz-28}; - display: flex; - align-items: center; - height: deprecated.$s-32; - padding: 0 deprecated.$s-8; - border-radius: deprecated.$br-6; - color: var(--menu-foreground-color); + flex-grow: 1; + margin-block: var(--sp-xxs); + margin-inline: 0; + padding-inline-start: $sz-6; +} - &:hover { - background-color: var(--menu-background-color-hover); - color: var(--menu-foreground-color-hover); - } - } +.more-options { + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + display: flex; + flex-direction: column; + gap: var(--sp-xs); + position: absolute; + inset-block-start: calc(var(--sp-xxs) + $sz-28); + inset-inline-end: 0; + inline-size: px2rem(156); + max-block-size: px2rem(300); // TODO: when this gets addressed in the DS, use a token + padding: var(--sp-xxs); + margin-block: var(--sp-xs) $sz-40; + margin-inline: 0; + border-radius: $br-8; + z-index: var(--z-index-dropdown); + overflow-y: auto; + background-color: var(--color-background-tertiary); +} + +.option-btn { + @include use-typography("body-small"); + + cursor: pointer; + display: flex; + align-items: center; + block-size: $sz-32; + padding-inline: var(--sp-s); + border-radius: $br-6; + color: var(--color-foreground-primary); + + &:hover, + &:focus-visible { + background-color: var(--color-background-quaternary); + color: var(--color-foreground-primary); + } + + &:focus-visible { + outline: none; } } .second-row { - @extend %dropdown-wrapper; - - left: unset; - right: 0; - width: deprecated.$s-108; - - .btn-options { - @include deprecated.button-style; - @extend %dropdown-element-base; - - width: 100%; - } + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + position: absolute; + inset-block-start: $sz-32; + inset-inline-start: unset; + inset-inline-end: 0; + inline-size: px2rem(108); + max-block-size: var(--menu-max-height, px2rem(300)); + padding: var(--sp-xxs); + margin: 0; + margin-block-start: px2rem(1); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + overflow: hidden auto; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 6c4268cfce..06ece36af4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -130,6 +130,20 @@ (if-let [cached (get @optical-offset-cache key)] (p/resolved cached) (-> (fonts/ensure-loaded! font-id) + (p/then + (fn [_] + ;; ensure-loaded! only guarantees the @font-face CSS text has + ;; been injected, not that the browser has actually fetched and + ;; parsed the font file: that fetch is lazy, normally triggered + ;; by the browser laying out DOM text with the font. Canvas + ;; measureText doesn't reliably trigger it, so without this + ;; explicit wait it can silently measure the fallback font + ;; instead, and the resulting bogus offset then gets cached + ;; forever — pushing the sample glyphs outside the clipped + ;; sample box instead of just centering them slightly wrong. + (let [spec (dm/str (or weight "400") " " (or style "normal") " 16px \"" family "\"")] + (-> (.load js/document.fonts spec) + (p/catch (constantly nil)))))) (p/then (fn [_] (let [em (or (optical-offset-em family weight style text) 0)] @@ -139,36 +153,67 @@ (defn- use-optical-offset "Lazily resolve the optical-centering offset (in `em`) for sample text in a given font, measuring once per font/sample and caching it. Falls back to 0 - when the font isn't available or the metrics can't be measured." + when the font isn't available or the metrics can't be measured. + + Returns `[offset ready?]`. `ready?` is true immediately when a value is + already cached, and false only while the very first measurement of a given + font/sample is still pending. Callers should keep the sample hidden until + `ready?`: the offset (and so the sample's rendered position) jumps once + that first, async measurement resolves, and painting the glyphs before + then makes automated screenshot/position-based tests flaky — whether the + test runs before or after the jump is a timing race, not a deterministic + outcome." [font-id family weight style text] - (let [offset* (mf/use-state 0)] + (let [key (optical-offset-key family weight style text) + offset* (mf/use-state #(get @optical-offset-cache key 0)) + ready?* (mf/use-state #(contains? @optical-offset-cache key))] (mf/use-effect (mf/deps font-id family weight style text) (fn [] - (let [cancelled? (volatile! false) - key (optical-offset-key family weight style text)] + (let [cancelled? (volatile! false)] (if (contains? @optical-offset-cache key) - (reset! offset* (get @optical-offset-cache key)) + (do + (reset! offset* (get @optical-offset-cache key)) + (reset! ready?* true)) (let [task (tm/schedule-on-idle (fn [] (-> (load-optical-offset font-id family weight style text) (p/then (fn [em] (when-not @cancelled? - (reset! offset* em)))))))] + (reset! offset* em) + (reset! ready?* true)))))))] (fn [] (vreset! cancelled? true) (tm/dispose! task))))) nil)) - (deref offset*))) + [(deref offset*) (deref ready?*)])) (defn- sample-container-style "Inline style that applies the typography font to the (clipped, fixed-height) sample container. Must be a real JS object (`#js`), not a ClojureScript map: the `:style` value here is a runtime expression, not a literal recognized by - the hiccup macro, so it reaches React unconverted." - [typography] - #js {:fontFamily (:font-family typography) + the hiccup macro, so it reaches React unconverted. + + Falls back to `font-data` (the live fontsdb entry for the typography's + `:font-id`) when the typography's own `:font-family` is blank: a font that + was unloaded when a typography's font/variant was last changed can leave + that field nil on the record (the same failure mode `remove-nil-style-attrs` + repairs for shape text spans), and the sample would otherwise render in + whatever fallback font the browser picks instead of the intended one. + + The family name is quoted, matching `font-item-preview*` below: setting + `style.fontFamily` to a raw, unquoted string parses it as CSS's + `<family-name>` grammar, i.e. whitespace-separated `<custom-ident>`s. A + family like \"Micro 5\" then tokenizes as the ident `Micro` followed by + the *number* `5` — not a valid ident — so the whole property is invalid + CSS and the browser silently drops it. A quoted `<string>` sidesteps that + entirely, since it isn't tokenized as identifiers at all." + [typography font-data] + #js {:fontFamily (let [family (:font-family typography) + family (if (str/blank? family) (:family font-data) family)] + (when-not (str/blank? family) + (dm/str "\"" family "\""))) :fontWeight (:font-weight typography) :fontStyle (:font-style typography)}) @@ -176,10 +221,14 @@ "Inline style that optically centers the sample glyphs. Must be applied to the text node itself, not to the clipped container: a transform on an `overflow: hidden` element moves its own clip region along with it, so it - would shift the whole box relative to the row instead of the glyphs inside it." - [em] - (when-not (zero? em) - #js {:transform (dm/str "translateY(" em "em)")})) + would shift the whole box relative to the row instead of the glyphs inside it. + + Hidden until `ready?` (see `use-optical-offset`), so the glyphs only ever + appear already in their final, correctly centered position instead of + visibly jumping there after the first paint." + [em ready?] + #js {:transform (when-not (zero? em) (dm/str "translateY(" em "em)")) + :visibility (when-not ready? "hidden")}) ;; --- FONT SELECTOR -------------------------------------------------------- @@ -210,11 +259,17 @@ ;; the row, so shift it by the measured offset once the font is known. ;; The label renders at `body-medium` (400/normal), which is the weight ;; and style we measure against. - label-offset (use-optical-offset font-id - (:family font) - "400" - "normal" - (:name font))] + ;; The selector always shows the font's name text as-is, unlike the + ;; small "Ag" sample elsewhere in this file, so unlike there this + ;; doesn't need to hide anything until the offset is ready — a + ;; shifting label is a minor, acceptable visual nicety here, not + ;; the row's only content. + [label-offset _label-ready?] + (use-optical-offset font-id + (:family font) + "400" + "normal" + (:name font))] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) @@ -702,11 +757,12 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) show-actions? (and is-asset? is-editable) - offset (use-optical-offset (:font-id typography) - (:font-family typography) - (:font-weight typography) - (:font-style typography) - "Ag") + [offset offset-ready?] + (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-delete (mf/use-fn @@ -741,8 +797,8 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:input @@ -777,8 +833,8 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) @@ -826,11 +882,12 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) name-only? (= (:name typography) (:name font-data)) - offset (use-optical-offset (:font-id typography) - (:font-family typography) - (:font-weight typography) - (:font-style typography) - "Ag") + [offset offset-ready?] + (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-name-blur (mf/use-fn @@ -865,6 +922,13 @@ (when ^boolean esc? (dom/blur! input-node)))))] + ;; use-optical-offset only triggers a font load as a side effect of an + ;; uncached offset measurement, so on a cache hit (e.g. a `0` offset + ;; cached from before the font was ever loaded) the font itself never + ;; gets fetched and the sample silently renders in the fallback font. + ;; Load it unconditionally too, same as the advanced-options view does. + (fonts/ensure-loaded! (:font-id typography)) + (mf/with-effect [is-editing] (when is-editing (reset! open* is-editing))) @@ -888,8 +952,8 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:input @@ -907,8 +971,8 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) diff --git a/frontend/src/app/main/ui/workspace/viewport/path_state.cljs b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs index 0f1d7da23b..7f31441904 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_state.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.path-state (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 8b75af07f0..a4c378fa8d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -20,6 +20,7 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.refs :as refs] @@ -314,11 +315,14 @@ :bottom :auto-height nil)] (when (some? grow-type) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) - ;; The WASM renderer needs an explicit reflow after the grow-type change - (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwwt/resize-wasm-text-all [shape-id]) - (ptk/data-event :layout/update {:ids [shape-id]}))))))))] + (let [uid (js/Symbol)] + (st/emit! (dwu/start-undo-transaction uid) + (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) + ;; The WASM renderer needs an explicit reflow after the grow-type change + (if (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-all [shape-id] {:undo-id uid}) + (ptk/data-event :layout/update {:ids [shape-id]})) + (st/emit! (dwu/commit-undo-transaction uid)))))))))] [:g.resize-handler (when ^boolean show-handler diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 643e793787..0b265a0148 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -47,6 +47,7 @@ [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.reflow :as wrfp] + [app.plugins.register :as r] [app.plugins.shape :as shape] [app.plugins.system-events :as se] [app.plugins.user :as user] @@ -242,15 +243,18 @@ :getCurrentUser (fn [] - (user/current-user-proxy plugin-id (:session-id @st/state))) + (when (r/check-permission plugin-id "user:read") + (user/current-user-proxy plugin-id (:session-id @st/state)))) :getActiveUsers (fn [] - (apply array - (->> (:workspace-presence @st/state) - (vals) - (remove #(= (:id %) (:session-id @st/state))) - (map #(user/active-user-proxy plugin-id (:id %)))))) + (if (r/check-permission plugin-id "user:read") + (apply array + (->> (:workspace-presence @st/state) + (vals) + (remove #(= (:id %) (:session-id @st/state))) + (map #(user/active-user-proxy plugin-id (:id %))))) + (array))) :uploadMediaUrl (fn [name url] diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 1ee7ee6550..6c9a7cbcf5 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -40,12 +40,14 @@ ;; FIXME: inconsistent with comment-thread: owner :user - {:get #(->> (dc/get-owner data) - (user/user-proxy plugin-id))} + {:get #(when (r/check-permission plugin-id "user:read") + (->> (dc/get-owner data) + (user/user-proxy plugin-id)))} :owner - {:get #(->> (dc/get-owner data) - (user/user-proxy plugin-id))} + {:get #(when (r/check-permission plugin-id "user:read") + (->> (dc/get-owner data) + (user/user-proxy plugin-id)))} :date {:get @@ -116,8 +118,9 @@ :board {:get #(shape/shape-proxy plugin-id file-id page-id (:frame-id data))} :owner - {:get #(->> (dc/get-owner data) - (user/user-proxy plugin-id))} + {:get #(when (r/check-permission plugin-id "user:read") + (->> (dc/get-owner data) + (user/user-proxy plugin-id)))} :position {:get diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 74d2c141ca..59d222512b 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -61,8 +61,9 @@ :createdBy {:get (fn [] - (when-let [user-data (get users (:profile-id @data))] - (user/user-proxy plugin-id user-data)))} + (when (r/check-permission plugin-id "user:read") + (when-let [user-data (get users (:profile-id @data))] + (user/user-proxy plugin-id user-data))))} :createdAt {:get #(:created-at @data)} diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index da3d686705..229cd24f99 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -325,7 +325,12 @@ :remove (fn [] - (st/emit! (dwsl/remove-layout #{id}))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsl/remove-layout #{id})))) :appendChild (fn [child] @@ -350,6 +355,9 @@ (u/changes-component-copy-structure? objects shape child-shape) (u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwsh/relocate-shapes #{child-id} id index) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index c7895ef7fc..35ff505a65 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -698,21 +698,37 @@ :addVariant (fn [] - (st/emit! - (se/event plugin-id "add-new-variant") - (dwv/add-new-variant id))) + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission") + + :else + (st/emit! + (se/event plugin-id "add-new-variant") + (dwv/add-new-variant id)))) :addProperty (fn [] - (st/emit! - (se/event plugin-id "add-new-property") - (dwv/add-new-property id {:property-value "Value 1"}))) + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :addProperty "Plugin doesn't have 'library:write' permission") + + :else + (st/emit! + (se/event plugin-id "add-new-property") + (dwv/add-new-property id {:property-value "Value 1"})))) :removeProperty (fn [pos] (let [nprops (->> (get-variant-components file-id id) first :variant-properties count)] - (if (or (not (nat-int? pos)) (>= pos nprops)) + (cond + (or (not (nat-int? pos)) (>= pos nprops)) (u/not-valid plugin-id :pos pos) + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :removeProperty "Plugin doesn't have 'library:write' permission") + + :else (st/emit! (se/event plugin-id "remove-property") (dwv/remove-property id pos))))) @@ -727,6 +743,9 @@ (not (string? name)) (u/not-valid plugin-id :name name) + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :renameProperty "Plugin doesn't have 'library:write' permission") + :else (st/emit! (dwv/update-property-name id pos name {:trigger "plugin:rename-property"}))))))) @@ -923,8 +942,15 @@ :transformInVariant (fn [] (let [component (u/locate-library-component file-id id)] - (when (and component - (not (ctk/is-variant? component))) + (cond + (or (nil? component) + (ctk/is-variant? component)) + nil + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission") + + :else (st/emit! (se/event plugin-id "transform-in-variant") (dwv/transform-in-variant (:main-instance-id component)))))) @@ -932,8 +958,15 @@ :addVariant (fn [] (let [component (u/locate-library-component file-id id)] - (when (and component - (ctk/is-variant? component)) + (cond + (or (nil? component) + (not (ctk/is-variant? component))) + nil + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission") + + :else (st/emit! (se/event plugin-id "add-new-variant") (dwv/add-new-variant (:main-instance-id component)))))) @@ -948,6 +981,9 @@ (not (string? value)) (u/not-valid plugin-id :name value) + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission") + :else (st/emit! (se/event plugin-id "variant-edit-property-value") diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 28a674f111..2be488813f 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -64,6 +64,9 @@ (or (not (string? value)) (empty? value)) (u/not-valid plugin-id :name value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))} @@ -79,12 +82,20 @@ (not (shape/shape-proxy? value)) (u/not-valid plugin-id :startingBoard value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))} :remove (fn [] - (st/emit! (dwi/remove-flow page-id id))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwi/remove-flow page-id id)))))) (defn page-proxy? [proxy] (obj/type-of? proxy "PageProxy")) @@ -315,6 +326,9 @@ (not (shape/shape-proxy? frame)) (u/not-valid plugin-id :createFlow-frame frame) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission") + :else (let [flow-id (uuid/next)] (st/emit! @@ -328,6 +342,9 @@ (not (flow-proxy? flow)) (u/not-valid plugin-id :removeFlow-flow flow) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeFlow "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/remove-flow id (obj/get flow "$id")) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 44059d3244..b7a36bdc7d 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -103,6 +103,9 @@ (not (contains? ctsi/event-types value)) (u/not-valid plugin-id :trigger value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-interaction (u/locate-shape file-id page-id shape-id) @@ -119,6 +122,9 @@ (or (not (sm/valid-safe-int? value)) (neg? value)) (u/not-valid plugin-id :delay value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-interaction (u/locate-shape file-id page-id shape-id) @@ -139,6 +145,9 @@ (not (sm/validate ctsi/schema:interaction interaction)) (u/not-valid plugin-id :action interaction) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-interaction (u/locate-shape file-id page-id shape-id) @@ -148,7 +157,12 @@ :remove (fn [] - (st/emit! (dwi/remove-interaction {:id shape-id} index))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwi/remove-interaction {:id shape-id} index)))))) (def lib-typography-proxy? nil) (def lib-component-proxy nil) @@ -200,15 +214,15 @@ (not (sm/validate [:vector types.fills/schema:fill] value)) (u/not-valid plugin-id :fills value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") + (not (u/page-active? (obj/get self "$page"))) (u/not-valid plugin-id :fills "Cannot modify a page that is not currently active") (cfh/text-shape? shape) (st/emit! (dwt/update-attrs id {:fills value})) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") - :else (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value)))))) @@ -1475,6 +1489,9 @@ :detach (fn [] (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :detach "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) (u/not-valid plugin-id :detach "Cannot modify a page that is not currently active") @@ -1485,12 +1502,12 @@ (fn [component] (let [shape (u/locate-shape file-id page-id id)] (cond - (not (u/page-active? page-id)) - (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active") - (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active") + (not (obj/type-of? component "LibraryComponentProxy")) (u/not-valid plugin-id :swapComponent "Component not valid") @@ -1507,12 +1524,12 @@ (fn [] (let [shape (u/locate-shape file-id page-id id)] (cond - (not (u/page-active? page-id)) - (u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active") - (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :resetOverrides "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active") + (not (ctk/in-component-copy? shape)) (u/not-valid plugin-id :resetOverrides "The shape is not a component copy instance") @@ -1527,6 +1544,9 @@ (not (sm/validate ctse/schema:export value)) (u/not-valid plugin-id :export value) + (not (r/check-permission plugin-id "content:read")) + (u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission") + :else (if (and (contains? cf/flags :wasm-export) (contains? #{:jpeg :webp :png} (:type value :png))) @@ -1598,6 +1618,9 @@ (not (sm/validate ctsi/schema:interaction interaction)) (u/not-valid plugin-id :addInteraction interaction) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addInteraction "Plugin doesn't have 'content:write' permission") + :else (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] (st/emit! @@ -1611,6 +1634,9 @@ (not (interaction-proxy? interaction)) (u/not-valid plugin-id :removeInteraction interaction) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index")) @@ -1691,8 +1717,14 @@ :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] - (if (some #(not (token-attr? %)) kw-attrs) + (cond + (some #(not (token-attr? %)) kw-attrs) (u/not-valid plugin-id :applyToken attrs) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") + + :else (st/emit! (-> (dwta/toggle-token {:token token :attrs kw-attrs @@ -1720,6 +1752,9 @@ (not (string? value)) (u/not-valid plugin-id :value value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission") + :else (let [shape (u/locate-shape file-id page-id id) component (u/locate-library-component file-id (:component-id shape))] @@ -1733,6 +1768,9 @@ (or (not (seq ids)) (not (every? uuid/parse* ids))) (u/not-valid plugin-id :ids ids) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission") + :else (let [;; Keep the input order (head shape first): it determines ;; the order of the resulting variant components (see diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 8825775e6f..b27019c7ba 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -17,6 +17,7 @@ [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] + [app.plugins.register :as r] [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj] @@ -40,7 +41,9 @@ :m1 :margin-top :m2 :margin-right :m3 :margin-bottom - :m4 :margin-left}) + :m4 :margin-left + + :font-family :font-families}) (def ^:private map:token-attr-plugin->token-attr (merge @@ -85,16 +88,20 @@ (defn- apply-token-to-shapes [plugin-id file-id set-id id shape-ids attrs] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") - (let [token (u/locate-token file-id set-id id)] - (if (some #(not (token-attr? %)) attrs) - (u/not-valid plugin-id :applyToSelected attrs) - (st/emit! - (-> (dwta/toggle-token {:token token - :attrs (into #{} (map token-attr-plugin->token-attr) attrs) - :shape-ids shape-ids - :expand-with-children false}) - (se/add-event plugin-id)))))) + :else + (let [token (u/locate-token file-id set-id id)] + (if (some #(not (token-attr? %)) attrs) + (u/not-valid plugin-id :applyToSelected attrs) + (st/emit! + (-> (dwta/toggle-token {:token token + :attrs (into #{} (map token-attr-plugin->token-attr) attrs) + :shape-ids shape-ids + :expand-with-children false}) + (se/add-event plugin-id))))))) (defn- typography-resolved-value->js "Converts a resolved typography composite (a Clojure map keyed by the @@ -204,8 +211,13 @@ (ctob/get-tokens set-id))) :set (fn [_ value] - (st/emit! (-> (dwtl/update-token set-id id {:name value}) - (se/add-event plugin-id))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (-> (dwtl/update-token set-id id {:name value}) + (se/add-event plugin-id)))))} :type {:this true @@ -230,11 +242,16 @@ base)) :set (fn [_ value] - (let [token (u/locate-token file-id set-id id) - value (cond-> value - (= :font-family (:type token)) - (ctob/convert-dtcg-font-family))] - (st/emit! (dwtl/update-token set-id id {:value value}))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :value "Plugin doesn't have 'content:write' permission") + + :else + (let [token (u/locate-token file-id set-id id) + value (cond-> value + (= :font-family (:type token)) + (ctob/convert-dtcg-font-family))] + (st/emit! (dwtl/update-token set-id id {:value value})))))} :resolvedValue {:this true @@ -265,28 +282,43 @@ :schema cfo/schema:token-description :set (fn [_ value] - (st/emit! (-> (dwtl/update-token set-id id {:description value}) - (se/add-event :plugin-id))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :description "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (-> (dwtl/update-token set-id id {:description value}) + (se/add-event plugin-id)))))} :duplicate (fn [] - ;; TODO: - ;; - add function duplicate-token in tokens-lib, that allows to specify the new id - ;; - use this function in dwtl/duplicate-token - ;; - return the new token proxy using the locally forced id - ;; - do the same with sets and themes - (let [token (u/locate-token file-id set-id id) - token' (ctob/make-token (-> (datafy token) - (dissoc :id - :modified-at)))] - (st/emit! (-> (dwtl/create-token set-id token') - (se/add-event plugin-id))) - (token-proxy plugin-id file-id set-id (:id token')))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission") + + :else + ;; TODO: + ;; - add function duplicate-token in tokens-lib, that allows to specify the new id + ;; - use this function in dwtl/duplicate-token + ;; - return the new token proxy using the locally forced id + ;; - do the same with sets and themes + (let [token (u/locate-token file-id set-id id) + token' (ctob/make-token (-> (datafy token) + (dissoc :id + :modified-at)))] + (st/emit! (-> (dwtl/create-token set-id token') + (se/add-event plugin-id))) + (token-proxy plugin-id file-id set-id (:id token'))))) :remove (fn [] - (st/emit! (-> (dwtl/delete-token set-id id) - (se/add-event plugin-id)))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (-> (dwtl/delete-token set-id id) + (se/add-event plugin-id))))) :applyToShapes {:enumerable false @@ -337,8 +369,13 @@ id) :set (fn [_ name] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/rename-token-set set name))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + :else + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/rename-token-set set name)))))} :active {:this true @@ -351,13 +388,23 @@ :schema ::sm/boolean :set (fn [_ value] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + + :else + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value)))))} :toggleActive - (fn [_] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/toggle-token-set (ctob/get-name set))))) + (fn [] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + + :else + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/toggle-token-set (ctob/get-name set)))))) :tokens {:this true @@ -416,39 +463,54 @@ (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] - (let [tokens-lib (u/locate-tokens-lib file-id) - token (ctob/make-token attrs) - ;; Resolve against all tokens in the library (including those - ;; in inactive sets) so that references to structurally - ;; existing tokens resolve even if their set is not active. - ;; The target set's tokens take precedence over equally named - ;; tokens in other sets, and the new token takes precedence - ;; over all. - tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib) - (ctob/get-tokens tokens-lib id)) - (assoc (:name token) token)) - resolved-tokens (ts/resolve-tokens tokens-tree) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addToken "Plugin doesn't have 'content:write' permission") - {:keys [errors resolved-value] :as resolved-token} - (get resolved-tokens (:name token))] + :else + (let [tokens-lib (u/locate-tokens-lib file-id) + token (ctob/make-token attrs) + ;; Resolve against all tokens in the library (including those + ;; in inactive sets) so that references to structurally + ;; existing tokens resolve even if their set is not active. + ;; The target set's tokens take precedence over equally named + ;; tokens in other sets, and the new token takes precedence + ;; over all. + tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib) + (ctob/get-tokens tokens-lib id)) + (assoc (:name token) token)) + resolved-tokens (ts/resolve-tokens tokens-tree) - (if resolved-value - (do (st/emit! (-> (dwtl/create-token id token) - (se/add-event plugin-id))) - (token-proxy plugin-id file-id id (:id token))) - (do (u/not-valid plugin-id :addToken (str errors)) - nil))))} + {:keys [errors resolved-value] :as resolved-token} + (get resolved-tokens (:name token))] + + (if resolved-value + (do (st/emit! (-> (dwtl/create-token id token) + (se/add-event plugin-id))) + (token-proxy plugin-id file-id id (:id token))) + (do (u/not-valid plugin-id :addToken (str errors)) + nil)))))} :duplicate (fn [] - (let [id-ref (atom nil)] - (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref})) - (when (some? @id-ref) - (token-set-proxy plugin-id file-id @id-ref)))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission") + + :else + (let [id-ref (atom nil)] + (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref})) + (when (some? @id-ref) + (token-set-proxy plugin-id file-id @id-ref))))) :remove (fn [] - (st/emit! (dwtl/delete-token-set id)))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/delete-token-set id))))))) (defn token-theme-proxy? [p] (obj/type-of? p "TokenThemeProxy")) @@ -501,8 +563,13 @@ (:id theme))) :set (fn [_ group] - (let [theme (u/locate-token-theme file-id id)] - (st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :group "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (u/locate-token-theme file-id id)] + (st/emit! (dwtl/update-token-theme id (assoc theme :group group))))))} :name {:this true @@ -517,9 +584,14 @@ (:group theme))) :set (fn [_ name] - (let [theme (u/locate-token-theme file-id id)] - (when name - (st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (u/locate-token-theme file-id id)] + (when name + (st/emit! (dwtl/update-token-theme id (assoc theme :name name)))))))} :active {:this true @@ -531,11 +603,21 @@ :schema ::sm/boolean :set (fn [_ value] - (st/emit! (dwtl/set-token-theme-active id value)))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/set-token-theme-active id value))))} :toggleActive - (fn [_] - (st/emit! (dwtl/toggle-token-theme-active id))) + (fn [] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/toggle-token-theme-active id)))) :activeSets {:this true @@ -554,32 +636,52 @@ {:enumerable false :schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]] :fn (fn [set-arg] - (let [set-name (token-set-name (resolve-token-set file-id set-arg)) - theme (u/locate-token-theme file-id id)] - (when (and set-name theme) - (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission") + + :else + (let [set-name (token-set-name (resolve-token-set file-id set-arg)) + theme (u/locate-token-theme file-id id)] + (when (and set-name theme) + (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name)))))))} :removeSet {:enumerable false :schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]] :fn (fn [set-arg] - (let [set-name (token-set-name (resolve-token-set file-id set-arg)) - theme (u/locate-token-theme file-id id)] - (when (and set-name theme) - (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeSet "Plugin doesn't have 'content:write' permission") + + :else + (let [set-name (token-set-name (resolve-token-set file-id set-arg)) + theme (u/locate-token-theme file-id id)] + (when (and set-name theme) + (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name)))))))} :duplicate (fn [] - (let [theme (u/locate-token-theme file-id id) - theme' (ctob/make-token-theme (-> (datafy theme) - (dissoc :id - :modified-at)))] - (st/emit! (dwtl/create-token-theme theme')) - (token-theme-proxy plugin-id file-id (:id theme')))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (u/locate-token-theme file-id id) + theme' (ctob/make-token-theme (-> (datafy theme) + (dissoc :id + :modified-at)))] + (st/emit! (dwtl/create-token-theme theme')) + (token-theme-proxy plugin-id file-id (:id theme'))))) :remove (fn [] - (st/emit! (dwtl/delete-token-theme id))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/delete-token-theme id)))))) (defn tokens-catalog [plugin-id file-id] @@ -619,9 +721,14 @@ nil) (sm/dissoc-key :id))]) ;; We don't allow plugins to set the id :fn (fn [attrs] - (let [theme (ctob/make-token-theme attrs)] - (st/emit! (dwtl/create-token-theme theme)) - (token-theme-proxy plugin-id file-id (:id theme))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addTheme "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (ctob/make-token-theme attrs)] + (st/emit! (dwtl/create-token-theme theme)) + (token-theme-proxy plugin-id file-id (:id theme)))))} :addSet {:enumerable false @@ -638,21 +745,26 @@ (sm/merge [:map [:active {:optional true} ::sm/boolean]]))] :fn (fn [attrs] - (let [active? (boolean (:active attrs)) - attrs (-> attrs - (dissoc :active) - (update :name ctob/normalize-set-name)) - set (ctob/make-token-set attrs)] - (st/emit! (dwtl/create-token-set set)) - ;; Newly created sets are inactive by default; activate it when - ;; requested. Enabling only adds the set name to the hidden theme, - ;; so it does not depend on the create event having propagated yet. - (when active? - (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true))) - ;; Pass the set name as `initial-name` so the proxy can resolve - ;; it immediately, before the async `st/emit!` above propagates - ;; the new set into `@st/state`. - (token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission") + + :else + (let [active? (boolean (:active attrs)) + attrs (-> attrs + (dissoc :active) + (update :name ctob/normalize-set-name)) + set (ctob/make-token-set attrs)] + (st/emit! (dwtl/create-token-set set)) + ;; Newly created sets are inactive by default; activate it when + ;; requested. Enabling only adds the set name to the hidden theme, + ;; so it does not depend on the create event having propagated yet. + (when active? + (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true))) + ;; Pass the set name as `initial-name` so the proxy can resolve + ;; it immediately, before the async `st/emit!` above propagates + ;; the new set into `@st/state`. + (token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set)))))} :getThemeById {:enumerable false diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a9c1718580..64d19c56d8 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -880,6 +880,25 @@ (h/call wasm/internal-module "_store_image") true))))) +(defn- store-image-url! + "Registers the public URL an image was loaded from so SVG export can emit a + linked `<image href>` instead of a Skia base64 embed." + [image-id url] + (when (and (wasm/live?) (some? url) (not (str/blank? url))) + (let [buffer (uuid/get-u32 image-id) + encoder (js/TextEncoder.) + encoded (.encode encoder url) + size (.-byteLength encoded) + offset (mem/alloc size) + heap (mem/get-heap-u8)] + (.set heap encoded offset) + (h/call wasm/internal-module "_store_image_url" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3)) + true))) + (defn- store-image-texture "Creates a WebGL texture from a decoded image and passes the texture ID to WASM. This avoids decoding the image twice (once in browser, once in WASM)." @@ -922,6 +941,7 @@ so Skia rasterizes them." [shape-id image-id thumbnail?] (let [url (cf/resolve-file-media {:id image-id} thumbnail?)] + (store-image-url! image-id url) {:key url :thumbnail? thumbnail? :callback @@ -959,6 +979,8 @@ (aget buffer 2) (aget buffer 3) thumbnail?)] + ;; Always register the URL (SVG export needs it even when bytes are cached). + (store-image-url! id (cf/resolve-file-media {:id id} thumbnail?)) (when (zero? cached-image?) (fetch-image shape-id id thumbnail?))))) @@ -993,6 +1015,7 @@ (aget buffer 2) (aget buffer 3) thumbnail?)] + (store-image-url! id (cf/resolve-file-media {:id id} thumbnail?)) (when (zero? cached-image?) (fetch-image shape-id id thumbnail?)))) (types.fills/get-image-ids fills)))))) @@ -1021,6 +1044,7 @@ (aget buffer 2) (aget buffer 3) thumbnail?)] + (store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?)) (when (zero? cached-image?) (fetch-image shape-id image-id thumbnail?)))) image-ids)))) @@ -2098,13 +2122,34 @@ (h/call wasm/internal-module "_set_structure_modifiers")))) +;; Axes the pixel grid rounds, as `propagate_modifiers` expects them. +(def ^:private pixel-precision + {:disabled 0 + :both 1 + :only-x 2 + :only-y 3}) + +(defn- pixel-precision-mode + "Encodes the pixel grid snapping for the renderer. `snap-ignore-axis` + names the axis to leave alone (`:x`, `:y` or nil)." + [snap-pixel? snap-ignore-axis] + (pixel-precision + (cond + (not snap-pixel?) :disabled + (= :x snap-ignore-axis) :only-y + (= :y snap-ignore-axis) :only-x + :else :both))) + (defn propagate-modifiers "Propagates geometry modifiers through the WASM shape tree. + Rounds the resulting geometry to the pixel grid when `snap-pixel?` is set, + skipping the axis named by `snap-ignore-axis` (`:x`, `:y` or nil). + Always returns a vector. When the context is not ready (lost / mid-reload) or `entries` is empty, returns `[]` so callers never receive `nil` (which would trip `set-modifiers`' vector assert)." - [entries pixel-precision] + [entries snap-pixel? snap-ignore-axis] (if-not (and (initialized?) (not ^boolean (empty? entries))) [] (let [heapf32 (mem/get-heap-f32) @@ -2122,7 +2167,8 @@ offset entries) - (let [offset (-> (h/call wasm/internal-module "_propagate_modifiers" pixel-precision) + (let [precision (pixel-precision-mode snap-pixel? snap-ignore-axis) + offset (-> (h/call wasm/internal-module "_propagate_modifiers" precision) (mem/->offset-32)) length (aget heapu32 offset) max-offset (+ offset 1 (* length MODIFIER-U32-SIZE)) diff --git a/frontend/test/frontend_tests/basic_shapes_test.cljs b/frontend/test/frontend_tests/basic_shapes_test.cljs index 1082e2435e..de9bca248b 100644 --- a/frontend/test/frontend_tests/basic_shapes_test.cljs +++ b/frontend/test/frontend_tests/basic_shapes_test.cljs @@ -76,3 +76,48 @@ (t/is (= (:stroke-alignment stroke') :inner)) (t/is (= (:stroke-color stroke') "#FABADA")) (t/is (= (:stroke-width stroke') 2)))))))) +(t/deftest test-update-stroke-color-preserves-dash-gap + ;; Custom dash/gap on a dashed stroke describe stroke geometry, not color; + ;; a stroke color change must preserve them (issue #11549). + (t/async + done + (let [store (ths/setup-store + (-> (cthf/sample-file :file1 :page-label :page1) + (cths/add-sample-shape :shape1 :strokes + [{:stroke-color "#000000" + :stroke-opacity 1 + :stroke-width 2 + :stroke-style :dashed + :stroke-dash 4 + :stroke-gap 20}]) + (cths/add-sample-shape :shape2 :strokes + [{:stroke-color "#000000" + :stroke-opacity 1 + :stroke-width 2 + :stroke-style :dashed}]))) + events [(dc/change-stroke-color #{(cthi/id :shape1)} {:color "#FABADA"} 0) + (dc/change-stroke-color #{(cthi/id :shape2)} {:color "#FABADA"} 0)]] + (ths/run-store + store done events + (fn [new-state] + (let [objects (dsh/lookup-page-objects new-state) + shape1' (get objects (cthi/id :shape1)) + stroke1' (first (:strokes shape1')) + shape2' (get objects (cthi/id :shape2)) + stroke2' (first (:strokes shape2'))] + + ;; dashed stroke with custom dash/gap keeps them after color change + (t/is (some? shape1')) + (t/is (= (:stroke-color stroke1') "#FABADA")) + (t/is (= (:stroke-style stroke1') :dashed)) + (t/is (= (:stroke-width stroke1') 2)) + (t/is (= (:stroke-dash stroke1') 4)) + (t/is (= (:stroke-gap stroke1') 20)) + + ;; dashed stroke without explicit dash/gap stays unset: + ;; no implicit default is materialized into stored data + (t/is (some? shape2')) + (t/is (= (:stroke-color stroke2') "#FABADA")) + (t/is (= (:stroke-style stroke2') :dashed)) + (t/is (nil? (:stroke-dash stroke2'))) + (t/is (nil? (:stroke-gap stroke2'))))))))) \ No newline at end of file diff --git a/frontend/test/frontend_tests/data/comments_filters_test.cljs b/frontend/test/frontend_tests/data/comments_filters_test.cljs new file mode 100644 index 0000000000..cc0b5181c4 --- /dev/null +++ b/frontend/test/frontend_tests/data/comments_filters_test.cljs @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.comments-filters-test + (:require + [app.main.data.comments :as dcmt] + [app.util.storage :as storage] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(def ^:private storage-key + :app.main.data.comments/hide-resolved-comments?) + +(t/deftest test-merge-persisted-filters-default + (let [prev (get @storage/user storage-key)] + (try + (swap! storage/user dissoc storage-key) + (t/is (= {:show :all} (dcmt/merge-persisted-filters nil))) + (t/is (= {:show :all} (dcmt/merge-persisted-filters {}))) + (finally + (if (some? prev) + (swap! storage/user assoc storage-key prev) + (swap! storage/user dissoc storage-key)))))) + +(t/deftest test-merge-persisted-filters-hide-resolved + (let [prev (get @storage/user storage-key)] + (try + (swap! storage/user assoc storage-key true) + (t/is (= {:show :pending} (dcmt/merge-persisted-filters nil))) + (finally + (if (some? prev) + (swap! storage/user assoc storage-key prev) + (swap! storage/user dissoc storage-key)))))) + +(t/deftest test-merge-persisted-filters-keeps-session-value + (let [prev (get @storage/user storage-key)] + (try + (swap! storage/user assoc storage-key true) + (t/is (= {:show :all :mode :yours} + (dcmt/merge-persisted-filters {:show :all :mode :yours}))) + (finally + (if (some? prev) + (swap! storage/user assoc storage-key prev) + (swap! storage/user dissoc storage-key)))))) + +(t/deftest test-update-filters-updates-show + (let [event (dcmt/update-filters {:show :pending}) + state (ptk/update event {})] + (t/is (= :pending (get-in state [:comments-local :show]))) + + (let [event (dcmt/update-filters {:show :all}) + state (ptk/update event state)] + (t/is (= :all (get-in state [:comments-local :show])))))) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index a235d83915..82d4f8ab99 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -58,7 +58,7 @@ This effectively tells the caller \"apply exactly the transform that was requested\", which is what the real WASM engine does for simple moves / resizes without constraints." - [entries _pixel-precision] + [entries _snap-pixel? _snap-ignore-axis] (track! :propagate-modifiers) (when (d/not-empty? entries) (into [] diff --git a/frontend/test/frontend_tests/logic/path_actions_test.cljs b/frontend/test/frontend_tests/logic/path_actions_test.cljs index 09451937ee..b191f7e8a4 100644 --- a/frontend/test/frontend_tests/logic/path_actions_test.cljs +++ b/frontend/test/frontend_tests/logic/path_actions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.path-actions-test (:require diff --git a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs index 5e79a7447a..8ecf867c4f 100644 --- a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs +++ b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.path-clipboard-test diff --git a/frontend/test/frontend_tests/logic/path_helpers_test.cljs b/frontend/test/frontend_tests/logic/path_helpers_test.cljs index ba0c28038f..ed86104201 100644 --- a/frontend/test/frontend_tests/logic/path_helpers_test.cljs +++ b/frontend/test/frontend_tests/logic/path_helpers_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.path-helpers-test diff --git a/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs index 66b20090c8..c73eddd205 100644 --- a/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs +++ b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.path-lifecycle-test diff --git a/frontend/test/frontend_tests/logic/path_test_helpers.cljs b/frontend/test/frontend_tests/logic/path_test_helpers.cljs index d55fcd17a4..d34060ecca 100644 --- a/frontend/test/frontend_tests/logic/path_test_helpers.cljs +++ b/frontend/test/frontend_tests/logic/path_test_helpers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.path-test-helpers diff --git a/frontend/test/frontend_tests/logic/path_tools_test.cljs b/frontend/test/frontend_tests/logic/path_tools_test.cljs index fd286ff728..f8a806f5af 100644 --- a/frontend/test/frontend_tests/logic/path_tools_test.cljs +++ b/frontend/test/frontend_tests/logic/path_tools_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.path-tools-test diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs index 7e39bf91ee..ed26f86d20 100644 --- a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs +++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.wasm-modifiers-nil-id-test "Reproduces the production crash \"Cannot read properties of null @@ -46,7 +46,7 @@ the real implementations." [] (set! wasm.api/propagate-modifiers - (fn [entries _pixel-precision] + (fn [entries _snap-pixel? _snap-ignore-axis] (swap! captured-geometry-entries into entries) (into [] (map (fn [[id data]] [id (:transform data)])) diff --git a/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs b/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs new file mode 100644 index 0000000000..37982a1f96 --- /dev/null +++ b/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs @@ -0,0 +1,104 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.logic.wasm-pixel-snap-test + "Covers which pixel-grid snapping options reach the WASM renderer. + + The rounding happens in Rust, so these tests assert on the arguments + crossing the bridge: which axis an axis-locked drag leaves alone, and + that rotation does not snap." + (:require + [app.common.geom.point :as gpt] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] + [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.transforms :as dwt] + [app.render-wasm.api :as wasm.api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private captured-snap-options + "One entry per `wasm.api/propagate-modifiers` call during a test." + (atom [])) + +(defn- install-capturing-spy! + "Records the snap options of every propagation. Must run after + `thw/setup-wasm-mocks!` so teardown restores the real implementation." + [] + (set! wasm.api/propagate-modifiers + (fn [entries snap-pixel? snap-ignore-axis] + (swap! captured-snap-options conj + {:snap-pixel? snap-pixel? :snap-ignore-axis snap-ignore-axis}) + (into [] + (map (fn [[id data]] [id (:transform data)])) + entries)))) + +(defn- enable-pixel-grid + [] + (fn [state] + (update state :workspace-layout conj :snap-pixel-grid))) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (reset! captured-snap-options []) + (thw/setup-wasm-mocks!) + (install-capturing-spy!)) + :after (fn [] + (thw/teardown-wasm-mocks!))}) + +(t/deftest axis-locked-move-tells-the-renderer-which-axis-to-ignore + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + modif-tree (dwm/create-modif-tree [(:id rect)] + (ctm/move-modifiers (gpt/point 5.2 0))) + events [(enable-pixel-grid) + (dwm/apply-wasm-modifiers modif-tree :snap-ignore-axis :y)]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (= [{:snap-pixel? true :snap-ignore-axis :y}] + @captured-snap-options))))))) + +(t/deftest move-without-axis-lock-snaps-both-axes + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + modif-tree (dwm/create-modif-tree [(:id rect)] + (ctm/move-modifiers (gpt/point 5.2 3.7))) + events [(enable-pixel-grid) + (dwm/apply-wasm-modifiers modif-tree)]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (= [{:snap-pixel? true :snap-ignore-axis nil}] + @captured-snap-options))))))) + +(t/deftest rotation-does-not-snap-to-the-pixel-grid + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + events [(enable-pixel-grid) + (dwt/increase-rotation #{(:id rect)} 15)]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (= [{:snap-pixel? false :snap-ignore-axis nil}] + @captured-snap-options))))))) diff --git a/frontend/test/frontend_tests/plugins/flex_test.cljs b/frontend/test/frontend_tests/plugins/flex_test.cljs new file mode 100644 index 0000000000..9cf5e68442 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/flex_test.cljs @@ -0,0 +1,40 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.plugins.flex-test + (:require + [app.common.types.shape.layout :as ctl] + [app.common.uuid :as uuid] + [app.main.store :as st] + [app.plugins.flex :as flex] + [app.plugins.register :as r] + [app.plugins.shape :as shape] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-05) +;; --------------------------------------------------------------------------- + +(t/deftest flex-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (flex/flex-layout-proxy plugin-id file-id page-id id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +;; TODO: flex-append-child-checks-permission test requires more complex mocking +;; of u/locate-objects, u/locate-shape, ctl/reverse?, etc. The permission check +;; is in place at flex.cljs line 358. diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index e359fd852e..d951cddb05 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -6,8 +6,11 @@ (ns frontend-tests.plugins.library-test (:require + [app.common.types.component :as ctk] + [app.common.uuid :as uuid] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.variants :as dwv] [app.main.store :as st] [app.plugins.library :as library] [app.plugins.register :as r] @@ -93,3 +96,112 @@ (t/is (contains? (:color @captured) :image)) (t/is (not (contains? (:color @captured) :color))) (t/is (not (contains? (:color @captured) :gradient)))))) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-02) +;; --------------------------------------------------------------------------- + +(t/deftest variant-add-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.addVariant proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest variant-add-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.addProperty proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest variant-remove-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}]) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.removeProperty proxy 0) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest variant-rename-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}]) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.renameProperty proxy 0 "newName") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :renameProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest component-transform-in-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :main-instance-id id}) + ctk/is-variant? (constantly false) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.transformInVariant proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest component-add-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :main-instance-id id}) + ctk/is-variant? (constantly true) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.addVariant proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest component-set-variant-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]}) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.setVariantProperty proxy 0 "red") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs index d48318b77b..c7fe33e643 100644 --- a/frontend/test/frontend_tests/plugins/page_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_test.cljs @@ -9,12 +9,17 @@ [app.common.test-helpers.files :as cthf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as cths] + [app.common.uuid :as uuid] [app.main.data.workspace.pages :as dwpg] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.page :as page] + [app.plugins.register :as r] [app.plugins.shape :as shape] + [app.plugins.utils :as u] [app.util.object :as obj] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw] [potok.v2.core :as ptk])) @@ -151,3 +156,85 @@ (done)))) (mock-page-initialized store page2-id)) 0)))) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-03) +;; --------------------------------------------------------------------------- + +(t/deftest flow-name-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)] + (set! (.-name proxy) "new-name") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest flow-starting-board-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop + shape/shape-proxy? (constantly true)] + (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)] + (set! (.-startingBoard proxy) #js {}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :startingBoard "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest flow-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest create-flow-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop + shape/shape-proxy? (constantly true)] + (let [proxy (page/page-proxy plugin-id file-id page-id) + frame #js {"$id" (uuid/next)}] + (.createFlow proxy "flow-name" frame) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :createFlow "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest remove-flow-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop + page/flow-proxy? (constantly true)] + (let [proxy (page/page-proxy plugin-id file-id page-id)] + (.removeFlow proxy (page/flow-proxy plugin-id file-id page-id flow-id)) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeFlow "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs index 4ce610848c..b85f471c13 100644 --- a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -11,10 +11,15 @@ [app.common.types.component :as ctk] [app.common.uuid :as uuid] [app.main.data.workspace :as dw] + [app.main.data.workspace.interactions :as dwi] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.variants :as dwv] [app.main.store :as st] [app.plugins.api :as api] [app.plugins.public-utils :as public-utils] + [app.plugins.register :as r] [app.plugins.shape :as shape] [app.plugins.utils :as u] [cljs.test :as t :include-macros true] @@ -204,3 +209,202 @@ (t/deftest group-empty-input-returns-nil (let [context (api/create-context plugin-id)] (t/is (nil? (.group context #js []))))) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-04) +;; --------------------------------------------------------------------------- + +(t/deftest commit-fills-text-shape-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/proxy->shape (constantly {:id shape-id :type :text}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (set! (.-fills proxy) #js [#js {:fillColor "#ff0000" :fillOpacity 1}]) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :fills "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-trigger-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (set! (.-trigger inter) "click") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :trigger "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-delay-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (set! (.-delay inter) 100) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :delay "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-action-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/proxy->interaction (constantly {:event-type :click :delay 0 :action-type :open-url :url "https://example.com"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (set! (.-action inter) #js {:type "open-url" :url "https://example.com"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :action "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (.remove inter) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest add-interaction-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-shape (constantly {:id shape-id}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.addInteraction proxy "click" #js {:type "open-url" :url "https://example.com"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addInteraction "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest remove-interaction-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id) + inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (.removeInteraction proxy inter) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest detach-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/page-active? (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.detach proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :detach "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest export-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false)] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.export proxy #js {:type "png" :scale 1}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :export "Plugin doesn't have 'content:read' permission"] + (first @errors))))))) + +(t/deftest apply-token-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color}) + shape/token-proxy? (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id) + token #js {"$set-id" (str set-id) "$id" (str token-id)}] + (.applyToken proxy token #js []) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :applyToken "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest switch-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-shape (constantly {:id shape-id :component-id shape-id}) + u/locate-library-component (constantly {:id (uuid/next)}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.switchVariant proxy 0 "value") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :switchVariant "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest combine-as-variants-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + other-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id}) + u/locate-library-component (constantly {:id (uuid/next)}) + ctk/is-variant? (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.combineAsVariants proxy #js [(str other-id)]) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index c501b9fb66..3f4f56f6d9 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -16,14 +16,20 @@ [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.register :as r] [app.plugins.tokens :as ptok] [app.plugins.utils :as u] [cljs.test :as t :include-macros true] [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] [potok.v2.core :as ptk])) -(t/use-fixtures :each {:before cthi/reset-idmap!}) +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) (def ^:private get-resolved-value @#'ptok/get-resolved-value) @@ -80,6 +86,18 @@ (t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom))) (t/is (= :m4 (ptok/token-attr-plugin->token-attr :margin-left)))) +(t/deftest token-attr-plugin->token-attr-resolves-font-family-alias + ;; Plugin-facing `fontFamilies` (kebab-cased to `:font-families` by the + ;; schema layer) maps to the canonical internal `:font-family`. + (t/is (= :font-family (ptok/token-attr-plugin->token-attr :font-families))) + (t/is (= :font-family (ptok/token-attr-plugin->token-attr "font-families")))) + +(t/deftest token-attr->token-attr-plugin-resolves-font-family-alias + ;; Symmetric direction: the canonical internal attribute maps to the + ;; plural plugin-facing name so applied-token readback serializes as + ;; camelCase `fontFamilies`, not the undocumented singular `fontFamily`. + (t/is (= :font-families (ptok/token-attr->token-attr-plugin :font-family)))) + (t/deftest token-attr-plugin->token-attr-coerces-string-input ;; This is the actual regression — JS plugin calls supply strings. (t/is (= :fill (ptok/token-attr-plugin->token-attr "fill"))) @@ -149,6 +167,57 @@ (done))) 0)))) +(t/deftest shape-apply-token-accepts-font-families + (t/async + done + (let [set-id (cthi/new-id! :token-set) + token-id (cthi/new-id! :font-family-token) + file (-> (cthf/sample-file :file1 :page-label :page1) + (ctho/add-text :text1 "Hello World!") + (ctht/add-tokens-lib) + (ctht/update-tokens-lib + #(-> % + (ctob/add-set + (ctob/make-token-set :id set-id + :name "fonts")) + (ctob/add-theme + (ctob/make-token-theme :name "theme" + :sets #{"fonts"})) + (ctob/set-active-themes #{"/theme"}) + (ctob/add-token + set-id + (ctob/make-token :id token-id + :name "font.primary" + :type :font-family + :value ["Inter"]))))) + store (ths/setup-store file) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js context (api/create-context "00000000-0000-0000-0000-000000000000") + ^js page (.-currentPage context) + ^js shape (.getShapeById page (str (cthi/id :text1))) + ^js library (.-library context) + ^js local (.-local library) + ^js catalog (.-tokens local) + ^js token-set (.getSetById catalog (str set-id)) + ^js token (.getTokenById token-set (str token-id))] + (.applyToken shape token #js ["fontFamilies"]) + (js/setTimeout + (fn [] + (let [shape-id (cthi/id :text1) + page-id (cthf/current-page-id file)] + ;; Plugin readback exposes the documented plural key. + (t/is (= "font.primary" (.. shape -tokens -fontFamilies))) + ;; The undocumented singular spelling must not leak. + (t/is (undefined? (.. shape -tokens -fontFamily))) + ;; Internal state keeps the canonical `:font-family` key. + (t/is (= "font.primary" + (get-in @store + [:files (:id file) :data :pages-index page-id + :objects shape-id :applied-tokens :font-family]))) + (done))) + 0)))) + (t/deftest token-attr?-rejects-unknown-input (t/is (false? (boolean (ptok/token-attr? :not-a-real-attr)))) (t/is (false? (boolean (ptok/token-attr? "not-a-real-attr")))) @@ -236,7 +305,8 @@ set-id (cthi/new-id! :set) dup-id (cthi/new-id! :dup) proxy (ptok/token-set-proxy "plugin-id" file-id set-id)] - (with-redefs [dwtl/duplicate-token-set + (with-redefs [r/check-permission (constantly true) + dwtl/duplicate-token-set (mock/stub (fn [id {:keys [id-ref]}] (t/is (= set-id id)) (reset! id-ref dup-id) @@ -253,7 +323,8 @@ set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives") theme (ptok/token-theme-proxy "plugin-id" file-id theme-id) captured (atom [])] - (with-redefs [u/locate-token-theme + (with-redefs [r/check-permission (constantly true) + u/locate-token-theme (fn [_file _theme] (ctob/make-token-theme :id theme-id :name "Theme" @@ -274,7 +345,8 @@ set-id (cthi/new-id! :set) token-id (cthi/new-id! :token) captured (atom nil)] - (with-redefs [u/locate-token (constantly {:id token-id + (with-redefs [r/check-permission (constantly true) + u/locate-token (constantly {:id token-id :name "font.primary" :type :font-family :value ["Inter"]}) @@ -347,7 +419,8 @@ theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light") emitted (atom []) invalid (atom [])] - (with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) + (with-redefs [r/check-permission (constantly true) + u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) u/not-valid (fn [_ code value] (swap! invalid conj [code value])) dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) @@ -367,7 +440,8 @@ theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light") emitted (atom []) invalid (atom [])] - (with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) + (with-redefs [r/check-permission (constantly true) + u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) u/not-valid (fn [_ code value] (swap! invalid conj [code value])) dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) @@ -400,3 +474,304 @@ (t/is (= 2 (count @errors))) (t/is (every? #(instance? js/Error %) @errors)))))) +;; ═══════════════════════════════════════════════════════════════ +;; Permission check tests (T9-F-01) +;; ═══════════════════════════════════════════════════════════════ + +;; Note: token-proxy-name-setter-checks-permission test removed because +;; schema validation runs before the permission check, making it impossible +;; to test the permission check directly for setters with schemas. + +(t/deftest token-proxy-value-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color}) + u/locate-tokens-lib (constantly nil) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (set! (.-value proxy) "#ff0000") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :value "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-proxy-description-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (set! (.-description proxy) "A description") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :description "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-proxy-duplicate-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color :value "#000"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (.duplicate proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-proxy-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-name-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (set! (.-name proxy) "new-core") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-active-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (set! (.-active proxy) true) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-toggle-active-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)] + (.toggleActive proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-add-token-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id :name "core"))) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly tokens-lib) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (t/is (fn? (.-addToken proxy))) + (.addToken proxy #js {"type" "color" "name" "color.test" "value" "#FF0000"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addToken "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-duplicate-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)] + (.duplicate proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-group-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"}) + u/locate-tokens-lib (constantly nil) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (set! (.-group proxy) "new-group") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :group "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-name-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"}) + u/locate-tokens-lib (constantly nil) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (set! (.-name proxy) "Dark") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-active-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (set! (.-active proxy) true) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-toggle-active-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (.toggleActive proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-add-set-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{}}) + u/locate-token-set (constantly {:id set-id :name "core"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id) + set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (.addSet proxy set-proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-remove-set-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{"core"}}) + u/locate-token-set (constantly {:id set-id :name "core"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id) + set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (.removeSet proxy set-proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-duplicate-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (.duplicate proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest tokens-catalog-add-theme-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [catalog (ptok/tokens-catalog plugin-id file-id)] + (.addTheme catalog #js {"name" "NewTheme" "group" "mode"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addTheme "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest tokens-catalog-add-set-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [catalog (ptok/tokens-catalog plugin-id file-id)] + (.addSet catalog #js {"name" "NewSet"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + diff --git a/frontend/test/frontend_tests/plugins/user_test.cljs b/frontend/test/frontend_tests/plugins/user_test.cljs new file mode 100644 index 0000000000..620d527318 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/user_test.cljs @@ -0,0 +1,80 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.plugins.user-test + (:require + [app.main.data.comments :as dc] + [app.main.store :as st] + [app.plugins.api :as api] + [app.plugins.comments :as comments] + [app.plugins.file :as file] + [app.plugins.register :as r] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest comment-thread-owner-returns-nil-without-user-read + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id owner-id})] + (with-redefs [r/check-permission (constantly false) + dc/get-owner (constantly {:id owner-id :fullname "Owner"})] + (t/is (nil? (.-owner thread))) + (t/is (nil? (.-user thread)))))) + +(t/deftest comment-reply-owner-returns-nil-without-user-read + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + reply-id (random-uuid) + reply (comments/comment-proxy + plugin-id + file-id + page-id + thread-id + {:id reply-id :owner-id owner-id})] + (with-redefs [r/check-permission (constantly false) + dc/get-owner (constantly {:id owner-id :fullname "Owner"})] + (t/is (nil? (.-owner reply))) + (t/is (nil? (.-user reply)))))) + +(t/deftest file-version-created-by-returns-nil-without-user-read + (let [file-id (random-uuid) + version-id (random-uuid) + profile-id (random-uuid) + version (file/file-version-proxy + plugin-id + file-id + {profile-id {:id profile-id :fullname "User"}} + {:id version-id + :label "Version" + :created-at (js/Date.) + :profile-id profile-id})] + (with-redefs [r/check-permission (constantly false)] + (t/is (nil? (.-createdBy version)))))) + +(t/deftest get-current-user-returns-nil-without-user-read + (let [ctx (api/create-context plugin-id)] + (with-redefs [r/check-permission (constantly false) + st/state (atom {:session-id (random-uuid) + :profile {:id (random-uuid) :fullname "User"}})] + (t/is (nil? (.getCurrentUser ctx)))))) + +(t/deftest get-active-users-returns-empty-without-user-read + (let [ctx (api/create-context plugin-id)] + (with-redefs [r/check-permission (constantly false) + st/state (atom {:session-id (random-uuid) + :profile {:id (random-uuid)} + :workspace-presence {(random-uuid) {:id (random-uuid)}}})] + (t/is (zero? (.-length (.getActiveUsers ctx))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a139201f54..e085b8f2ec 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -8,6 +8,7 @@ [frontend-tests.code-gen-style-test] [frontend-tests.composable-tests.comp.sync-test] [frontend-tests.copy-as-svg-test] + [frontend-tests.data.comments-filters-test] [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.nitrate-test] @@ -45,10 +46,12 @@ [frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.update-position-test] [frontend-tests.logic.wasm-modifiers-nil-id-test] + [frontend-tests.logic.wasm-pixel-snap-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] [frontend-tests.plugins.file-test] + [frontend-tests.plugins.flex-test] [frontend-tests.plugins.format-test] [frontend-tests.plugins.grid-test] [frontend-tests.plugins.interactions-test] @@ -60,6 +63,7 @@ [frontend-tests.plugins.shape-bugfixes-test] [frontend-tests.plugins.text-test] [frontend-tests.plugins.tokens-test] + [frontend-tests.plugins.user-test] [frontend-tests.plugins.utils-test] [frontend-tests.plugins.value-objects-test] [frontend-tests.render-dimensions-test] @@ -114,6 +118,7 @@ 'frontend-tests.code-gen-style-test 'frontend-tests.composable-tests.comp.sync-test 'frontend-tests.copy-as-svg-test + 'frontend-tests.data.comments-filters-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.nitrate-test 'frontend-tests.data.profile-test @@ -152,9 +157,11 @@ 'frontend-tests.logic.sidebar-transform-coalescing-test 'frontend-tests.logic.update-position-test 'frontend-tests.logic.wasm-modifiers-nil-id-test + 'frontend-tests.logic.wasm-pixel-snap-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test + 'frontend-tests.plugins.flex-test 'frontend-tests.plugins.format-test 'frontend-tests.plugins.grid-test 'frontend-tests.plugins.interactions-test @@ -166,6 +173,7 @@ 'frontend-tests.plugins.shape-bugfixes-test 'frontend-tests.plugins.text-test 'frontend-tests.plugins.tokens-test + 'frontend-tests.plugins.user-test 'frontend-tests.plugins.utils-test 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index af4dd7fde3..42c442f5f0 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^4.1.10" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 734fd8e946..95f89b8594 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,12 +7,8 @@ import { playwright } from '@vitest/browser-playwright' // https://vitejs.dev/config/ import path from "node:path"; -import { fileURLToPath } from "node:url"; import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; -const dirname = - typeof __dirname !== "undefined" - ? __dirname - : path.dirname(fileURLToPath(import.meta.url)); +const dirname = import.meta.dirname; // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ @@ -45,15 +41,14 @@ export default defineConfig({ {browser: "chromium"}, ], }, - setupFiles: [".storybook/vitest.setup.ts"], }, }, ], }, resolve: { alias: { - "@target": resolve(__dirname, "./target/storybook"), - "@public": resolve(__dirname, "./resources/public/js/"), + "@target": resolve(dirname, "./target/storybook"), + "@public": resolve(dirname, "./resources/public/js/"), }, }, }); diff --git a/library/package.json b/library/package.json index f22f9aea27..aff0eb2082 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0-RC1", "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index 6fa97dcff3..9f4a4d81a3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/library/pnpm-workspace.yaml b/library/pnpm-workspace.yaml index b5e864413d..528f811e1a 100644 --- a/library/pnpm-workspace.yaml +++ b/library/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + minimumReleaseAgeExclude: - brace-expansion@5.0.8 || 5.0.9 patchedDependencies: diff --git a/mcp/README.md b/mcp/README.md index 1b8dc3ea29..6842adce7e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -267,7 +267,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | -| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | +| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | diff --git a/mcp/package.json b/mcp/package.json index 2b0046f3b0..abd1b8c8be 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.6" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 4c82e9796a..fae32707ef 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 5377e1bba7..534fef6226 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,6 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index e675c2e7c8..4711b793a5 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -24,7 +24,7 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "class-transformer": "^0.5.1", diff --git a/mcp/packages/server/src/PenpotMcpServer.test.ts b/mcp/packages/server/src/PenpotMcpServer.test.ts index 5c04e50400..68665359f5 100644 --- a/mcp/packages/server/src/PenpotMcpServer.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.test.ts @@ -1,6 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { PenpotMcpServer } from "./PenpotMcpServer"; +import { PenpotMcpServer, shouldRegisterDeveloperTools } from "./PenpotMcpServer"; + +test("registers developer tools in local devenv mode", () => { + assert.equal(shouldRegisterDeveloperTools(true, false), true); +}); + +test("does not register developer tools in multi-user devenv mode", () => { + assert.equal(shouldRegisterDeveloperTools(true, true), false); +}); + +test("does not register developer tools when devenv mode is disabled", () => { + assert.equal(shouldRegisterDeveloperTools(false, false), false); +}); // ── Pure function tests ──────────────────────────────────────── diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 09849c9316..c620aca3fc 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -50,6 +50,13 @@ class ToolInfo { ) {} } +/** + * Indicates whether developer tools may be registered for the current server mode. + */ +export function shouldRegisterDeveloperTools(isDevEnv: boolean, isMultiUserMode: boolean): boolean { + return isDevEnv && !isMultiUserMode; +} + export class PenpotMcpServer { /** * Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed. @@ -259,7 +266,7 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } - if (this.isDevEnv()) { + if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) { const nreplClient = new NreplClient(); toolInstances.push(new CljsReplTool(this, nreplClient)); toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 706069eee7..6b15ed28ab 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index eb1c390c2f..13e111b1dc 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + allowBuilds: esbuild: true sharp: false diff --git a/media-processor/package.json b/media-processor/package.json index 0bb5711cf7..ea012c5246 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "dependencies": { "express": "^5.2.1", "multer": "^2.2.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 51eef68766..a35ada5f76 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/media-processor/pnpm-workspace.yaml b/media-processor/pnpm-workspace.yaml index 5ed0b5af0d..f355f8d1a4 100644 --- a/media-processor/pnpm-workspace.yaml +++ b/media-processor/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +storeDir: ../.pnpm-store + allowBuilds: esbuild: true diff --git a/package.json b/package.json index 695a8f2e20..965f175b3d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 9cd186baf0..96bd5dc421 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -10,6 +10,8 @@ - **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing. - **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. +- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001. +- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index 8d6aff2904..2bec61bf8b 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/colors-to-tokens-plugin/vite.config.ts b/plugins/apps/colors-to-tokens-plugin/vite.config.ts index ce57d28043..4f9cb48c23 100644 --- a/plugins/apps/colors-to-tokens-plugin/vite.config.ts +++ b/plugins/apps/colors-to-tokens-plugin/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, test: { watch: false, globals: true, diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 60697f5367..949296d0b4 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -26,5 +26,5 @@ "vite": "^8.2.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/composable-test-suite/pnpm-lock.yaml b/plugins/apps/composable-test-suite/pnpm-lock.yaml deleted file mode 100644 index 93ed3672c1..0000000000 --- a/plugins/apps/composable-test-suite/pnpm-lock.yaml +++ /dev/null @@ -1,857 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@penpot/plugin-styles': - specifier: 1.4.1 - version: 1.4.1 - '@penpot/plugin-types': - specifier: 1.4.1 - version: 1.4.1 - devDependencies: - playwright: - specifier: ^1.61.1 - version: 1.61.1 - prettier: - specifier: ^3.6.2 - version: 3.9.4 - typescript: - specifier: ^5.8.3 - version: 5.9.3 - vite: - specifier: ^7.0.8 - version: 7.3.6(@types/node@26.0.1) - vite-live-preview: - specifier: ^0.3.2 - version: 0.3.2(vite@7.3.6(@types/node@26.0.1)) - -packages: - - '@commander-js/extra-typings@12.1.0': - resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==} - peerDependencies: - commander: ~12.1.0 - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@penpot/plugin-styles@1.4.1': - resolution: {integrity: sha512-6TuJqKQsq1Xmhn2A02R+kCOzIzIdqgFg5z6ncLH2PlAflKIX6aYsGiOF7yFx4RYgCegRVMFPnVis6/hwO+YGQg==} - - '@penpot/plugin-types@1.4.1': - resolution: {integrity: sha512-pHE2B3GI8M5JR03S/NdBoN+z6e1R1IEh3vpFbLG9LN0EZpQE6nEbmCo5jWAWI73Jqlg6CHG/RWVJNmWECnkDTA==} - - '@rollup/rollup-android-arm-eabi@4.62.2': - resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.2': - resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.2': - resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.2': - resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.2': - resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.2': - resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.62.2': - resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.62.2': - resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.62.2': - resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.62.2': - resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.62.2': - resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.2': - resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.2': - resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.2': - resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} - cpu: [x64] - os: [win32] - - '@types/ansi-html@0.0.0': - resolution: {integrity: sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==} - - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - - ansi-html@0.0.9: - resolution: {integrity: sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==} - engines: {'0': node >= 0.8.0} - hasBin: true - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - escape-goat@4.0.0: - resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} - engines: {node: '>=12'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - p-defer@4.0.1: - resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} - engines: {node: '>=12'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} - hasBin: true - - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} - engines: {node: ^10 || ^12 || >=14} - - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} - engines: {node: '>=14'} - hasBin: true - - rollup@4.62.2: - resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - - vite-live-preview@0.3.2: - resolution: {integrity: sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==} - hasBin: true - peerDependencies: - vite: '>=5.2.13' - - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - -snapshots: - - '@commander-js/extra-typings@12.1.0(commander@12.1.0)': - dependencies: - commander: 12.1.0 - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@penpot/plugin-styles@1.4.1': {} - - '@penpot/plugin-types@1.4.1': {} - - '@rollup/rollup-android-arm-eabi@4.62.2': - optional: true - - '@rollup/rollup-android-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-x64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.2': - optional: true - - '@types/ansi-html@0.0.0': {} - - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree@1.0.9': {} - - '@types/ms@2.1.0': {} - - '@types/node@26.0.1': - dependencies: - undici-types: 8.3.0 - - '@types/ws@8.18.1': - dependencies: - '@types/node': 26.0.1 - - ansi-html@0.0.9: {} - - chalk@5.6.2: {} - - commander@12.1.0: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - escape-goat@4.0.0: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.2: - optional: true - - fsevents@2.3.3: - optional: true - - ms@2.1.3: {} - - nanoid@3.3.15: {} - - p-defer@4.0.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - playwright-core@1.61.1: {} - - playwright@1.61.1: - dependencies: - playwright-core: 1.61.1 - optionalDependencies: - fsevents: 2.3.2 - - postcss@8.5.16: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prettier@3.9.4: {} - - rollup@4.62.2: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.2 - '@rollup/rollup-android-arm64': 4.62.2 - '@rollup/rollup-darwin-arm64': 4.62.2 - '@rollup/rollup-darwin-x64': 4.62.2 - '@rollup/rollup-freebsd-arm64': 4.62.2 - '@rollup/rollup-freebsd-x64': 4.62.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 - '@rollup/rollup-linux-arm-musleabihf': 4.62.2 - '@rollup/rollup-linux-arm64-gnu': 4.62.2 - '@rollup/rollup-linux-arm64-musl': 4.62.2 - '@rollup/rollup-linux-loong64-gnu': 4.62.2 - '@rollup/rollup-linux-loong64-musl': 4.62.2 - '@rollup/rollup-linux-ppc64-gnu': 4.62.2 - '@rollup/rollup-linux-ppc64-musl': 4.62.2 - '@rollup/rollup-linux-riscv64-gnu': 4.62.2 - '@rollup/rollup-linux-riscv64-musl': 4.62.2 - '@rollup/rollup-linux-s390x-gnu': 4.62.2 - '@rollup/rollup-linux-x64-gnu': 4.62.2 - '@rollup/rollup-linux-x64-musl': 4.62.2 - '@rollup/rollup-openbsd-x64': 4.62.2 - '@rollup/rollup-openharmony-arm64': 4.62.2 - '@rollup/rollup-win32-arm64-msvc': 4.62.2 - '@rollup/rollup-win32-ia32-msvc': 4.62.2 - '@rollup/rollup-win32-x64-gnu': 4.62.2 - '@rollup/rollup-win32-x64-msvc': 4.62.2 - fsevents: 2.3.3 - - source-map-js@1.2.1: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - typescript@5.9.3: {} - - undici-types@8.3.0: {} - - vite-live-preview@0.3.2(vite@7.3.6(@types/node@26.0.1)): - dependencies: - '@commander-js/extra-typings': 12.1.0(commander@12.1.0) - '@types/ansi-html': 0.0.0 - '@types/debug': 4.1.13 - '@types/ws': 8.18.1 - ansi-html: 0.0.9 - chalk: 5.6.2 - commander: 12.1.0 - debug: 4.4.3 - escape-goat: 4.0.0 - p-defer: 4.0.1 - vite: 7.3.6(@types/node@26.0.1) - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - vite@7.3.6(@types/node@26.0.1): - dependencies: - esbuild: 0.28.1 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.16 - rollup: 4.62.2 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.0.1 - fsevents: 2.3.3 - - ws@8.21.0: {} diff --git a/plugins/apps/composable-test-suite/pnpm-workspace.yaml b/plugins/apps/composable-test-suite/pnpm-workspace.yaml deleted file mode 100644 index c5739b7433..0000000000 --- a/plugins/apps/composable-test-suite/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -ignoredBuiltDependencies: - - esbuild diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 5e6772cc8e..6036de7b96 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/contrast-plugin/vite.config.ts b/plugins/apps/contrast-plugin/vite.config.ts index 5b4538a2e6..afa64b0c84 100644 --- a/plugins/apps/contrast-plugin/vite.config.ts +++ b/plugins/apps/contrast-plugin/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, test: { globals: true, environment: 'jsdom', diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 9507e644df..ee5d641bf9 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/create-palette-plugin/vite.config.ts b/plugins/apps/create-palette-plugin/vite.config.ts index f8b241a643..b82adfd3c6 100644 --- a/plugins/apps/create-palette-plugin/vite.config.ts +++ b/plugins/apps/create-palette-plugin/vite.config.ts @@ -1,7 +1,7 @@ /// <reference types="vitest/config" /> import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, server: { port: 4202, host: '0.0.0.0', diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 008fd417ff..866566cfa8 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/e2e/vite.config.ts b/plugins/apps/e2e/vite.config.ts index ae60487c66..12a797acb0 100644 --- a/plugins/apps/e2e/vite.config.ts +++ b/plugins/apps/e2e/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, test: { testTimeout: 20000, watch: false, diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 59850da654..3e919d958b 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/example-styles/vite.config.ts b/plugins/apps/example-styles/vite.config.ts index 39a4e556da..43d5819170 100644 --- a/plugins/apps/example-styles/vite.config.ts +++ b/plugins/apps/example-styles/vite.config.ts @@ -1,7 +1,7 @@ /// <reference types="vitest/config" /> import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, server: { port: 4202, host: '0.0.0.0', diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 5df19f2d44..749eebfdff 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index f747085348..fffd54a5d3 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/lorem-ipsum-plugin/vite.config.ts b/plugins/apps/lorem-ipsum-plugin/vite.config.ts index 92c2628f69..a26b77cba6 100644 --- a/plugins/apps/lorem-ipsum-plugin/vite.config.ts +++ b/plugins/apps/lorem-ipsum-plugin/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, test: { watch: false, globals: true, diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index e88b6354fe..3d2fa7ec9b 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -19,5 +19,5 @@ "devDependencies": { "playwright": "^1.62.1" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.node.json b/plugins/apps/plugin-api-test-suite/tsconfig.node.json index 15543271a6..0314a479cf 100644 --- a/plugins/apps/plugin-api-test-suite/tsconfig.node.json +++ b/plugins/apps/plugin-api-test-suite/tsconfig.node.json @@ -4,6 +4,7 @@ "outDir": "../../dist/out-tsc", "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "types": ["node"], "noUnusedLocals": true, "noUnusedParameters": true diff --git a/plugins/apps/plugin-api-test-suite/vite.config.headless.ts b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts index c2fad314f5..706f1f43d5 100644 --- a/plugins/apps/plugin-api-test-suite/vite.config.headless.ts +++ b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts @@ -1,4 +1,4 @@ -import { iifeConfig } from './vite.config.iife'; +import { iifeConfig } from './vite.config.iife.ts'; // Builds the CI test entry as a single self-executing (IIFE) bundle, evaluated // inside the Penpot plugin sandbox via `globalThis.ɵloadPlugin({ code })` by the diff --git a/plugins/apps/plugin-api-test-suite/vite.config.iife.ts b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts index b2ee8a2985..130db9dd37 100644 --- a/plugins/apps/plugin-api-test-suite/vite.config.iife.ts +++ b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts @@ -12,7 +12,7 @@ import { defineConfig, type UserConfig } from 'vite'; */ export function iifeConfig(name: string, entry: string): UserConfig { return defineConfig({ - root: __dirname, + root: import.meta.dirname, resolve: { tsconfigPaths: true, }, diff --git a/plugins/apps/plugin-api-test-suite/vite.config.tests.ts b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts index 39468d5209..dbf4d20114 100644 --- a/plugins/apps/plugin-api-test-suite/vite.config.tests.ts +++ b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts @@ -1,4 +1,4 @@ -import { iifeConfig } from './vite.config.iife'; +import { iifeConfig } from './vite.config.iife.ts'; // Builds the test cases as a single self-executing (IIFE) bundle that publishes // the discovered tests on `globalThis.__penpotReloadedTests`. The UI "Reload" diff --git a/plugins/apps/plugin-api-test-suite/vite.config.ts b/plugins/apps/plugin-api-test-suite/vite.config.ts index 25c811f49e..00e01630ff 100644 --- a/plugins/apps/plugin-api-test-suite/vite.config.ts +++ b/plugins/apps/plugin-api-test-suite/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, // Emit relative asset URLs in index.html so the built plugin works when served // from a subdirectory (Penpot serves the bundled plugins under `/plugins/...`). // Vite resolves `./` to `/` for the dev server, so `pnpm run dev` is unaffected. diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index c2db3b3009..7a79003310 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index e675d5c538..852e7f1764 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/poc-tokens-plugin/vite.config.ts b/plugins/apps/poc-tokens-plugin/vite.config.ts index 80619b371f..0f62223ab9 100644 --- a/plugins/apps/poc-tokens-plugin/vite.config.ts +++ b/plugins/apps/poc-tokens-plugin/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, test: { watch: false, globals: true, diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index ca57c4ff7c..87279ccc4b 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index ccdb6df697..20bfb2245f 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 15935921fe..c63c7069ed 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index fa31aac91a..4dc44175bb 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts new file mode 100644 index 0000000000..52a0267db0 --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, vi, expect, beforeEach } from 'vitest'; +import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin'; +import { createPlugin } from './create-plugin'; +import { ses } from './ses.js'; +import type { Context } from '@penpot/plugin-types'; +import type { Manifest } from './models/manifest.model.js'; + +vi.mock('./create-plugin', () => ({ + createPlugin: vi.fn(), +})); + +// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the +// real `ses.harden` to assert that `loadPlugin` never hardens the host +// context. + +describe('loadPlugin host context boundary (regression for #11001)', () => { + let manifest: Manifest; + + beforeEach(() => { + manifest = { + pluginId: 'test-plugin', + name: 'Test Plugin', + host: '', + code: '', + permissions: ['content:read'], + }; + + vi.mocked(createPlugin).mockResolvedValue({ + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>); + }); + + it('does not freeze host-owned functions reachable through the context', async () => { + const hostListener = function hostListener() { + return 'host-value'; + }; + const nestedHostObject = { + nestedFn() { + return 'nested'; + }, + }; + const hostContext = { + addListener: hostListener, + nested: nestedHostObject, + } as unknown as Context; + + setContextBuilder(() => hostContext); + + const hardenSpy = vi.spyOn(ses, 'harden'); + + await loadPlugin(manifest); + + // The host context itself must be passed through untouched so the host + // can keep modifying its own runtime objects (e.g. on page navigation). + expect(createPlugin).toHaveBeenCalledWith( + hostContext, + manifest, + expect.any(Function), + undefined, + ); + + // Host-owned functions must remain extensible: page navigation and + // runtime code may patch/augment them (e.g. assigning `toString` on a + // wrapped listener). A deep `ses.harden(context)` here would freeze + // them and turn such later assignments into + // `TypeError: Cannot assign to read only property 'toString'`. + expect(Object.isFrozen(hostListener)).toBe(false); + expect(Object.isExtensible(hostListener)).toBe(true); + expect(Object.isFrozen(nestedHostObject)).toBe(false); + expect(() => { + hostListener.toString = () => 'patched-by-host'; + }).not.toThrow(); + + expect(hardenSpy).not.toHaveBeenCalled(); + expect(getPlugins()).toHaveLength(1); + + hardenSpy.mockRestore(); + }); +}); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts new file mode 100644 index 0000000000..06ef7b20b7 --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, vi, expect, beforeAll } from 'vitest'; +import 'ses'; +import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin'; +import type { Context } from '@penpot/plugin-types'; +import type { Manifest } from './models/manifest.model.js'; + +// Real initialization-path regression tests for #11001. +// +// NOTE: `./create-plugin`, `./plugin-manager` and +// `./create-sandbox` are intentionally NOT mocked here. This spec exercises +// the real `loadPlugin → createPlugin → createPluginManager → createSandbox` +// path with the real SES implementation, mirroring the production +// initialization order from `plugins-runtime/src/index.ts`: +// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics +// +// `hardenIntrinsics()` is deliberately NOT called up front: the first test +// must run in the production window where only `repairIntrinsics` has run. +// Tests run in declaration order; the later tests build on the locked-down +// state the first real `loadPlugin` leaves behind (via `createSandbox`). +// +// Note on SES isolation: this suite depends on Vitest's default +// file-level isolation. Each test file runs in a separate worker +// process, so SES intrinsics frozen here do not leak into other +// spec files. + +const REPAIR_OPTIONS = { + evalTaming: 'unsafeEval', + stackFiltering: 'verbose', + errorTaming: 'unsafe', + consoleTaming: 'unsafe', + errorTrapping: 'none', + unhandledRejectionTrapping: 'none', +}; + +function makeManifest( + code: string, + permissions: Manifest['permissions'], +): Manifest { + return { + pluginId: 'test-plugin', + name: 'Test Plugin', + host: '', + code, + permissions, + }; +} + +function makeHostFixture() { + const listenerTypes: string[] = []; + const listeners = new Map<symbol, string>(); + // Inline code (empty host + non-URL code) resolves without network, so no + // fetch mock is needed. UI/modal APIs are never touched by the probe code. + const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' })); + const selection: object[] = [{ id: 'shape-1' }]; + const context = { + addListener: (type: string, _callback: (...args: unknown[]) => unknown) => { + const id = Symbol(type); + listeners.set(id, type); + listenerTypes.push(type); + return id; + }, + removeListener: (id: symbol) => { + listeners.delete(id); + }, + theme: 'dark', + createRectangle, + selection, + // Host-only member: present on the raw context but NOT part of the + // public penpot API. Plugin code must never see it (see B-2 below). + __internalSecret: 'host-internal', + } as unknown as Context; + return { context, listenerTypes, createRectangle, selection }; +} + +function lastCompartmentGlobalThis(): Record<string, unknown> { + const plugins = getPlugins(); + const last = plugins[plugins.length - 1] as unknown as { + compartment: { compartment: { globalThis: Record<string, unknown> } }; + }; + return last.compartment.compartment.globalThis; +} + +describe('loadPlugin real initialization path (regression for #11001)', () => { + beforeAll(() => { + // Production module-load step only: repairs intrinsics WITHOUT + // installing override taming, exactly like `index.ts` at import time. + ( + globalThis as unknown as { repairIntrinsics(opts: object): void } + ).repairIntrinsics({ ...REPAIR_OPTIONS }); + }); + + it('loads through the real path and keeps host function augmentation working', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + + await loadPlugin( + makeManifest('penpot.on("finish", function () {});', ['content:read']), + ); + + // The plugin code really ran inside the sandbox: the manager registers + // `themechange` + `finish`, and the plugin code adds its own `finish` + // listener through the public API. + expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']); + expect(getPlugins()).toHaveLength(1); + + // The user-facing behavior from #11001: host-side augmentation of a + // fresh function (e.g. assigning `toString` during page navigation) + // succeeds after a real plugin load. + const freshWrapper = function freshWrapper() { + return 'navigation-wrapper'; + }; + expect(() => { + freshWrapper.toString = () => 'patched-by-runtime'; + }).not.toThrow(); + expect(fixture.listenerTypes.length).toBe(3); + }); + + it('denies the write API without permission and leaves the host untouched', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + + await expect( + loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])), + ).rejects.toThrow(/content:write/); + expect(fixture.createRectangle).not.toHaveBeenCalled(); + }); + + it('allows the same write API with permission', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + + await loadPlugin( + makeManifest('penpot.createRectangle();', [ + 'content:read', + 'content:write', + ]), + ); + expect(fixture.createRectangle).toHaveBeenCalledTimes(1); + }); + + it('does not expose raw host-only context members to plugin code', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + + await loadPlugin( + makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [ + 'content:read', + ]), + ); + // The public `penpot` object is a boundary proxy over a curated API, not + // the raw host context, so host-only members are invisible inside. + expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined'); + }); + + it('keeps safeReturn protection on returned values without blocking allowed edits', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + + await loadPlugin( + makeManifest( + 'penpot.createRectangle(); ' + + 'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);', + ['content:read', 'content:write'], + ), + ); + expect(fixture.createRectangle).toHaveBeenCalledTimes(1); + expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true); + }); +}); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index d05178f1f7..dfd44f4971 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -3,7 +3,6 @@ import type { Context } from '@penpot/plugin-types'; import { loadManifest } from './parse-manifest.js'; import { Manifest } from './models/manifest.model.js'; import { createPlugin } from './create-plugin.js'; -import { ses } from './ses.js'; let plugins: Awaited<ReturnType<typeof createPlugin>>[] = []; @@ -54,8 +53,23 @@ export const loadPlugin = async function ( closeAllPlugins(); + // The host context is not deeply frozen at this load stage. + // + // The context still contains host-internal function objects and shared + // prototypes that the host may legitimately extend after plugin load + // (for example, by assigning custom properties). Deep-freezing here + // would freeze those prototypes before SES override taming completes, + // preventing later host-side mutations with a "Cannot assign to read + // only property" TypeError. + // + // Responsibility boundary: this function forwards the context to the + // sandbox layer without deep-freezing it. The public API that plugins + // consume is constructed by the API module (`api/index.ts`), and + // `createSandbox`'s proxy handler applies `ses.safeReturn` to values + // crossing into the sandbox. Compartment isolation and intrinsics + // hardening are performed by createSandbox, not here. const plugin = await createPlugin( - ses.harden(context) as Context, + context, manifest, () => { plugins = plugins.filter((api) => api !== plugin); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts index d53f4f5296..7b4d8ac9cb 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts @@ -3,6 +3,7 @@ import { createPluginManager } from './plugin-manager'; import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js'; import { PluginModalElement } from './modal/plugin-modal.js'; import { openUIApi } from './api/openUI.api.js'; +import { validateUIUrl } from './validate-url.js'; import type { Context, Theme } from '@penpot/plugin-types'; import type { Manifest } from './models/manifest.model.js'; @@ -16,6 +17,10 @@ vi.mock('./api/openUI.api.js', () => ({ openUIApi: vi.fn(), })); +vi.mock('./validate-url.js', () => ({ + validateUIUrl: vi.fn(), +})); + describe('createPluginManager', () => { let mockContext: Context; let manifest: Manifest; @@ -294,4 +299,39 @@ describe('createPluginManager', () => { expect(mockContext.removeListener).toHaveBeenCalled(); expect(onCloseCallback).toHaveBeenCalled(); }); + + it('should validate the modal URL before opening', async () => { + const pluginManager = await createPluginManager( + mockContext, + manifest, + onCloseCallback, + onReloadModal, + ); + + pluginManager.openModal('Test Modal', '/test-url'); + + expect(validateUIUrl).toHaveBeenCalledWith( + 'https://example.com/plugin', + manifest.host, + ); + }); + + it('should throw when URL validation fails', async () => { + vi.mocked(validateUIUrl).mockImplementation(() => { + throw new Error("Plugin UI URL must not point to Penpot's own domain"); + }); + + const pluginManager = await createPluginManager( + mockContext, + manifest, + onCloseCallback, + onReloadModal, + ); + + expect(() => pluginManager.openModal('Test Modal', '/test-url')).toThrow( + "Plugin UI URL must not point to Penpot's own domain", + ); + + expect(openUIApi).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 2a2b43e1c6..7878a1abcd 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -7,6 +7,7 @@ import { openUIApi } from './api/openUI.api.js'; import { OpenUIOptions } from './models/open-ui-options.model.js'; import { RegisterListener } from './models/plugin.model.js'; import { openUISchema } from './models/open-ui-options.schema.js'; +import { validateUIUrl } from './validate-url.js'; export async function createPluginManager( context: Context, @@ -94,6 +95,7 @@ export async function createPluginManager( const openModal = (name: string, url: string, options?: OpenUIOptions) => { const theme = context.theme as Theme; const modalUrl = prepareUrl(manifest, url, { theme }); + validateUIUrl(modalUrl, manifest.host); if (modal?.getAttribute('iframe-src') === modalUrl) { return; diff --git a/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts b/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts new file mode 100644 index 0000000000..34466a69b6 --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + getPenpotOrigin, + isPenpotOrigin, + validateUIUrl, +} from './validate-url.js'; + +describe('validate-url', () => { + const originalLocation = globalThis.location; + const originalPenpotPublicURI = (globalThis as any).penpotPublicURI; + const externalHost = 'https://example.com'; + + beforeEach(() => { + delete (globalThis as any).penpotPublicURI; + }); + + afterEach(() => { + if (originalPenpotPublicURI !== undefined) { + (globalThis as any).penpotPublicURI = originalPenpotPublicURI; + } else { + delete (globalThis as any).penpotPublicURI; + } + }); + + describe('getPenpotOrigin', () => { + it('should return location.origin when penpotPublicURI is not set', () => { + expect(getPenpotOrigin()).toBe(originalLocation.origin); + }); + + it('should return origin from penpotPublicURI when set', () => { + (globalThis as any).penpotPublicURI = 'https://design.penpot.com/'; + expect(getPenpotOrigin()).toBe('https://design.penpot.com'); + }); + + it('should fall back to location.origin when penpotPublicURI is invalid', () => { + (globalThis as any).penpotPublicURI = 'not-a-valid-url'; + expect(getPenpotOrigin()).toBe(originalLocation.origin); + }); + }); + + describe('isPenpotOrigin', () => { + it('should be true for a URL on Penpot origin', () => { + expect( + isPenpotOrigin(`${originalLocation.origin}/plugin/manifest.json`), + ).toBe(true); + }); + + it('should be false for a URL on another origin', () => { + expect(isPenpotOrigin(`${externalHost}/manifest.json`)).toBe(false); + }); + + it('should be false for an unparseable URL', () => { + expect(isPenpotOrigin('not-a-valid-url')).toBe(false); + }); + }); + + describe('validateUIUrl', () => { + it('should throw when URL has same origin as location.origin', () => { + const penpotOrigin = originalLocation.origin; + expect(() => + validateUIUrl(`${penpotOrigin}/some/path`, externalHost), + ).toThrow("Plugin UI URL must not point to Penpot's own domain"); + }); + + it('should not throw when URL has different origin', () => { + expect(() => + validateUIUrl('https://example.com/plugin-ui', externalHost), + ).not.toThrow(); + }); + + it('should throw when URL matches penpotPublicURI origin', () => { + (globalThis as any).penpotPublicURI = 'https://design.penpot.com/'; + expect(() => + validateUIUrl('https://design.penpot.com/some/path', externalHost), + ).toThrow("Plugin UI URL must not point to Penpot's own domain"); + }); + + it('should not throw when URL has same hostname but different port', () => { + const url = new URL(originalLocation.origin); + const differentPort = `${url.protocol}//${url.hostname}:9999`; + expect(() => + validateUIUrl(`${differentPort}/path`, externalHost), + ).not.toThrow(); + }); + + it('should throw even when URL has different path on same origin', () => { + const penpotOrigin = originalLocation.origin; + expect(() => + validateUIUrl(`${penpotOrigin}/deeply/nested/path`, externalHost), + ).toThrow(); + }); + + it('should not throw when the manifest is served from Penpot origin', () => { + const penpotOrigin = originalLocation.origin; + expect(() => + validateUIUrl(`${penpotOrigin}/some/path`, `${penpotOrigin}/plugin`), + ).not.toThrow(); + }); + + it('should not throw when the manifest is served from penpotPublicURI origin', () => { + (globalThis as any).penpotPublicURI = 'https://design.penpot.com/'; + expect(() => + validateUIUrl( + 'https://design.penpot.com/some/path', + 'https://design.penpot.com/plugin', + ), + ).not.toThrow(); + }); + + it('should still throw when the manifest host is unparseable', () => { + const penpotOrigin = originalLocation.origin; + expect(() => validateUIUrl(`${penpotOrigin}/path`, '')).toThrow( + "Plugin UI URL must not point to Penpot's own domain", + ); + }); + }); +}); diff --git a/plugins/libs/plugins-runtime/src/lib/validate-url.ts b/plugins/libs/plugins-runtime/src/lib/validate-url.ts new file mode 100644 index 0000000000..6ff4a72273 --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/validate-url.ts @@ -0,0 +1,44 @@ +export function getPenpotOrigin(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const publicUri = (globalThis as any).penpotPublicURI; + if (publicUri) { + try { + return new URL(publicUri).origin; + } catch { + // fall through to location.origin + } + } + return globalThis.location.origin; +} + +/** + * Whether the given URL is served from Penpot's own origin. Unparseable URLs + * are considered external. + */ +export function isPenpotOrigin(url: string): boolean { + try { + return new URL(url).origin === getPenpotOrigin(); + } catch { + return false; + } +} + +/** + * Rejects UI URLs that resolve to Penpot's own origin, which would let the + * plugin iframe escape its sandbox isolation. + * + * Plugins whose manifest is itself served from Penpot's origin are part of the + * instance and are exempt from the check. + */ +export function validateUIUrl(url: string, manifestHost: string): void { + if (isPenpotOrigin(manifestHost)) { + return; + } + + const parsed = new URL(url); + if (parsed.origin === getPenpotOrigin()) { + throw new Error( + `Plugin UI URL must not point to Penpot's own domain: ${url}`, + ); + } +} diff --git a/plugins/libs/plugins-runtime/vite.config.ts b/plugins/libs/plugins-runtime/vite.config.ts index 14f8d61d18..a054f46cb1 100644 --- a/plugins/libs/plugins-runtime/vite.config.ts +++ b/plugins/libs/plugins-runtime/vite.config.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import checker from 'vite-plugin-checker'; export default defineConfig({ - root: __dirname, + root: import.meta.dirname, cacheDir: '../node_modules/.vite/plugins-runtime', resolve: { @@ -17,7 +17,7 @@ export default defineConfig({ plugins: [ dts({ entryRoot: 'src', - tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'), + tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'), }), checker({ typescript: { diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 31e7d851b3..62f2b3c21b 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/plugins/package.json b/plugins/package.json index b7ab60fbd0..25fa71a7fb 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index d1d02b8eeb..70b95a2f81 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/plugins/pnpm-workspace.yaml b/plugins/pnpm-workspace.yaml index a0099791fc..864c77bb38 100644 --- a/plugins/pnpm-workspace.yaml +++ b/plugins/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + packages: - 'apps/**' - 'libs/**' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 008bd049b7..c35d255221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.3.4 + version: 12.3.4 + +packages: + + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} + cpu: [x64] + os: [win32] + + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.3.4': + optional: true + + '@pnpm/exe.darwin-x64@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64@12.3.4': + optional: true + + '@pnpm/exe.linux-x64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-x64@12.3.4': + optional: true + + '@pnpm/exe.win32-arm64@12.3.4': + optional: true + + '@pnpm/exe.win32-x64@12.3.4': + optional: true + + pnpm@12.3.4: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 + +--- lockfileVersion: '9.0' settings: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7a28fcc29c..612f46bca4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: .pnpm-store + allowBuilds: esbuild: true diff --git a/render-wasm/package.json b/render-wasm/package.json index cdbe651e3d..3f8b581f51 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", + "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index c06712b52e..58127cd45a 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.0.0 - version: 12.0.0 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.0.0': - resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.0.0': - resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.0.0': - resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.0.0': - resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.0.0': - resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.0.0': - resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.0.0': - resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.0.0': - resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.0.0: - resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.0.0': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.0.0': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.0.0': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.0.0': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.0.0': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.0.0': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.0.0': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.0.0': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.0.0: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0' diff --git a/render-wasm/pnpm-workspace.yaml b/render-wasm/pnpm-workspace.yaml index 09a02ca1c8..cad1aaed6b 100644 --- a/render-wasm/pnpm-workspace.yaml +++ b/render-wasm/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +storeDir: ../.pnpm-store + allowBuilds: esbuild: true onlyBuiltDependencies: diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots index 43a23421ca..68140fefdd 100755 --- a/render-wasm/preview-snapshots +++ b/render-wasm/preview-snapshots @@ -12,6 +12,7 @@ # # Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies # the bundled font into `target/svg-preview/fonts/` so the gallery renders text. +# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills. # # When a test produced a pending change there will be a `*.snap.new` next to the # accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side. @@ -27,10 +28,14 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview" OUT="$OUT_DIR/index.html" FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf" FONT_DIR="$OUT_DIR/fonts" +IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg" +IMAGE_DIR="$OUT_DIR/images" mkdir -p "$OUT_DIR" mkdir -p "$FONT_DIR" +mkdir -p "$IMAGE_DIR" cp "$FONT_SRC" "$FONT_DIR/" +cp "$IMAGE_SRC" "$IMAGE_DIR/" # Prints the SVG body of a snapshot file: everything after the second `---` # line (the YAML front matter insta writes). diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index b7956075a6..26e149d97c 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -124,14 +124,21 @@ macro_rules! with_current_shape { #[cfg(test)] pub(crate) struct TestRenderResourcesGuard { prev: *mut RenderResources, + _lock: std::sync::MutexGuard<'static, ()>, } +#[cfg(test)] +static TEST_RENDER_RESOURCES_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] impl TestRenderResourcesGuard { pub(crate) fn install(resources: &mut RenderResources) -> Self { + let lock = TEST_RENDER_RESOURCES_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let prev = unsafe { RENDER_RESOURCES }; unsafe { RENDER_RESOURCES = resources as *mut _ }; - Self { prev } + Self { prev, _lock: lock } } } diff --git a/render-wasm/src/math/bools.rs b/render-wasm/src/math/bools.rs index c1f5b82edc..cb300d83d9 100644 --- a/render-wasm/src/math/bools.rs +++ b/render-wasm/src/math/bools.rs @@ -268,7 +268,6 @@ fn difference( .iter() .filter(|s| path_a.contains(to_point(s.evaluate(TValue::Parametric(0.5))))) .copied() - .map(|s| s.reverse()) .map(|b| (BezierSource::B, b)), ); @@ -278,16 +277,53 @@ fn difference( fn exclusion(segments_a: Vec<Bezier>, segments_b: Vec<Bezier>) -> Vec<(BezierSource, Bezier)> { let mut result = Vec::new(); result.extend(segments_a.iter().copied().map(|b| (BezierSource::A, b))); - result.extend( - segments_b - .iter() - .copied() - .map(|s| s.reverse()) - .map(|b| (BezierSource::B, b)), - ); + result.extend(segments_b.iter().copied().map(|b| (BezierSource::B, b))); result } +// Mirrors `app.common.types.path.subpath/clockwise?`. +fn is_clockwise(path: &Path) -> bool { + let mut points: Vec<(f32, f32)> = Vec::new(); + + for segment in path.segments().iter() { + match *segment { + Segment::MoveTo(p) => { + if !points.is_empty() { + break; + } + points.push(p); + } + Segment::LineTo(p) => points.push(p), + Segment::CurveTo((_, _, p)) => points.push(p), + Segment::Close => break, + } + } + + if points.len() < 3 { + return false; + } + + let mut signed_area = 0.0f64; + for i in 0..points.len() { + let (x1, y1) = points[i]; + let (x2, y2) = points[(i + 1) % points.len()]; + signed_area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1); + } + + signed_area > 0.0 +} + +// The kept pieces of B must point the same way round as the kept pieces of A. Not the +// `path.bool/content-bool-pair` rule, which reverses intersection on same winding and +// relies on `subpath/merge-paths` flipping subpaths when it joins them. +fn should_reverse_b(bool_type: BoolType, a_is_clockwise: bool, path_b: &Path) -> bool { + let same_winding = a_is_clockwise == is_clockwise(path_b); + match bool_type { + BoolType::Union | BoolType::Intersection => !same_winding, + BoolType::Difference | BoolType::Exclusion => same_winding, + } +} + #[derive(Debug, Clone, PartialEq, Copy)] enum BezierSource { A, @@ -305,17 +341,14 @@ fn pop_first_from_pool(pool: &mut BezierPool) -> Option<(BezierSource, Bezier)> pool.iter_mut().find_map(|e| e.take()) } -// Find and remove the segment whose start point is closest to `end` within the -// appropriate threshold. Same-source segments use a tight threshold -// (INTERSECT_THRESHOLD_SAMEd) so we prefer staying on the same original path; -// cross-source segments use a wider threshold (INTERSECT_THRESHOLD_DIFFERENT) -// to allow switching paths at intersection points. +// Same-source candidates get a tighter threshold so we stay on the original path. A +// candidate that joins by its `end` points the wrong way, so we reverse it. fn find_next_in_pool( pool: &mut BezierPool, end: DVec2, source: BezierSource, ) -> Option<(BezierSource, Bezier)> { - let mut best_idx: Option<usize> = None; + let mut best: Option<(usize, bool)> = None; let mut best_dist_sq = f64::MAX; for (i, entry) in pool.iter().enumerate() { @@ -327,16 +360,21 @@ fn find_next_in_pool( } else { INTERSECT_THRESHOLD_DIFFERENT as f64 }; - let dx = bezier.start.x - end.x; - let dy = bezier.start.y - end.y; - let dist_sq = dx * dx + dy * dy; - if dist_sq <= threshold * threshold && dist_sq < best_dist_sq { - best_dist_sq = dist_sq; - best_idx = Some(i); + for (reversed, point) in [(false, bezier.start), (true, bezier.end)] { + let dx = point.x - end.x; + let dy = point.y - end.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq <= threshold * threshold && dist_sq < best_dist_sq { + best_dist_sq = dist_sq; + best = Some((i, reversed)); + } } } - best_idx.and_then(|i| pool[i].take()) + let (idx, reversed) = best?; + pool[idx] + .take() + .map(|(src, bezier)| (src, if reversed { bezier.reverse() } else { bezier })) } fn push_bezier(result: &mut Vec<Segment>, bezier: &Bezier) { @@ -410,32 +448,45 @@ fn beziers_to_segments(beziers: &[(BezierSource, Bezier)]) -> Vec<Segment> { result } -pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path { - if children_ids.is_empty() { - return Path::default(); +fn bool_beziers( + bool_type: BoolType, + path_a: &Path, + a_is_clockwise: bool, + path_b: &Path, +) -> (Vec<(BezierSource, Bezier)>, bool) { + let (segs_a, mut segs_b) = split_segments(path_a, path_b); + + if should_reverse_b(bool_type, a_is_clockwise, path_b) { + for segment in segs_b.iter_mut() { + *segment = segment.reverse(); + } } - let Some(child) = shapes.get(&children_ids[children_ids.len() - 1]) else { + let beziers = match bool_type { + BoolType::Union => union(path_a, segs_a, path_b, segs_b), + BoolType::Difference => difference(path_a, segs_a, path_b, segs_b), + BoolType::Intersection => intersection(path_a, segs_a, path_b, segs_b), + BoolType::Exclusion => exclusion(segs_a, segs_b), + }; + + (beziers, path_a.is_even_odd() || path_b.is_even_odd()) +} + +// Fold `paths` left to right; the first entry is the base operand. +fn bool_fold(bool_type: BoolType, paths: &[Path]) -> Path { + let Some((first, rest)) = paths.split_first() else { return Path::default(); }; - let mut current_path = child.to_path(shapes); + let mut current_path = first.clone(); + // Every fold step chains A's fragments, which keep their direction, so the + // accumulated path keeps this winding. Carry it instead of re-reading it from the + // emitted segment list, whose subpath order and direction fall out of pool ordering. + let is_clockwise_a = is_clockwise(¤t_path); - for idx in (0..children_ids.len() - 1).rev() { - let Some(other) = shapes.get(&children_ids[idx]) else { - continue; - }; - let other_path = other.to_path(shapes); - - let (segs_a, segs_b) = split_segments(¤t_path, &other_path); - - let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd(); - let beziers = match bool_type { - BoolType::Union => union(¤t_path, segs_a, &other_path, segs_b), - BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b), - BoolType::Intersection => intersection(¤t_path, segs_a, &other_path, segs_b), - BoolType::Exclusion => exclusion(segs_a, segs_b), - }; + for other_path in rest { + let (beziers, is_even_odd) = + bool_beziers(bool_type, ¤t_path, is_clockwise_a, other_path); current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd); } @@ -443,6 +494,16 @@ pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: Shap current_path } +pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path { + let paths: Vec<Path> = children_ids + .iter() + .rev() + .filter_map(|id| shapes.get(id).map(|child| child.to_path(shapes))) + .collect(); + + bool_fold(bool_type, &paths) +} + pub fn update_bool_to_path(shape: &mut Shape, shapes: ShapesPoolRef) { let children_ids = shape.children_ids(true); @@ -481,6 +542,7 @@ pub fn debug_render_bool_paths( }; let mut current_path = child.to_path(shapes); + let is_clockwise_a = is_clockwise(¤t_path); for idx in (0..children_ids.len() - 1).rev() { let Some(other) = shapes.get(&children_ids[idx]) else { @@ -488,15 +550,12 @@ pub fn debug_render_bool_paths( }; let other_path = other.to_path(shapes); - let (segs_a, segs_b) = split_segments(¤t_path, &other_path); - - let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd(); - let beziers = match bool_data.bool_type { - BoolType::Union => union(¤t_path, segs_a, &other_path, segs_b), - BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b), - BoolType::Intersection => intersection(¤t_path, segs_a, &other_path, segs_b), - BoolType::Exclusion => exclusion(segs_a, segs_b), - }; + let (beziers, is_even_odd) = bool_beziers( + bool_data.bool_type, + ¤t_path, + is_clockwise_a, + &other_path, + ); current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd); if idx == 0 { @@ -572,3 +631,210 @@ pub fn debug_render_bool_paths( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn linear(from: (f64, f64), to: (f64, f64)) -> Bezier { + Bezier::from_linear_coordinates(from.0, from.1, to.0, to.1) + } + + fn polygon(points: &[(f32, f32)]) -> Path { + let mut segments = vec![Segment::MoveTo(points[0])]; + segments.extend(points[1..].iter().map(|p| Segment::LineTo(*p))); + segments.push(Segment::Close); + Path::new(segments) + } + + fn count(segments: &[Segment], f: fn(&Segment) -> bool) -> usize { + segments.iter().filter(|s| f(s)).count() + } + + fn is_move_to(s: &Segment) -> bool { + matches!(s, Segment::MoveTo(_)) + } + + fn is_close(s: &Segment) -> bool { + matches!(s, Segment::Close) + } + + fn ring_area(ring: &[(f32, f32)]) -> f64 { + let mut area = 0.0f64; + for i in 0..ring.len() { + let (x1, y1) = ring[i]; + let (x2, y2) = ring[(i + 1) % ring.len()]; + area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1); + } + area / 2.0 + } + + fn signed_area(segments: &[Segment]) -> f64 { + let mut total = 0.0f64; + let mut ring: Vec<(f32, f32)> = Vec::new(); + + for segment in segments { + match *segment { + Segment::MoveTo(p) => { + total += ring_area(&ring); + ring.clear(); + ring.push(p); + } + Segment::LineTo(p) | Segment::CurveTo((_, _, p)) => ring.push(p), + Segment::Close => { + total += ring_area(&ring); + ring.clear(); + } + } + } + + total + ring_area(&ring) + } + + // Operands and expected results taken from the CLJS bool (`app.common.types.path.bool`) + // run on the same shapes: A clockwise, B and C counter-clockwise, all overlapping. + const A_CW: [(f32, f32); 4] = [ + (100.0, 100.0), + (300.0, 100.0), + (300.0, 300.0), + (100.0, 300.0), + ]; + const B_CCW: [(f32, f32); 4] = [ + (200.0, 200.0), + (200.0, 400.0), + (400.0, 400.0), + (400.0, 200.0), + ]; + const C_CCW: [(f32, f32); 4] = [(60.0, 240.0), (60.0, 360.0), (260.0, 360.0), (260.0, 240.0)]; + + #[test] + fn test_is_clockwise() { + let cw = Path::new(vec![ + Segment::MoveTo((0.0, 0.0)), + Segment::LineTo((10.0, 0.0)), + Segment::LineTo((10.0, 10.0)), + Segment::LineTo((0.0, 10.0)), + Segment::Close, + ]); + assert!(is_clockwise(&cw)); + + let ccw = Path::new(vec![ + Segment::MoveTo((0.0, 0.0)), + Segment::LineTo((0.0, 10.0)), + Segment::LineTo((10.0, 10.0)), + Segment::LineTo((10.0, 0.0)), + Segment::Close, + ]); + assert!(!is_clockwise(&ccw)); + } + + #[test] + fn test_should_reverse_b_only_depends_on_relative_winding() { + let cw = Path::new(vec![ + Segment::MoveTo((0.0, 0.0)), + Segment::LineTo((10.0, 0.0)), + Segment::LineTo((10.0, 10.0)), + Segment::LineTo((0.0, 10.0)), + Segment::Close, + ]); + let ccw = Path::new(vec![ + Segment::MoveTo((0.0, 0.0)), + Segment::LineTo((0.0, 10.0)), + Segment::LineTo((10.0, 10.0)), + Segment::LineTo((10.0, 0.0)), + Segment::Close, + ]); + + assert!(should_reverse_b(BoolType::Difference, true, &cw)); + assert!(!should_reverse_b(BoolType::Difference, true, &ccw)); + assert!(!should_reverse_b(BoolType::Union, true, &cw)); + assert!(should_reverse_b(BoolType::Union, true, &ccw)); + } + + // Fragments from #11482: two point the wrong way, so joining them start-to-start only + // left five open subpaths. + #[test] + fn test_beziers_to_segments_closes_reversed_fragments() { + let beziers = vec![ + ( + BezierSource::A, + linear((2764.00, -240.00), (2834.74, -110.71)), + ), + ( + BezierSource::A, + linear((2809.29, -85.26), (2693.26, -201.29)), + ), + ( + BezierSource::A, + linear((2718.71, -226.74), (2764.00, -240.00)), + ), + ( + BezierSource::B, + linear((2718.71, -226.74), (2834.74, -110.71)), + ), + ( + BezierSource::B, + linear((2809.29, -85.26), (2693.26, -201.29)), + ), + ]; + + let segments = beziers_to_segments(&beziers); + + let moves = segments + .iter() + .filter(|s| matches!(s, Segment::MoveTo(_))) + .count(); + let closes = segments + .iter() + .filter(|s| matches!(s, Segment::Close)) + .count(); + + assert_eq!(moves, 2); + assert_eq!(closes, 2); + // 3 fragments in the first subpath, 2 in the second, each dropping its closing LineTo. + assert_eq!(segments.len(), 7); + } + + // #11482: A clockwise, B counter-clockwise. Reference (CLJS): + // M100,100 L300,100 L300,200 L200,200 L200,300 L100,300 Z + #[test] + fn test_difference_with_opposite_winding_operand() { + let result = bool_fold(BoolType::Difference, &[polygon(&A_CW), polygon(&B_CCW)]); + let segments = result.segments(); + + assert_eq!(count(segments, is_move_to), 1); + assert_eq!(count(segments, is_close), 1); + assert!((signed_area(segments) - 30000.0).abs() < 1.0); + assert!(is_clockwise(&result)); + } + + // Reference (CLJS): + // M100,100 L300,100 L300,200 L400,200 L400,400 L200,400 L200,300 L100,300 Z + #[test] + fn test_union_with_opposite_winding_operand() { + let result = bool_fold(BoolType::Union, &[polygon(&A_CW), polygon(&B_CCW)]); + let segments = result.segments(); + + assert_eq!(count(segments, is_move_to), 1); + assert_eq!(count(segments, is_close), 1); + assert!((signed_area(segments) - 70000.0).abs() < 1.0); + assert!(is_clockwise(&result)); + } + + // The second fold step must compare against A's winding, not against the winding of + // the intermediate path, whose subpath order and direction fall out of pool ordering. + // Reference (CLJS): M100,100 L300,100 L300,200 L200,200 L200,240 L100,240 Z + #[test] + fn test_difference_folds_three_opposite_winding_operands() { + let result = bool_fold( + BoolType::Difference, + &[polygon(&A_CW), polygon(&B_CCW), polygon(&C_CCW)], + ); + let segments = result.segments(); + + assert_eq!(count(segments, is_move_to), 1); + assert_eq!(count(segments, is_close), 1); + assert!((signed_area(segments) - 24000.0).abs() < 1.0); + assert!(is_clockwise(&result)); + } +} diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 64767b29bb..c2ee801c41 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -720,7 +720,12 @@ impl RenderState { /// Renders background blur effect directly to the given target surface. /// Must be called BEFORE any save_layer for the shape's own opacity/blend, /// so that the backdrop blur is independent of the shape's visual properties. - fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) { + fn render_background_blur( + &mut self, + shape: &Shape, + clip_bounds: Option<&ClipStack>, + target_surface: SurfaceId, + ) { if self.options.is_fast_mode() { return; } @@ -760,8 +765,14 @@ impl RenderState { matrix.post_translate(center); matrix.pre_translate(-center); + self.surfaces.canvas(target_surface).save(); + + if let Some(clips) = clip_bounds { + let antialias = shape.should_use_antialias(scale, self.options.antialias_threshold); + self.clip_target_surface_to_stack(clips, target_surface, scale, antialias); + } + let canvas = self.surfaces.canvas(target_surface); - canvas.save(); // Current/Export have no render context transform (identity canvas). // Apply scale + translate + shape transform so the clip maps @@ -3804,7 +3815,7 @@ impl RenderState { // Render background blur BEFORE save_layer so it modifies // the backdrop independently of the shape's opacity. if !node_render_state.is_root() && self.focus_mode.is_active() { - self.render_background_blur(element, target_surface); + self.render_background_blur(element, clip_bounds.as_ref(), target_surface); } self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface); diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index bebd20f7c6..ac740b064d 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -3,7 +3,7 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::error::Result; use crate::get_resources; -use crate::render::get_source_rect; +use crate::render::{get_image_dest_rect, get_source_rect}; use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; // Set the clipping area to the shape outline within the container bounds @@ -91,11 +91,12 @@ fn draw_image_fill( let size = image.dimensions(); let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); let container = &shape.selrect; - - let src_rect = get_source_rect(size, container, image_fill); - let dest_rect = container; let sampling = get_resources().sampling_options; + let dest_rect = get_image_dest_rect(container, image_fill); + let src_rect = get_source_rect(size, &dest_rect, image_fill); + let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape); + // `save_layer` is only required when a shape-level image filter (blur) must // run over the clipped image. Otherwise a plain save/clip (or no clip for // axis-aligned rects) avoids an offscreen buffer per fill — the hot path @@ -121,7 +122,7 @@ fn draw_image_fill( let mut draw_paint = paint.clone(); draw_paint.set_anti_alias(antialias); - if is_axis_aligned_image_rect(shape) { + if !needs_clip { canvas.draw_image_rect_with_sampling_options( image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), @@ -163,10 +164,6 @@ fn draw_svg_image_fill( let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); let container = &shape.selrect; let size = skia::ISize::new(size.width as i32, size.height as i32); - let src_rect = get_source_rect(size, container, image_fill); - if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { - return true; - } let mut image_paint = skia::Paint::default(); image_paint.set_anti_alias(antialias); @@ -183,16 +180,22 @@ fn draw_svg_image_fill( let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint); canvas.save_layer(&fill_layer); - // Map the cropped source rect onto the container: cover semantics when - // keep-aspect-ratio is set, stretch otherwise (same math as the raster - // path, expressed as a canvas transform). - let scale_x = container.width() / src_rect.width(); - let scale_y = container.height() / src_rect.height(); + let dest_rect = get_image_dest_rect(container, image_fill); + let src_rect = get_source_rect(size, &dest_rect, image_fill); + if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { + canvas.restore(); + canvas.restore(); + return true; + } + + let scale_x = dest_rect.width() / src_rect.width(); + let scale_y = dest_rect.height() / src_rect.height(); canvas.translate(( - container.left - src_rect.left * scale_x, - container.top - src_rect.top * scale_y, + dest_rect.left - src_rect.left * scale_x, + dest_rect.top - src_rect.top * scale_y, )); canvas.scale((scale_x, scale_y)); + dom.render(canvas); canvas.restore(); diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 79b5e5653a..4b1ce66754 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -20,6 +20,18 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect { ) } +pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect { + match image_fill.transform() { + Some(tf) => MathRect::from_xywh( + container.left + tf.x * container.width(), + container.top + tf.y * container.height(), + tf.width * container.width(), + tf.height * container.height(), + ), + None => *container, + } +} + pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect { let image_width = size.width as f32; let image_height = size.height as f32; @@ -82,6 +94,9 @@ pub struct ImageStore { tick: Cell<u64>, /// gpu-only context: Option<Box<DirectContext>>, + /// Source URL registered when the image was fetched (SVG export references + /// this in linked `<image>` elements). + source_urls: HashMap<Uuid, String>, } /// Creates a Skia image from an existing WebGL texture. @@ -227,6 +242,7 @@ impl ImageStore { total_bytes: 0, tick: Cell::new(0), context: Some(Box::new(context.clone())), + source_urls: HashMap::new(), } } @@ -239,6 +255,7 @@ impl ImageStore { total_bytes: 0, tick: Cell::new(0), context: None, + source_urls: HashMap::new(), } } @@ -476,4 +493,14 @@ impl ImageStore { None } } + + pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) { + if !url.is_empty() { + self.source_urls.insert(id, url); + } + } + + pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> { + self.source_urls.get(id).map(String::as_str) + } } diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 083c08f920..a36b9ee741 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -1,6 +1,6 @@ use skia_safe::{self as skia, Paint}; -use crate::shapes::{Shape, Type}; +use crate::shapes::{radius_to_sigma, Shape, Type}; use crate::state::ShapesPoolRef; use crate::render::vector::draw_shape_geometry; @@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry; /// Accumulates the SVG document body while drawing. pub(crate) struct SvgLayerCanvas { - pub(super) scale: f32, + scale: f32, page_rect: skia::Rect, tx: f32, ty: f32, @@ -97,6 +97,28 @@ impl SvgLayerCanvas { self.out.push_str("</g>"); } + /// Appends raw SVG markup to the body (flushes any pending Skia fragment first). + pub(super) fn push_raw(&mut self, markup: &str) { + self.flush(); + self.out.push_str(markup); + } + + /// CTM for leaf content placed in page space: Scale * Translate * Centered. + pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String { + let mut ctm = skia::Matrix::scale((self.scale, self.scale)); + ctm = ctm * skia::Matrix::translate((self.tx, self.ty)); + ctm = ctm * shape.centered_transform(); + format!( + "matrix({} {} {} {} {} {})", + ctm.scale_x(), + ctm.skew_y(), + ctm.skew_x(), + ctm.scale_y(), + ctm.translate_x(), + ctm.translate_y() + ) + } + /// Emits a `<clipPath>` from a shape's geometry (in device/page space). /// /// A mask can be a group too. Since a group has no geometry of its own, we @@ -114,17 +136,40 @@ impl SvgLayerCanvas { } /// Finalizes a fragment canvas as a `<clipPath>` def. + /// + /// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd + /// stroke rings would otherwise fill solid. pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { let data = canvas.end(); let doc = String::from_utf8_lossy(data.as_bytes()); let inner = extract_inner_svg(&doc); let prefix = format!("f{}_", self.frag_no); self.frag_no += 1; - let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)); + let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)) + .replace("fill-rule=", "clip-rule="); self.defs.push_str(&format!( "<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>" )); } + + /// Registers a layer-blur `<filter>` and returns its id. + /// + /// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`). + /// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs. + pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String { + let id = self.unique("blur"); + self.defs.push_str(&format!( + concat!( + "<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ", + "color-interpolation-filters=\"sRGB\">", + "<feGaussianBlur stdDeviation=\"{sigma}\"/>", + "</filter>" + ), + id = id, + sigma = sigma + )); + id + } } /// Draws a clip geometry into `cv` (already set up with the page transform). @@ -145,11 +190,11 @@ fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, pai } /// Builds the `<g>` attribute string for a shape's composite effects (opacity, -/// blend mode). Returns `None` when the shape needs no wrapper. +/// blend mode, layer blur). Returns `None` when the shape needs no wrapper. /// -/// Layer blur / shadows are intentionally omitted here — they need native SVG -/// filter re-emission to survive `SkSVGDevice` and land in later PRs. -pub(super) fn effect_attrs(element: &Shape) -> Option<String> { +/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters). +/// Shadows still need dedicated re-emission in a later PR. +pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> { let mut parts: Vec<String> = Vec::new(); let opacity = element.opacity(); @@ -161,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option<String> { parts.push(format!("style=\"mix-blend-mode:{css}\"")); } + if let Some(blur) = element.visible_layer_blur() { + // Match canvas `Shape::image_filter`: sigma from radius × export scale. + let sigma = radius_to_sigma(blur.value * builder.scale); + let id = builder.push_layer_blur_filter(sigma); + parts.push(format!("filter=\"url(#{id})\"")); + } + if parts.is_empty() { None } else { diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index 46729b94b0..e97c39c36f 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -5,8 +5,9 @@ use skia_safe as skia; use crate::globals::TestRenderResourcesGuard; use crate::render::{FontStore, RenderResources}; use crate::shapes::{ - Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign, - TextContent, TextDirection, TextSpan, Type, + Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment, + SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan, + Type, }; use crate::state::ShapesPool; use crate::utils::uuid_from_u32_quartet; @@ -17,6 +18,10 @@ use super::render_tree_to_svg; /// Font URL referenced in exported SVG `@font-face` rules. pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; +/// Media URL referenced by linked `<image href>` fills in SVG export tests. +/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`. +pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg"; + fn register_test_font_urls(fonts: &mut FontStore) { let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string()); @@ -27,6 +32,31 @@ pub(super) fn uid(n: u32) -> Uuid { uuid_from_u32_quartet(0, 0, 0, n) } +/// Adds a rectangle filled with a linked image (must call `render_with` / register URL). +pub(super) fn add_image_rect( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + image_id: Uuid, + keep_aspect_ratio: bool, + opacity: u8, +) { + add_rect_with_fills( + pool, + id, + parent, + (l, t, r, b), + vec![Fill::Image(ImageFill::new( + image_id, + opacity, + 200, + 100, + keep_aspect_ratio, + ))], + ); +} + /// Adds a solid-filled rectangle to the pool. pub(super) fn add_solid_rect( pool: &mut ShapesPool, @@ -67,15 +97,101 @@ pub(super) fn add_frame( (l, t, r, b): (f32, f32, f32, f32), color: skia::Color, clip: bool, +) { + add_frame_with_fills( + pool, + id, + parent, + (l, t, r, b), + vec![Fill::Solid(SolidColor(color))], + clip, + ); +} + +fn add_frame_with_fills( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + fills: Vec<Fill>, + clip: bool, ) { let shape = pool.add_shape(id); shape.set_parent(parent); shape.set_shape_type(Type::Frame(Frame::default())); shape.set_selrect(l, t, r, b); - shape.set_fills(vec![Fill::Solid(SolidColor(color))]); + shape.set_fills(fills); shape.set_clip(clip); } +/// Frame whose background is a linked image fill. +pub(super) fn add_image_frame( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + image_id: Uuid, + clip: bool, +) { + add_frame_with_fills( + pool, + id, + parent, + (l, t, r, b), + vec![test_image_fill(image_id)], + clip, + ); +} + +fn triangle_segments(closed: bool) -> Vec<Segment> { + let mut segments = vec![ + Segment::MoveTo((10.0, 90.0)), + Segment::LineTo((50.0, 10.0)), + Segment::LineTo((90.0, 90.0)), + ]; + if closed { + segments.push(Segment::Close); + } + segments +} + +fn add_path_with_fills( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + segments: Vec<Segment>, + fills: Vec<Fill>, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Path(Path::new(segments))); + shape.set_selrect(l, t, r, b); + shape.set_fills(fills); +} + +fn test_image_fill(image_id: Uuid) -> Fill { + Fill::Image(ImageFill::new(image_id, 255, 200, 100, true)) +} + +/// Triangle path (open or closed) with a linked image fill. +pub(super) fn add_image_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + closed: bool, + image_id: Uuid, +) { + add_path_with_fills( + pool, + id, + parent, + (0.0, 0.0, 100.0, 100.0), + triangle_segments(closed), + vec![test_image_fill(image_id)], + ); +} + /// Adds an empty (unmasked) group. pub(super) fn add_group( pool: &mut ShapesPool, @@ -93,6 +209,20 @@ pub(super) fn add_group( } } +/// SVG-raw leaf with markup (same form `get-static-markup` uploads to WASM). +pub(super) fn add_svg_raw( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + content: &str, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_svg_raw_content(content.to_string()); + shape.set_selrect(l, t, r, b); +} + /// Adds a single-line text shape using the embedded default font. pub(super) fn add_solid_text( pool: &mut ShapesPool, @@ -153,9 +283,152 @@ pub(super) fn add_text_with_fills( shape.set_shape_type(Type::Text(content)); } +/// Adds a rectangle with a single solid stroke (no fill). +pub(super) fn add_stroked_rect( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + stroke: Stroke, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![]); + shape.add_stroke(stroke); +} + +/// Adds a closed rectangular path with a single solid stroke (no fill). +pub(super) fn add_stroked_closed_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + bounds: (f32, f32, f32, f32), + stroke: Stroke, +) { + add_stroked_path(pool, id, parent, bounds, stroke, true); +} + +/// Adds an open polyline path with a single solid stroke (no fill). +pub(super) fn add_stroked_open_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + bounds: (f32, f32, f32, f32), + stroke: Stroke, +) { + add_stroked_path(pool, id, parent, bounds, stroke, false); +} + +fn add_stroked_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + stroke: Stroke, + closed: bool, +) { + let mut segments = vec![ + Segment::MoveTo((l, t)), + Segment::LineTo((r, t)), + Segment::LineTo((r, b)), + Segment::LineTo((l, b)), + ]; + if closed { + segments.push(Segment::Close); + } + let path = Path::new(segments); + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Path(path)); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![]); + shape.add_stroke(stroke); +} + +pub(super) fn solid_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Solid, width, color) +} + +pub(super) fn dotted_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Dotted, width, color) +} + +pub(super) fn dashed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Dashed, width, color) +} + +pub(super) fn mixed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Mixed, width, color) +} + +fn stroke_with_style( + kind: StrokeKind, + style: StrokeStyle, + width: f32, + color: skia::Color, +) -> Stroke { + let mut stroke = match kind { + StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None), + StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None), + StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None), + }; + stroke.fill = Fill::Solid(SolidColor(color)); + stroke +} + +fn image_stroke(kind: StrokeKind, style: StrokeStyle, width: f32, image_id: Uuid) -> Stroke { + let mut stroke = match kind { + StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None), + StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None), + StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None), + }; + stroke.fill = test_image_fill(image_id); + stroke +} + +pub(super) fn image_solid_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke { + image_stroke(kind, StrokeStyle::Solid, width, image_id) +} + +pub(super) fn image_dotted_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke { + image_stroke(kind, StrokeStyle::Dotted, width, image_id) +} + +/// Text with a linked image fill (register URL via `render_with`). +pub(super) fn add_image_text( + pool: &mut ShapesPool, + id: Uuid, + bounds: (f32, f32, f32, f32), + text: &str, + font_size: f32, + image_id: Uuid, +) { + add_text_with_fills( + pool, + id, + bounds, + text, + font_size, + vec![test_image_fill(image_id)], + ); +} + pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { + render_with(pool, root, |_resources| {}) +} + +/// Like [`render`], but lets the test register extra resources (e.g. image URLs) +/// before export. +pub(super) fn render_with( + pool: &ShapesPool, + root: Uuid, + setup: impl FnOnce(&mut RenderResources), +) -> String { let mut resources = RenderResources::try_new_headless().expect("headless resources"); register_test_font_urls(&mut resources.fonts); + setup(&mut resources); let _guard = TestRenderResourcesGuard::install(&mut resources); let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export"); String::from_utf8(bytes).expect("utf8 svg") diff --git a/render-wasm/src/render/svg/fixtures/test-fill.svg b/render-wasm/src/render/svg/fixtures/test-fill.svg new file mode 100644 index 0000000000..9d7fae920e --- /dev/null +++ b/render-wasm/src/render/svg/fixtures/test-fill.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100"> + <defs> + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0%" stop-color="#2563eb"/> + <stop offset="100%" stop-color="#7c3aed"/> + </linearGradient> + </defs> + <rect width="200" height="100" fill="url(#g)"/> + <circle cx="60" cy="50" r="28" fill="#fbbf24"/> + <rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/> + <text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text> +</svg> diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index fead20bbf4..f4f9f2de9c 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -1,10 +1,9 @@ use crate::error::Result; -use crate::render::shape_renderer::ShapeRenderer; -use crate::render::vector::VectorRenderer; use crate::shapes::{Shape, Stroke}; use crate::state::ShapesPoolRef; use super::document::{effect_attrs, SvgLayerCanvas}; +use super::images::{emit_fills, emit_strokes}; use super::render_tree; use crate::render::RenderResources; @@ -15,9 +14,7 @@ pub(super) fn render_frame( tree: ShapesPoolRef, scale: f32, ) -> Result<()> { - let matrix = element.centered_transform(); - - let effects = effect_attrs(element); + let effects = effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } @@ -29,14 +26,9 @@ pub(super) fn render_frame( builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); } - // Frame background (frame space). + // Frame background (frame space), with linked `<image>` for image fills. if !element.fills.is_empty() { - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - renderer.draw_fills(element, &element.fills)?; - canvas.restore(); + emit_fills(builder, shared, element, &element.fills, tree, scale)?; } // Children (absolute coords). @@ -45,20 +37,19 @@ pub(super) fn render_frame( render_tree(builder, shared, child_id, tree, scale)?; } - // Strokes over children (frame space). - let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); - if !visible_strokes.is_empty() { - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - renderer.draw_strokes(element, &visible_strokes)?; - canvas.restore(); - } - + // Close content clip before strokes. Outer (and half of center) strokes + // extend past the frame bounds; keeping them under clip-path hides them. + // Matches GPU: clipped-frame strokes render in exit without the frame clip. if clipped { builder.close_group(); } + + // Strokes over children (frame space), outside the content clip. + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + emit_strokes(builder, shared, element, &visible_strokes, scale)?; + } + if effects.is_some() { builder.close_group(); } diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs index 5bca7d8876..cd2359baef 100644 --- a/render-wasm/src/render/svg/groups.rs +++ b/render-wasm/src/render/svg/groups.rs @@ -13,7 +13,7 @@ pub(super) fn render_group( tree: ShapesPoolRef, scale: f32, ) -> Result<()> { - let effects = effect_attrs(element); + let effects = effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs new file mode 100644 index 0000000000..e072100758 --- /dev/null +++ b/render-wasm/src/render/svg/images.rs @@ -0,0 +1,201 @@ +use crate::error::Result; +use crate::math::Rect as MathRect; +use crate::render::get_dest_rect; +use crate::render::get_image_dest_rect; +use crate::render::shape_renderer::ShapeRenderer; +use crate::render::vector::{paint_svg_stroke_silhouette, VectorRenderer}; +use crate::shapes::{Fill, ImageFill, Shape, Stroke}; +use crate::state::ShapesPoolRef; + +use super::document::SvgLayerCanvas; +use crate::render::RenderResources; + +/// Emits fills bottom -> top for SVG export. +/// +/// Non-image fills go through Skia's SVG canvas. Image fills with a registered +/// source URL become native linked `<image>` elements (see `store_image_url`); +/// without a URL they fall back to Skia (base64-embed) when a CPU image exists. +pub(super) fn emit_fills( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + shape: &Shape, + fills: &[Fill], + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + if fills.is_empty() { + return Ok(()); + } + + // fills[0] is the topmost layer; draw bottom → top. + for fill in fills.iter().rev() { + match fill { + Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => { + emit_image_fill(builder, shared, shape, image_fill, tree)?; + } + fill => { + let matrix = shape.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_fills(shape, std::slice::from_ref(fill))?; + canvas.restore(); + } + } + } + Ok(()) +} + +/// Emits a linked SVG `<image>` clipped to the shape geometry. +/// +/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit +/// a native `<image href="...">` instead so the export stays linked to the +/// registered media URL (see `store_image_url`). +fn emit_image_fill( + builder: &mut SvgLayerCanvas, + shared: &RenderResources, + shape: &Shape, + image_fill: &ImageFill, + tree: ShapesPoolRef, +) -> Result<()> { + let Some(url) = shared.images.source_url(&image_fill.id()) else { + return Ok(()); + }; + + let clip_id = builder.unique("imgclip"); + builder.push_clip_path(&clip_id, shape, tree); + let href = xml_escape_attr(url); + let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill); + emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id); + Ok(()) +} + +/// Emits strokes bottom -> top for SVG export. +/// +/// Image strokes with a registered URL become a linked `<image>` clipped to the +/// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes +/// go through [`VectorRenderer`]. +pub(super) fn emit_strokes( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + shape: &Shape, + strokes: &[&Stroke], + scale: f32, +) -> Result<()> { + if strokes.is_empty() { + return Ok(()); + } + + let matrix = shape.centered_transform(); + // strokes[0] is topmost; draw bottom -> top. + for stroke in strokes.iter().rev() { + match &stroke.fill { + Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => { + emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?; + } + _ => { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_strokes(shape, std::slice::from_ref(stroke))?; + canvas.restore(); + } + } + } + Ok(()) +} + +/// Linked `<image>` clipped to the stroke outline (opaque filled path). +fn emit_image_stroke( + builder: &mut SvgLayerCanvas, + shared: &RenderResources, + shape: &Shape, + stroke: &Stroke, + image_fill: &ImageFill, + scale: f32, +) -> Result<()> { + let Some(url) = shared.images.source_url(&image_fill.id()) else { + return Ok(()); + }; + + let clip_id = builder.unique("imgstrokeclip"); + let canvas = builder.new_fragment(); + { + let cv: &skia_safe::Canvas = &canvas; + cv.save(); + cv.concat(&shape.centered_transform()); + if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) { + cv.restore(); + return Ok(()); + } + cv.restore(); + } + builder.finish_clip_path_fragment(&clip_id, canvas); + + let href = xml_escape_attr(url); + let dest = image_stroke_dest_rect(shape, stroke); + emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id); + Ok(()) +} + +/// Where to place the linked image for an image-filled stroke. +/// +/// Starts from the same dest as the GPU path (`selrect` + `stroke.delta()`), then +/// grows on open paths so marker caps are still covered by the `<image>`. +fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect { + let mut dest = get_dest_rect(&shape.selrect(), stroke.delta()); + if !shape.is_open() { + return dest; + } + let cap_margin = stroke.cap_bounds_margin(); + if cap_margin <= 0.0 { + return dest; + } + let mut with_caps = shape.selrect(); + with_caps.inset((-cap_margin, -cap_margin)); + dest.join(with_caps); + dest +} + +/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM. +pub(super) fn emit_linked_image_element( + builder: &mut SvgLayerCanvas, + shape: &Shape, + image_fill: &ImageFill, + dest_rect: MathRect, + href: &str, + clip_id: &str, +) { + let opacity = image_fill.opacity() as f32 / 255.0; + let preserve = if image_fill.keep_aspect_ratio() { + "xMidYMid slice" + } else { + "none" + }; + let transform = builder.page_shape_matrix_attr(shape); + + let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON { + String::new() + } else { + format!(r#" opacity="{opacity}""#) + }; + + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + builder.push_raw(&format!( + r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#, + dest_rect.left(), + dest_rect.top(), + dest_rect.width(), + dest_rect.height(), + )); + builder.close_group(); +} + +pub(super) fn xml_escape_attr(s: &str) -> String { + s.replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index e992b1ad66..e3dde7cf84 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -8,7 +8,8 @@ use crate::shapes::{Shape, Type}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; -use super::vector::{render_leaf_content, VectorRenderer}; +use super::shape_renderer::ShapeRenderer; +use super::vector::VectorRenderer; use super::RenderResources; /// Collects the registered font aliases used by every text span in the subtree @@ -59,8 +60,10 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native /// `<clipPath>`. /// -/// Special-case re-emission for shadows, layer blur, masks, text strokes, and -/// deferred strokes is intentionally out of scope for this cut. +/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper. +/// Shadows, masks, and text strokes still need dedicated SVG re-emission. +/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines; +/// image-filled strokes use a linked `<image>` clipped to the stroke. pub fn render_to_svg( shared: &mut RenderResources, id: &Uuid, @@ -124,6 +127,7 @@ pub(crate) fn render_tree_to_svg( mod document; mod frames; mod groups; +mod images; mod text; use document::SvgLayerCanvas; @@ -132,6 +136,7 @@ use groups::render_group; use text::render_text_fill; use document::effect_attrs; +use images::{emit_fills, emit_strokes}; /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. fn render_body( @@ -171,7 +176,7 @@ fn render_tree( | Type::Path(_) | Type::Bool(_) | Type::Text(_) - | Type::SVGRaw(_) => render_leaf(builder, shared, element, scale), + | Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale), } } @@ -179,24 +184,50 @@ fn render_leaf( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, element: &Shape, + tree: ShapesPoolRef, scale: f32, ) -> Result<()> { - let effects = effect_attrs(element); + let effects = effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } { if matches!(element.shape_type, Type::Text(_)) { - render_text_fill(builder, element)?; - } else { + render_text_fill(builder, shared, element)?; + } else if matches!(element.shape_type, Type::SVGRaw(_)) { let matrix = element.centered_transform(); let canvas = builder.canvas(); canvas.save(); canvas.concat(&matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - render_leaf_content(&mut renderer, element)?; + renderer.draw_svg(element)?; canvas.restore(); + } else { + emit_fills(builder, shared, element, &element.fills, tree, scale)?; + + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_fill_inner_shadows(element)?; + canvas.restore(); + + let visible_strokes: Vec<_> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + emit_strokes(builder, shared, element, &visible_strokes, scale)?; + if !element.has_fills() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + for stroke in &visible_strokes { + renderer.draw_stroke_inner_shadows(element, stroke)?; + } + canvas.restore(); + } + } } } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..2ef3bd3c17 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap @@ -0,0 +1,12 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="124" viewBox="0 0 164 124"><defs><clipPath id="clip0" clipPathUnits="userSpaceOnUse"> + <rect transform="translate(12 12)" width="140" height="100"/> +</clipPath></defs><g clip-path="url(#clip0)"> + <rect fill="#EEE" transform="translate(12 12)" width="140" height="100"/> +</g> + <path fill="#1040FF" transform="translate(12 12)" d="M-12 -12L152 -12L152 112L-12 112L-12 -12ZM140 0L0 0L0 100L140 100L140 0Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap new file mode 100644 index 0000000000..070cd8441d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <path fill="green" d="M18 0L0 0L0 8L18 8L18 0ZM0 36L0 18L8 18L8 36L0 36ZM0 72L0 54L8 54L8 72L0 72ZM28 80L10 80L10 72L28 72L28 80ZM64 80L45.999996 80L45.999996 72L64 72L64 80ZM100 80L100 72L82 72L82 80L100 80ZM100 44L100 62L92 62L92 44L100 44ZM100 8L100 26L92 26L92 8L100 8ZM72 0L90 0L90 8L72 8L72 0ZM36 0L54.000004 0L54.000004 8L36 8L36 0Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap new file mode 100644 index 0000000000..d6fa94869e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"> + <path fill="green" transform="translate(8 8)" d="M0 4C2.6666667 4 4 2.6666667 4 0C4 -2.6666667 2.6666667 -4 0 -4C-2.6666667 -4 -4 -2.6666667 -4 0C-4 2.6666667 -2.6666667 4 0 4ZM13 4C15.666667 4 17 2.6666667 17 0C17 -2.6666667 15.666667 -4 13 -4C10.333333 -4 9 -2.6666667 9 0C9 2.6666667 10.333333 4 13 4ZM26 4C28.666666 4 30 2.6666667 30 0C30 -2.6666667 28.666666 -4 26 -4C23.333334 -4 22 -2.6666667 22 0C22 2.6666667 23.333334 4 26 4ZM39 4C41.666668 4 43 2.6666667 43 0C43 -2.6666667 41.666668 -4 39 -4C36.333332 -4 35 -2.6666667 35 0C35 2.6666667 36.333332 4 39 4ZM52 4C54.666668 4 56 2.6666667 56 0C56 -2.6666667 54.666668 -4 52 -4C49.333332 -4 48 -2.6666667 48 0C48 2.6666667 49.333332 4 52 4ZM65 4C67.666664 4 69 2.6666667 69 0C69 -2.6666667 67.666664 -4 65 -4C62.333332 -4 61 -2.6666667 61 0C61 2.6666667 62.333332 4 65 4ZM78 4C80.666664 4 82 2.6666667 82 0C82 -2.6666667 80.666664 -4 78 -4C75.333336 -4 74 -2.6666667 74 0C74 2.6666667 75.333336 4 78 4ZM91 4C93.666664 4 95 2.6666667 95 0C95 -2.6666667 93.666664 -4 91 -4C88.333336 -4 87 -2.6666667 87 0C87 2.6666667 88.333336 4 91 4ZM100 8C102.66666 8 104 6.666667 104 4C104 1.3333333 102.66666 0 100 0C97.333336 0 96 1.3333333 96 4C96 6.666667 97.333336 8 100 8ZM0 13C2.6666667 13 4 11.666667 4 9C4 6.333333 2.6666667 5 0 5C-2.6666667 5 -4 6.333333 -4 9C-4 11.666667 -2.6666667 13 0 13ZM100 21C102.66666 21 104 19.666666 104 17C104 14.333333 102.66666 13 100 13C97.333336 13 96 14.333333 96 17C96 19.666666 97.333336 21 100 21ZM0 26C2.6666667 26 4 24.666666 4 22C4 19.333334 2.6666667 18 0 18C-2.6666667 18 -4 19.333334 -4 22C-4 24.666666 -2.6666667 26 0 26ZM100 34C102.66666 34 104 32.666668 104 30C104 27.333334 102.66666 26 100 26C97.333336 26 96 27.333334 96 30C96 32.666668 97.333336 34 100 34ZM0 39C2.6666667 39 4 37.666668 4 35C4 32.333332 2.6666667 31 0 31C-2.6666667 31 -4 32.333332 -4 35C-4 37.666668 -2.6666667 39 0 39ZM100 47C102.66666 47 104 45.666668 104 43C104 40.333332 102.66666 39 100 39C97.333336 39 96 40.333332 96 43C96 45.666668 97.333336 47 100 47ZM0 52C2.6666667 52 4 50.666668 4 48C4 45.333332 2.6666667 44 0 44C-2.6666667 44 -4 45.333332 -4 48C-4 50.666668 -2.6666667 52 0 52ZM100 60C102.66666 60 104 58.666668 104 56C104 53.333332 102.66666 52 100 52C97.333336 52 96 53.333332 96 56C96 58.666668 97.333336 60 100 60ZM0 65C2.6666667 65 4 63.666668 4 61C4 58.333332 2.6666667 57 0 57C-2.6666667 57 -4 58.333332 -4 61C-4 63.666668 -2.6666667 65 0 65ZM100 73C102.66666 73 104 71.666664 104 69C104 66.333336 102.66666 65 100 65C97.333336 65 96 66.333336 96 69C96 71.666664 97.333336 73 100 73ZM0 78C2.6666667 78 4 76.666664 4 74C4 71.333336 2.6666667 70 0 70C-2.6666667 70 -4 71.333336 -4 74C-4 76.666664 -2.6666667 78 0 78ZM7 84C9.666667 84 11 82.666664 11 80C11 77.333336 9.666667 76 7 76C4.333333 76 3 77.333336 3 80C3 82.666664 4.333333 84 7 84ZM20 84C22.666666 84 24 82.666664 24 80C24 77.333336 22.666666 76 20 76C17.333334 76 16 77.333336 16 80C16 82.666664 17.333334 84 20 84ZM33 84C35.666668 84 37 82.666664 37 80C37 77.333336 35.666668 76 33 76C30.333334 76 29 77.333336 29 80C29 82.666664 30.333334 84 33 84ZM45.999996 84C48.666664 84 49.999996 82.666664 49.999996 80C49.999996 77.333336 48.666664 76 45.999996 76C43.333328 76 41.999996 77.333336 41.999996 80C41.999996 82.666664 43.333328 84 45.999996 84ZM59 84C61.666668 84 63 82.666664 63 80C63 77.333336 61.666668 76 59 76C56.333332 76 55 77.333336 55 80C55 82.666664 56.333332 84 59 84ZM72 84C74.666664 84 76 82.666664 76 80C76 77.333336 74.666664 76 72 76C69.333336 76 68 77.333336 68 80C68 82.666664 69.333336 84 72 84ZM85 84C87.666664 84 89 82.666664 89 80C89 77.333336 87.666664 76 85 76C82.333336 76 81 77.333336 81 80C81 82.666664 82.333336 84 85 84ZM98 84C100.66666 84 102 82.666664 102 80C102 77.333336 100.66666 76 98 76C95.333336 76 94 77.333336 94 80C94 82.666664 95.333336 84 98 84Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap new file mode 100644 index 0000000000..180801df6e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <path fill="blue" d="M100 0L0 0L0 80L100 80L100 0ZM8 9C8 7.7741446 7.7323785 6.6098022 7.197135 5.5069714C8.7744055 7.1689906 10.708694 8 13 8C15.737486 8 17.904152 6.8878965 19.5 4.6636891C21.095848 6.8878965 23.262514 8 26 8C28.737486 8 30.904152 6.8878965 32.5 4.6636891C34.095848 6.8878965 36.262516 8 39 8C41.737484 8 43.904152 6.8878965 45.5 4.6636891C47.095848 6.8878965 49.262516 8 52 8C54.737484 8 56.904152 6.8878965 58.5 4.6636891C60.095848 6.8878965 62.262516 8 65 8C67.737488 8 69.904152 6.8878965 71.5 4.6636891C73.095848 6.8878965 75.262512 8 78 8C80.737488 8 82.904152 6.8878965 84.5 4.6636891C86.095848 6.8878965 88.262512 8 91 8C91.656258 8 92.302719 7.9204535 92.939392 7.7613606C93.525131 8.8608704 94.324104 9.7737494 95.336311 10.5C93.112106 12.095847 92 14.262514 92 17C92 19.737486 93.112106 21.904152 95.336311 23.5C93.112106 25.095848 92 27.262514 92 30C92 32.737484 93.112106 34.904152 95.336311 36.5C93.112106 38.095848 92 40.262516 92 43C92 45.737484 93.112106 47.904152 95.336311 49.5C93.112106 51.095848 92 53.262516 92 56C92 58.737484 93.112106 60.904152 95.336311 62.5C93.112106 64.095848 92 66.262512 92 69C92 70.631767 92.456512 72.123863 93.369545 73.47628C92.642754 73.992149 92.019569 74.61216 91.5 75.336311C89.904152 73.112106 87.737488 72 85 72C82.262512 72 80.095848 73.112106 78.5 75.336311C76.904152 73.112106 74.737488 72 72 72C69.262512 72 67.095848 73.112106 65.5 75.336311C63.904152 73.112106 61.737484 72 59 72C56.262512 72 54.095844 73.112106 52.5 75.336311C50.904152 73.112106 48.737484 72 45.999996 72C43.262512 72 41.095844 73.112106 39.5 75.336311C37.904152 73.112106 35.737484 72 33 72C30.262514 72 28.095848 73.112106 26.5 75.336311C24.904152 73.112106 22.737486 72 20 72C17.262514 72 15.095847 73.112106 13.5 75.336311C12.085551 73.364929 10.170585 72.264732 7.7551012 72.035721C7.276824 70.147453 6.2463531 68.635551 4.6636891 67.5C6.8878965 65.904152 8 63.737488 8 61C8 58.262516 6.8878965 56.095848 4.6636891 54.5C6.8878965 52.904152 8 50.737484 8 48C8 45.262516 6.8878965 43.095848 4.6636891 41.5C6.8878965 39.904152 8 37.737484 8 35C8 32.262516 6.8878965 30.095848 4.6636891 28.5C6.8878965 26.904152 8 24.737486 8 22C8 19.262514 6.8878965 17.095848 4.6636891 15.5C6.8878965 13.904153 8 11.737486 8 9Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap new file mode 100644 index 0000000000..afc467606b --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(16 16)" d="M-8 0C-8 1.6422184 -7.538126 3.1422181 -6.6143785 4.5C-7.538126 5.8577819 -8 7.3577814 -8 9C-8 11.737486 -6.8878965 13.904153 -4.6636891 15.5C-6.8878965 17.095848 -8 19.262514 -8 22C-8 24.737486 -6.8878965 26.904152 -4.6636891 28.5C-6.8878965 30.095848 -8 32.262516 -8 35C-8 37.737484 -6.8878965 39.904152 -4.6636891 41.5C-6.8878965 43.095848 -8 45.262516 -8 48C-8 50.737484 -6.8878965 52.904152 -4.6636891 54.5C-6.8878965 56.095848 -8 58.262516 -8 61C-8 63.737488 -6.8878965 65.904152 -4.6636891 67.5C-6.8878965 69.095848 -8 71.262512 -8 74C-8 78.851593 -5.5850339 81.506355 -0.75510108 81.964279C0.26408672 85.988091 2.8491201 88 7 88C9.7374859 88 11.904153 86.887894 13.5 84.663689C15.095847 86.887894 17.262514 88 20 88C22.737486 88 24.904152 86.887894 26.5 84.663689C28.095848 86.887894 30.262514 88 33 88C35.737484 88 37.904152 86.887894 39.499996 84.663689C41.095844 86.887894 43.262512 88 45.999996 88C48.737484 88 50.904152 86.887894 52.499996 84.663689C54.095844 86.887894 56.262512 88 59 88C61.737484 88 63.904152 86.887894 65.5 84.663689C67.095848 86.887894 69.262512 88 72 88C74.737488 88 76.904152 86.887894 78.5 84.663689C80.095848 86.887894 82.262512 88 85 88C87.737488 88 89.904152 86.887894 91.5 84.663689C93.095848 86.887894 95.262512 88 98 88C103.33334 88 106 85.333336 106 80C106 78.368233 105.54349 76.876137 104.63046 75.52372C106.87682 73.929276 108 71.754707 108 69C108 66.262512 106.88789 64.095848 104.66369 62.5C106.88789 60.904152 108 58.737484 108 56C108 53.262516 106.88789 51.095848 104.66369 49.5C106.88789 47.904152 108 45.737484 108 43C108 40.262516 106.88789 38.095848 104.66369 36.5C106.88789 34.904152 108 32.737484 108 30C108 27.262514 106.88789 25.095848 104.66369 23.5C106.88789 21.904152 108 19.737486 108 17C108 14.262514 106.88789 12.095847 104.66369 10.5C106.88789 8.9041529 108 6.7374859 108 4C108 -1.3333335 105.33334 -4 100 -4C99.343742 -4 98.697281 -3.9204535 98.060608 -3.7613606C96.55526 -6.5871201 94.201721 -8 91 -8C88.262512 -8 86.095848 -6.8878965 84.5 -4.6636891C82.904152 -6.8878965 80.737488 -8 78 -8C75.262512 -8 73.095848 -6.8878965 71.5 -4.6636891C69.904152 -6.8878965 67.737488 -8 65 -8C62.262516 -8 60.095848 -6.8878965 58.5 -4.6636891C56.904152 -6.8878965 54.737484 -8 52 -8C49.262516 -8 47.095848 -6.8878965 45.5 -4.6636891C43.904152 -6.8878965 41.737484 -8 39 -8C36.262516 -8 34.095848 -6.8878965 32.5 -4.6636891C30.904152 -6.8878965 28.737486 -8 26 -8C23.262514 -8 21.095848 -6.8878965 19.5 -4.6636891C17.904152 -6.8878965 15.737486 -8 13 -8C10.262514 -8 8.0958471 -6.8878965 6.5 -4.6636891C4.9041519 -6.8878965 2.7374856 -8 0 -8C-5.3333335 -8 -8 -5.3333335 -8 0ZM100 0L0 0L0 80L100 80L100 0Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-16" y="-16" width="132" height="112" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 16 16)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap new file mode 100644 index 0000000000..b25f12faa4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"> + <path fill="green" transform="translate(16 16)" d="M-8 0C-8 1.6422184 -7.538126 3.1422181 -6.6143785 4.5C-7.538126 5.8577819 -8 7.3577814 -8 9C-8 11.737486 -6.8878965 13.904153 -4.6636891 15.5C-6.8878965 17.095848 -8 19.262514 -8 22C-8 24.737486 -6.8878965 26.904152 -4.6636891 28.5C-6.8878965 30.095848 -8 32.262516 -8 35C-8 37.737484 -6.8878965 39.904152 -4.6636891 41.5C-6.8878965 43.095848 -8 45.262516 -8 48C-8 50.737484 -6.8878965 52.904152 -4.6636891 54.5C-6.8878965 56.095848 -8 58.262516 -8 61C-8 63.737488 -6.8878965 65.904152 -4.6636891 67.5C-6.8878965 69.095848 -8 71.262512 -8 74C-8 78.851593 -5.5850339 81.506355 -0.75510108 81.964279C0.26408672 85.988091 2.8491201 88 7 88C9.7374859 88 11.904153 86.887894 13.5 84.663689C15.095847 86.887894 17.262514 88 20 88C22.737486 88 24.904152 86.887894 26.5 84.663689C28.095848 86.887894 30.262514 88 33 88C35.737484 88 37.904152 86.887894 39.499996 84.663689C41.095844 86.887894 43.262512 88 45.999996 88C48.737484 88 50.904152 86.887894 52.499996 84.663689C54.095844 86.887894 56.262512 88 59 88C61.737484 88 63.904152 86.887894 65.5 84.663689C67.095848 86.887894 69.262512 88 72 88C74.737488 88 76.904152 86.887894 78.5 84.663689C80.095848 86.887894 82.262512 88 85 88C87.737488 88 89.904152 86.887894 91.5 84.663689C93.095848 86.887894 95.262512 88 98 88C103.33334 88 106 85.333336 106 80C106 78.368233 105.54349 76.876137 104.63046 75.52372C106.87682 73.929276 108 71.754707 108 69C108 66.262512 106.88789 64.095848 104.66369 62.5C106.88789 60.904152 108 58.737484 108 56C108 53.262516 106.88789 51.095848 104.66369 49.5C106.88789 47.904152 108 45.737484 108 43C108 40.262516 106.88789 38.095848 104.66369 36.5C106.88789 34.904152 108 32.737484 108 30C108 27.262514 106.88789 25.095848 104.66369 23.5C106.88789 21.904152 108 19.737486 108 17C108 14.262514 106.88789 12.095847 104.66369 10.5C106.88789 8.9041529 108 6.7374859 108 4C108 -1.3333335 105.33334 -4 100 -4C99.343742 -4 98.697281 -3.9204535 98.060608 -3.7613606C96.55526 -6.5871201 94.201721 -8 91 -8C88.262512 -8 86.095848 -6.8878965 84.5 -4.6636891C82.904152 -6.8878965 80.737488 -8 78 -8C75.262512 -8 73.095848 -6.8878965 71.5 -4.6636891C69.904152 -6.8878965 67.737488 -8 65 -8C62.262516 -8 60.095848 -6.8878965 58.5 -4.6636891C56.904152 -6.8878965 54.737484 -8 52 -8C49.262516 -8 47.095848 -6.8878965 45.5 -4.6636891C43.904152 -6.8878965 41.737484 -8 39 -8C36.262516 -8 34.095848 -6.8878965 32.5 -4.6636891C30.904152 -6.8878965 28.737486 -8 26 -8C23.262514 -8 21.095848 -6.8878965 19.5 -4.6636891C17.904152 -6.8878965 15.737486 -8 13 -8C10.262514 -8 8.0958471 -6.8878965 6.5 -4.6636891C4.9041519 -6.8878965 2.7374856 -8 0 -8C-5.3333335 -8 -8 -5.3333335 -8 0ZM100 0L0 0L0 80L100 80L100 0Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap new file mode 100644 index 0000000000..0022609790 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"> + <path fill="red" transform="translate(16 16)" d="M0 -8L13 -8L13 0L0 0L0 -8ZM35 0L26 0L26 -8L35 -8L35 0ZM61 0L48 0L48 -8L61 -8L61 0ZM83 0L74 0L74 -8L83 -8L83 0ZM100 31L100 22L108 22L108 31L100 31ZM100 57L100 44L108 44L108 57L100 57ZM100 79L100 70L108 70L108 79L100 79ZM75 80L88 80L88 88L75 88L75 80ZM53 80L62 80L62 88L53 88L53 80ZM27 80L39.999996 80L39.999996 88L27 88L27 80ZM0 37L0 46L-8 46L-8 37L0 37ZM0 11L0 24L-8 24L-8 11L0 11ZM0 59L0 72L-8 72L-8 59L0 59ZM5 80L14 80L14 88L5 88L5 80ZM100 9L100 0L96 0L96 -8L108 -8L108 9L100 9Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap new file mode 100644 index 0000000000..69b2aedb7c --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"> + <path fill="none" stroke="green" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80L0 0Z"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..8144ac1dd2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <path fill="blue" d="M100 0L0 0L0 80L100 80L100 0ZM8 8L8 72L92 72L92 8L8 8Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap new file mode 100644 index 0000000000..22fa54bc00 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(16 16)" d="M-8 -8L108 -8L108 88L-8 88L-8 -8ZM100 0L0 0L0 80L100 80L100 0Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-16" y="-16" width="132" height="112" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 16 16)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..be53344b75 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"> + <path fill="green" transform="translate(16 16)" d="M-8 -8L108 -8L108 88L-8 88L-8 -8ZM100 0L0 0L0 80L100 80L100 0Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap new file mode 100644 index 0000000000..c34757a0ad --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)"> + <rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/> + <rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/> +</g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap new file mode 100644 index 0000000000..972225a8f5 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse"> + <rect width="100" height="80"/> +</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap new file mode 100644 index 0000000000..c33b82fe72 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/> +</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap new file mode 100644 index 0000000000..470af02231 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse"> + <rect width="200" height="120"/> +</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap new file mode 100644 index 0000000000..45ab01f3a2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/> +</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap new file mode 100644 index 0000000000..a982089e5b --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 306 +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse"> + <text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181"> + HOLA + </text> +</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap new file mode 100644 index 0000000000..208edb618a --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)"> + <rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/> +</g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap new file mode 100644 index 0000000000..2e6d8f7918 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse"> + <rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/> +</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g> + <rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap new file mode 100644 index 0000000000..375278612c --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"> + <path fill="red" transform="translate(8 8)" d="M0 4C2.6666667 4 4 2.6666667 4 0C4 -2.6666667 2.6666667 -4 0 -4C-2.6666667 -4 -4 -2.6666667 -4 0C-4 2.6666667 -2.6666667 4 0 4ZM13 4C15.666667 4 17 2.6666667 17 0C17 -2.6666667 15.666667 -4 13 -4C10.333333 -4 9 -2.6666667 9 0C9 2.6666667 10.333333 4 13 4ZM26 4C28.666666 4 30 2.6666667 30 0C30 -2.6666667 28.666666 -4 26 -4C23.333334 -4 22 -2.6666667 22 0C22 2.6666667 23.333334 4 26 4ZM39 4C41.666668 4 43 2.6666667 43 0C43 -2.6666667 41.666668 -4 39 -4C36.333332 -4 35 -2.6666667 35 0C35 2.6666667 36.333332 4 39 4ZM52 4C54.666668 4 56 2.6666667 56 0C56 -2.6666667 54.666668 -4 52 -4C49.333332 -4 48 -2.6666667 48 0C48 2.6666667 49.333332 4 52 4ZM65 4C67.666664 4 69 2.6666667 69 0C69 -2.6666667 67.666664 -4 65 -4C62.333332 -4 61 -2.6666667 61 0C61 2.6666667 62.333332 4 65 4ZM78 4C80.666664 4 82 2.6666667 82 0C82 -2.6666667 80.666664 -4 78 -4C75.333336 -4 74 -2.6666667 74 0C74 2.6666667 75.333336 4 78 4ZM91 4C93.666664 4 95 2.6666667 95 0C95 -2.6666667 93.666664 -4 91 -4C88.333336 -4 87 -2.6666667 87 0C87 2.6666667 88.333336 4 91 4ZM100 8C102.66666 8 104 6.666667 104 4C104 1.3333333 102.66666 0 100 0C97.333336 0 96 1.3333333 96 4C96 6.666667 97.333336 8 100 8ZM100 21C102.66666 21 104 19.666666 104 17C104 14.333333 102.66666 13 100 13C97.333336 13 96 14.333333 96 17C96 19.666666 97.333336 21 100 21ZM100 34C102.66666 34 104 32.666668 104 30C104 27.333334 102.66666 26 100 26C97.333336 26 96 27.333334 96 30C96 32.666668 97.333336 34 100 34ZM100 47C102.66666 47 104 45.666668 104 43C104 40.333332 102.66666 39 100 39C97.333336 39 96 40.333332 96 43C96 45.666668 97.333336 47 100 47ZM100 60C102.66666 60 104 58.666668 104 56C104 53.333332 102.66666 52 100 52C97.333336 52 96 53.333332 96 56C96 58.666668 97.333336 60 100 60ZM100 73C102.66666 73 104 71.666664 104 69C104 66.333336 102.66666 65 100 65C97.333336 65 96 66.333336 96 69C96 71.666664 97.333336 73 100 73ZM7 84C9.666667 84 11 82.666664 11 80C11 77.333336 9.666667 76 7 76C4.333333 76 3 77.333336 3 80C3 82.666664 4.333333 84 7 84ZM20 84C22.666666 84 24 82.666664 24 80C24 77.333336 22.666666 76 20 76C17.333334 76 16 77.333336 16 80C16 82.666664 17.333334 84 20 84ZM33 84C35.666668 84 37 82.666664 37 80C37 77.333336 35.666668 76 33 76C30.333334 76 29 77.333336 29 80C29 82.666664 30.333334 84 33 84ZM45.999996 84C48.666664 84 49.999996 82.666664 49.999996 80C49.999996 77.333336 48.666664 76 45.999996 76C43.333328 76 41.999996 77.333336 41.999996 80C41.999996 82.666664 43.333328 84 45.999996 84ZM59 84C61.666668 84 63 82.666664 63 80C63 77.333336 61.666668 76 59 76C56.333332 76 55 77.333336 55 80C55 82.666664 56.333332 84 59 84ZM72 84C74.666664 84 76 82.666664 76 80C76 77.333336 74.666664 76 72 76C69.333336 76 68 77.333336 68 80C68 82.666664 69.333336 84 72 84ZM85 84C87.666664 84 89 82.666664 89 80C89 77.333336 87.666664 76 85 76C82.333336 76 81 77.333336 81 80C81 82.666664 82.333336 84 85 84ZM98 84C100.66666 84 102 82.666664 102 80C102 77.333336 100.66666 76 98 76C95.333336 76 94 77.333336 94 80C94 82.666664 95.333336 84 98 84Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap new file mode 100644 index 0000000000..8977724407 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 744 +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="344" height="294" viewBox="0 0 344 294"> + <path fill="#1040FF" transform="translate(102 102)" d="M0 6C4 6 6 4 6 0C6 -4 4 -6 0 -6C-4 -6 -6 -4 -6 0C-6 4 -4 6 0 6ZM17 6C21 6 23 4 23 0C23 -4 21 -6 17 -6C13 -6 11 -4 11 0C11 4 13 6 17 6ZM34 6C38 6 40 4 40 0C40 -4 38 -6 34 -6C30 -6 28 -4 28 0C28 4 30 6 34 6ZM51 6C55 6 57 4 57 0C57 -4 55 -6 51 -6C47 -6 45 -4 45 0C45 4 47 6 51 6ZM68 6C72 6 74 4 74 0C74 -4 72 -6 68 -6C64 -6 62 -4 62 0C62 4 64 6 68 6ZM85 6C89 6 91 4 91 0C91 -4 89 -6 85 -6C81 -6 79 -4 79 0C79 4 81 6 85 6ZM102 6C106 6 108 4 108 0C108 -4 106 -6 102 -6C98 -6 96 -4 96 0C96 4 98 6 102 6ZM119 6C123 6 125 4 125 0C125 -4 123 -6 119 -6C115 -6 113 -4 113 0C113 4 115 6 119 6ZM136 6C140 6 142 4 142 0C142 -4 140 -6 136 -6C132 -6 130 -4 130 0C130 4 132 6 136 6ZM140 19C144 19 146 17 146 13.000001C146 9.000001 144 7.000001 140 7.000001C136 7.000001 134 9.000001 134 13.000001C134 17 136 19 140 19ZM140 36C144 36 146 34 146 30C146 26 144 24 140 24C136 24 134 26 134 30C134 34 136 36 140 36ZM140 53C144 53 146 51 146 47C146 43 144 41 140 41C136 41 134 43 134 47C134 51 136 53 140 53ZM140 70C144 70 146 68 146 64C146 60 144 58 140 58C136 58 134 60 134 64C134 68 136 70 140 70ZM140 87C144 87 146 85 146 81C146 77 144 75 140 75C136 75 134 77 134 81C134 85 136 87 140 87ZM13 96C17 96 19 94 19 90C19 86 17 84 13 84C9 84 7 86 7 90C7 94 9 96 13 96ZM30 96C34 96 36 94 36 90C36 86 34 84 30 84C26 84 24 86 24 90C24 94 26 96 30 96ZM47 96C51 96 53 94 53 90C53 86 51 84 47 84C43 84 41 86 41 90C41 94 43 96 47 96ZM64 96C68 96 70 94 70 90C70 86 68 84 64 84C60 84 58 86 58 90C58 94 60 96 64 96ZM81 96C85 96 87 94 87 90C87 86 85 84 81 84C77 84 75 86 75 90C75 94 77 96 81 96ZM98 96C102 96 104 94 104 90C104 86 102 84 98 84C94 84 92 86 92 90C92 94 94 96 98 96ZM115 96C119 96 121 94 121 90C121 86 119 84 115 84C111 84 109 86 109 90C109 94 111 96 115 96ZM132 96C136 96 138 94 138 90C138 86 136 84 132 84C128 84 126 86 126 90C126 94 128 96 132 96Z" fill-rule="evenodd"/> + <path fill="#1040FF" transform="translate(102 102)" d="M-18 0Q-18 0.14729172 -18.007227 0.29440603Q-18.014454 0.44152033 -18.028893 0.58810282Q-18.043329 0.7346853 -18.064941 0.88038278Q-18.086552 1.0260803 -18.115288 1.1705418Q-18.144024 1.3150033 -18.179813 1.4578809Q-18.215601 1.6007583 -18.258358 1.7417078Q-18.301113 1.8826571 -18.350735 2.0213387Q-18.400356 2.1600204 -18.456722 2.2961001Q-18.513088 2.4321799 -18.576065 2.5653303Q-18.63904 2.6984804 -18.708473 2.8283801Q-18.777905 2.9582796 -18.853628 3.0846162Q-18.92935 3.2109523 -19.011181 3.333421Q-19.09301 3.4558895 -19.180752 3.5741954Q-19.268494 3.6925013 -19.361937 3.8063595Q-19.455376 3.9202175 -19.554291 4.0293536Q-19.653206 4.1384897 -19.757359 4.2426405Q-19.861507 4.3467913 -19.970644 4.4457064Q-20.079781 4.5446215 -20.19364 4.6380625Q-20.307497 4.7315035 -20.425802 4.8192449Q-20.544107 4.9069862 -20.666576 4.9888172Q-20.789045 5.0706482 -20.915382 5.1463714Q-21.041719 5.2220945 -21.171619 5.2915277Q-21.301517 5.3609605 -21.434669 5.4239359Q-21.567818 5.4869108 -21.703897 5.5432768Q-21.839977 5.5996428 -21.978659 5.6492639Q-22.11734 5.698885 -22.258291 5.741642Q-22.399239 5.7843981 -22.542118 5.8201871Q-22.684994 5.8559761 -22.829454 5.8847113Q-22.973915 5.9134464 -23.119614 5.9350591Q-23.265312 5.9566712 -23.411896 5.9711084Q-23.558479 5.9855452 -23.705593 5.9927731Q-23.852707 6 -24 6Q-24.147291 6 -24.294407 5.9927726Q-24.441521 5.9855452 -24.588104 5.9711084Q-24.734684 5.9566712 -24.880381 5.9350591Q-25.026077 5.9134464 -25.17054 5.8847113Q-25.315002 5.8559761 -25.457878 5.8201871Q-25.600758 5.7843981 -25.741707 5.741642Q-25.882656 5.698885 -26.021336 5.6492639Q-26.160017 5.5996428 -26.296097 5.5432768Q-26.432177 5.4869108 -26.565327 5.4239354Q-26.698479 5.3609605 -26.828379 5.2915277Q-26.958279 5.2220945 -27.084616 5.1463714Q-27.210953 5.0706482 -27.33342 4.9888172Q-27.455889 4.9069862 -27.574194 4.8192449Q-27.692501 4.7315035 -27.806358 4.6380625Q-27.920216 4.5446215 -28.029352 4.4457064Q-28.138489 4.3467913 -28.242638 4.2426405Q-28.346788 4.1384897 -28.445704 4.0293536Q-28.544621 3.9202175 -28.638062 3.8063595Q-28.731501 3.6925013 -28.819241 3.5741954Q-28.906982 3.4558895 -28.988811 3.333421Q-29.070642 3.2109523 -29.146366 3.0846159Q-29.222088 2.9582796 -29.291523 2.8283801Q-29.360954 2.6984804 -29.423929 2.56533Q-29.486906 2.4321799 -29.543272 2.2961001Q-29.59964 2.1600204 -29.64926 2.0213387Q-29.698881 1.8826571 -29.741638 1.7417079Q-29.784393 1.6007584 -29.820185 1.4578809Q-29.855972 1.3150033 -29.884708 1.1705418Q-29.913445 1.0260803 -29.935055 0.88038278Q-29.956669 0.7346853 -29.971107 0.58810282Q-29.985544 0.44152033 -29.992771 0.29440603Q-30 0.14729172 -30 0Q-30 -0.14729172 -29.992771 -0.29440603Q-29.985544 -0.44152033 -29.971107 -0.58810282Q-29.956669 -0.7346853 -29.935057 -0.88038278Q-29.913445 -1.0260803 -29.884708 -1.1705418Q-29.855972 -1.3150033 -29.820185 -1.4578809Q-29.784393 -1.6007583 -29.741638 -1.7417078Q-29.698881 -1.8826571 -29.64926 -2.0213387Q-29.59964 -2.1600204 -29.543272 -2.2961001Q-29.486906 -2.4321799 -29.423931 -2.5653303Q-29.360954 -2.6984804 -29.291523 -2.8283801Q-29.22209 -2.9582796 -29.146366 -3.0846162Q-29.070644 -3.2109523 -28.988813 -3.333421Q-28.906982 -3.4558895 -28.819241 -3.5741954Q-28.731501 -3.6925013 -28.638062 -3.8063595Q-28.544621 -3.9202175 -28.445705 -4.0293536Q-28.346788 -4.1384897 -28.242638 -4.2426405Q-28.138489 -4.3467913 -28.029354 -4.4457064Q-27.920218 -4.5446215 -27.80636 -4.6380625Q-27.692501 -4.7315035 -27.574194 -4.8192449Q-27.455889 -4.9069862 -27.33342 -4.9888172Q-27.210953 -5.0706482 -27.084614 -5.1463714Q-26.958279 -5.2220945 -26.828381 -5.2915277Q-26.698479 -5.3609605 -26.565329 -5.4239359Q-26.432177 -5.4869108 -26.296097 -5.5432768Q-26.160017 -5.5996428 -26.021336 -5.6492639Q-25.882656 -5.698885 -25.741707 -5.741642Q-25.600758 -5.7843981 -25.45788 -5.8201871Q-25.315002 -5.8559761 -25.17054 -5.8847113Q-25.026077 -5.9134464 -24.880381 -5.9350591Q-24.734684 -5.9566712 -24.588102 -5.9711084Q-24.441521 -5.9855452 -24.294407 -5.9927731Q-24.147291 -6 -24 -6Q-23.852707 -6 -23.705591 -5.9927726Q-23.558479 -5.9855452 -23.411896 -5.9711084Q-23.265312 -5.9566712 -23.119616 -5.9350591Q-22.973919 -5.9134464 -22.829456 -5.8847113Q-22.684994 -5.8559761 -22.542118 -5.8201871Q-22.399239 -5.7843981 -22.258291 -5.741642Q-22.11734 -5.698885 -21.978657 -5.6492639Q-21.839975 -5.5996428 -21.703896 -5.5432768Q-21.567818 -5.4869108 -21.434668 -5.4239354Q-21.301517 -5.3609605 -21.171619 -5.2915277Q-21.041719 -5.2220945 -20.915382 -5.1463714Q-20.789045 -5.0706482 -20.666576 -4.9888172Q-20.544107 -4.9069862 -20.4258 -4.8192449Q-20.307495 -4.7315035 -20.193638 -4.6380625Q-20.079781 -4.5446215 -19.970644 -4.4457064Q-19.861507 -4.3467913 -19.757359 -4.2426405Q-19.653206 -4.1384897 -19.554291 -4.0293536Q-19.455376 -3.9202175 -19.361937 -3.8063595Q-19.268494 -3.6925013 -19.180754 -3.5741954Q-19.093012 -3.4558895 -19.011183 -3.333421Q-18.929352 -3.2109523 -18.85363 -3.0846159Q-18.777906 -2.9582796 -18.708473 -2.8283801Q-18.63904 -2.6984804 -18.576063 -2.56533Q-18.513088 -2.4321799 -18.456722 -2.2961001Q-18.400356 -2.1600204 -18.350737 -2.0213387Q-18.301117 -1.8826571 -18.25836 -1.7417079Q-18.215603 -1.6007584 -18.179813 -1.4578809Q-18.144024 -1.3150033 -18.115288 -1.1705418Q-18.086552 -1.0260803 -18.064941 -0.88038278Q-18.043329 -0.7346853 -18.028893 -0.58810282Q-18.014454 -0.44152033 -18.007227 -0.29440603Q-18 -0.14729172 -18 0ZM-5.9791851 12.020815Q-5.9791851 12.168106 -5.9864125 12.315221Q-5.9936399 12.462336 -6.0080771 12.608918Q-6.0225143 12.755501 -6.044126 12.901198Q-6.0657382 13.046895 -6.0944734 13.191357Q-6.1232085 13.335817 -6.1589975 13.478696Q-6.194787 13.621572 -6.2375436 13.762523Q-6.2802997 13.903472 -6.3299203 14.042152Q-6.3795414 14.180834 -6.4359074 14.316914Q-6.4922733 14.452993 -6.5552492 14.586144Q-6.6182246 14.719296 -6.6876578 14.849195Q-6.7570906 14.979094 -6.8328133 15.105431Q-6.908536 15.231766 -6.9903669 15.354235Q-7.0721979 15.476704 -7.1599398 15.59501Q-7.2476816 15.713316 -7.3411226 15.827173Q-7.4345632 15.941031 -7.5334783 16.050167Q-7.6323934 16.159302 -7.7365446 16.263454Q-7.8406954 16.367603 -7.949831 16.466518Q-8.0589666 16.565434 -8.1728249 16.658876Q-8.2866821 16.752316 -8.4049883 16.840057Q-8.5232944 16.927799 -8.6457634 17.00963Q-8.7682323 17.091461 -8.8945684 17.167187Q-9.0209045 17.242908 -9.1508045 17.31234Q-9.2807045 17.381773 -9.4138546 17.444748Q-9.5470047 17.507723 -9.6830845 17.564089Q-9.8191643 17.620457 -9.9578457 17.670078Q-10.096527 17.7197 -10.237476 17.762455Q-10.378426 17.805212 -10.521303 17.841002Q-10.664181 17.876789 -10.808642 17.905525Q-10.953104 17.934261 -11.098803 17.955872Q-11.244499 17.977486 -11.391083 17.991922Q-11.537664 18.006359 -11.684778 18.013588Q-11.831892 18.020813 -11.979185 18.020815Q-12.126476 18.020813 -12.273592 18.013586Q-12.420706 18.006359 -12.567289 17.991922Q-12.713871 17.977486 -12.859568 17.955872Q-13.005265 17.934261 -13.149727 17.905525Q-13.294188 17.876791 -13.437066 17.841003Q-13.579944 17.805214 -13.720893 17.762457Q-13.861842 17.7197 -14.000524 17.670078Q-14.139205 17.620457 -14.275285 17.564091Q-14.411365 17.507725 -14.544516 17.44475Q-14.677666 17.381775 -14.807566 17.312342Q-14.937466 17.242908 -15.063802 17.167187Q-15.190138 17.091461 -15.312606 17.00963Q-15.435075 16.927799 -15.553381 16.840057Q-15.671686 16.752316 -15.785545 16.658876Q-15.899402 16.565434 -16.008537 16.466518Q-16.117672 16.367603 -16.221825 16.263454Q-16.325977 16.159302 -16.424892 16.050167Q-16.523808 15.941031 -16.617249 15.827174Q-16.71069 15.713316 -16.798429 15.59501Q-16.886171 15.476704 -16.968002 15.354235Q-17.049833 15.231766 -17.125557 15.10543Q-17.201279 14.979094 -17.270712 14.849195Q-17.340143 14.719296 -17.403118 14.586143Q-17.466095 14.452993 -17.522461 14.316914Q-17.578827 14.180834 -17.628448 14.042153Q-17.67807 13.903472 -17.720827 13.762523Q-17.763584 13.621572 -17.799374 13.478696Q-17.835161 13.335817 -17.863895 13.191357Q-17.892632 13.046895 -17.914242 12.901197Q-17.935856 12.755501 -17.950293 12.608918Q-17.964729 12.462336 -17.971956 12.315222Q-17.979183 12.168106 -17.979185 12.020815Q-17.979183 11.873524 -17.971958 11.726409Q-17.964729 11.579296 -17.950294 11.432714Q-17.935856 11.286131 -17.914242 11.140432Q-17.892632 10.994735 -17.863895 10.850273Q-17.835161 10.705812 -17.799374 10.562934Q-17.763584 10.420056 -17.720827 10.279107Q-17.67807 10.138157 -17.628448 9.9994755Q-17.578827 9.8607931 -17.522461 9.7247133Q-17.466095 9.5886345 -17.403118 9.4554844Q-17.340143 9.3223343 -17.270712 9.1924343Q-17.201279 9.0625343 -17.125555 8.9361973Q-17.049833 8.8098612 -16.968002 8.6873932Q-16.886171 8.5649242 -16.798431 8.446619Q-16.71069 8.3283138 -16.617249 8.2144556Q-16.523808 8.1005974 -16.424892 7.9914613Q-16.325977 7.8823252 -16.221825 7.7781739Q-16.117672 7.6740227 -16.008537 7.5751081Q-15.899402 7.4761925 -15.785545 7.3827519Q-15.671686 7.2893105 -15.553379 7.2015686Q-15.435074 7.1138268 -15.312605 7.0319963Q-15.190136 6.9501653 -15.063801 6.8744421Q-14.937464 6.7987194 -14.807565 6.7292871Q-14.677665 6.6598539 -14.544515 6.596879Q-14.411364 6.5339031 -14.275284 6.4775372Q-14.139204 6.4211712 -14.000522 6.3715501Q-13.861841 6.321929 -13.720892 6.2791729Q-13.579943 6.2364159 -13.437065 6.2006273Q-13.294188 6.1648383 -13.149726 6.1361032Q-13.005264 6.107368 -12.859568 6.0857558Q-12.71387 6.0641432 -12.567287 6.0497065Q-12.420706 6.0352688 -12.273591 6.0280418Q-12.126476 6.0208149 -11.979185 6.0208149Q-11.831892 6.0208149 -11.684778 6.0280418Q-11.537664 6.0352693 -11.391082 6.0497069Q-11.244499 6.0641437 -11.098802 6.0857558Q-10.953104 6.107368 -10.808642 6.1361032Q-10.664181 6.1648383 -10.521302 6.2006273Q-10.378426 6.2364159 -10.237476 6.2791729Q-10.096527 6.321929 -9.9578447 6.3715506Q-9.8191624 6.4211712 -9.6830835 6.4775372Q-9.5470037 6.5339031 -9.4138536 6.596879Q-9.2807045 6.6598539 -9.1508045 6.7292871Q-9.0209045 6.7987194 -8.8945684 6.8744426Q-8.7682323 6.9501657 -8.6457634 7.0319967Q-8.5232944 7.1138277 -8.4049883 7.2015691Q-8.2866821 7.2893105 -8.1728249 7.3827519Q-8.0589666 7.4761925 -7.949831 7.5751076Q-7.8406954 7.6740227 -7.7365441 7.7781734Q-7.6323929 7.8823247 -7.5334778 7.9914608Q-7.4345627 8.1005974 -7.3411222 8.2144547Q-7.2476811 8.3283129 -7.1599398 8.4466181Q-7.0721979 8.5649242 -6.9903669 8.6873932Q-6.908536 8.8098621 -6.8328133 8.9361982Q-6.7570906 9.0625343 -6.6876578 9.1924353Q-6.6182246 9.3223343 -6.5552487 9.4554844Q-6.4922733 9.5886345 -6.4359074 9.7247143Q-6.3795414 9.8607941 -6.3299203 9.9994755Q-6.2802992 10.138158 -6.2375431 10.279108Q-6.1947865 10.420057 -6.1589975 10.562934Q-6.123208 10.705812 -6.0944729 10.850273Q-6.0657377 10.994735 -6.044126 11.140432Q-6.0225139 11.28613 -6.0080771 11.432713Q-5.9936399 11.579295 -5.9864125 11.72641Q-5.9791851 11.873524 -5.9791851 12.020815ZM6.0416298 24.04163Q6.0416298 24.188921 6.0344024 24.336037Q6.0271749 24.483149 6.0127382 24.629732Q5.998301 24.776314 5.9766889 24.92201Q5.9550762 25.067707 5.9263411 25.21217Q5.8976059 25.356628 5.8618169 25.499508Q5.8260283 25.642384 5.7832718 25.783335Q5.7405152 25.924282 5.6908941 26.062965Q5.6412725 26.201645 5.5849066 26.337727Q5.5285406 26.473806 5.4655657 26.606956Q5.4025903 26.740108 5.3331575 26.870008Q5.2637243 26.999908 5.1880012 27.126244Q5.112278 27.252579 5.030447 27.375048Q4.948616 27.497517 4.8608747 27.615824Q4.7731333 27.734129 4.6796923 27.847988Q4.5862513 27.961845 4.4873362 28.07098Q4.3884211 28.180115 4.2842703 28.284267Q4.1801195 28.388418 4.0709834 28.487335Q3.9618473 28.58625 3.8479893 28.679691Q3.7341313 28.773132 3.6158254 28.860874Q3.4975195 28.948614 3.375051 29.030445Q3.2525823 29.112276 3.126246 29.188Q2.9999099 29.263721 2.8700101 29.333157Q2.7401104 29.402588 2.6069601 29.465563Q2.47381 29.528538 2.3377302 29.584904Q2.2016504 29.64127 2.0629687 29.690891Q1.9242871 29.740513 1.7833378 29.783272Q1.6423883 29.826027 1.4995108 29.861814Q1.3566332 29.897602 1.2121717 29.926338Q1.0677102 29.955074 0.92201269 29.976686Q0.77631521 29.998301 0.62973267 30.012739Q0.48315018 30.027176 0.33603585 30.034401Q0.18892156 30.04163 0.041629791 30.04163Q-0.10566191 30.04163 -0.25277621 30.034401Q-0.39989054 30.027176 -0.54647303 30.012739Q-0.69305551 29.998302 -0.83875299 29.976688Q-0.98445046 29.955076 -1.128912 29.92634Q-1.2733736 29.897604 -1.4162512 29.861816Q-1.5591288 29.826027 -1.7000782 29.783272Q-1.8410275 29.740513 -1.9797091 29.690891Q-2.1183908 29.64127 -2.2544706 29.584904Q-2.3905504 29.528538 -2.5237007 29.465563Q-2.6568508 29.402588 -2.7867506 29.333157Q-2.9166503 29.263721 -3.0429864 29.188Q-3.169323 29.112276 -3.2917914 29.030445Q-3.4142599 28.948614 -3.5325658 28.860874Q-3.6508718 28.773132 -3.76473 28.679691Q-3.8785877 28.58625 -3.9877238 28.487335Q-4.0968599 28.388418 -4.2010107 28.284267Q-4.3051615 28.180115 -4.4040771 28.07098Q-4.5029922 27.961845 -4.5964332 27.847988Q-4.6898737 27.734129 -4.7776151 27.615822Q-4.8653564 27.497517 -4.9471874 27.375048Q-5.0290184 27.252579 -5.1047416 27.126244Q-5.1804647 26.999908 -5.249898 26.870008Q-5.3193307 26.740108 -5.3823061 26.606958Q-5.445281 26.473806 -5.501647 26.337727Q-5.558013 26.201645 -5.6076345 26.062965Q-5.6572552 25.924282 -5.7000122 25.783335Q-5.7427683 25.642384 -5.7785578 25.499508Q-5.8143463 25.356628 -5.8430815 25.21217Q-5.8718166 25.067707 -5.8934288 24.922009Q-5.915041 24.776314 -5.9294786 24.629732Q-5.9439154 24.483149 -5.9511423 24.336037Q-5.9583702 24.188921 -5.9583702 24.04163Q-5.9583702 23.894339 -5.9511428 23.747225Q-5.9439154 23.600109 -5.9294786 23.453527Q-5.915041 23.306946 -5.8934288 23.161247Q-5.8718166 23.015549 -5.8430815 22.871086Q-5.8143463 22.726624 -5.7785573 22.583746Q-5.7427683 22.440868 -5.7000122 22.299919Q-5.6572552 22.15897 -5.6076345 22.020287Q-5.558013 21.881605 -5.501647 21.745525Q-5.445281 21.609446 -5.3823056 21.476295Q-5.3193307 21.343147 -5.249898 21.213247Q-5.1804647 21.083347 -5.1047416 20.957012Q-5.0290184 20.830673 -4.9471874 20.708206Q-4.8653564 20.585737 -4.7776151 20.467432Q-4.6898737 20.349127 -4.5964332 20.23527Q-4.5029922 20.12141 -4.4040771 20.012274Q-4.3051615 19.903137 -4.2010107 19.798988Q-4.0968599 19.694836 -3.9877238 19.595922Q-3.8785877 19.497005 -3.7647297 19.403566Q-3.6508718 19.310123 -3.5325661 19.222383Q-3.4142599 19.13464 -3.2917914 19.052811Q-3.169323 18.970982 -3.0429866 18.89526Q-2.9166503 18.819536 -2.7867506 18.750103Q-2.6568508 18.680668 -2.5237005 18.617693Q-2.3905504 18.554718 -2.2544706 18.498352Q-2.1183908 18.441986 -1.9797091 18.392365Q-1.8410275 18.342743 -1.7000782 18.299988Q-1.5591288 18.257233 -1.4162513 18.221443Q-1.2733736 18.185654 -1.1289121 18.156918Q-0.98445058 18.128181 -0.8387531 18.106571Q-0.69305551 18.084959 -0.54647309 18.070522Q-0.39989054 18.056084 -0.25277627 18.048855Q-0.10566191 18.04163 0.041629791 18.04163Q0.18892156 18.04163 0.33603591 18.048857Q0.48315024 18.056084 0.62973273 18.070522Q0.77631521 18.084959 0.92201269 18.106571Q1.0677102 18.128181 1.2121717 18.156918Q1.3566332 18.185654 1.4995108 18.221443Q1.6423883 18.257233 1.7833378 18.299988Q1.9242871 18.342743 2.0629687 18.392365Q2.2016504 18.441986 2.3377302 18.498352Q2.47381 18.554718 2.6069601 18.617693Q2.7401104 18.680668 2.8700101 18.750103Q2.9999099 18.819536 3.126246 18.895258Q3.2525823 18.970982 3.375051 19.052811Q3.4975195 19.13464 3.6158254 19.222383Q3.7341313 19.310123 3.8479896 19.403566Q3.9618475 19.497005 4.0709834 19.595922Q4.1801195 19.694836 4.2842703 19.798988Q4.3884211 19.903137 4.4873362 20.012276Q4.5862513 20.12141 4.6796923 20.23527Q4.7731333 20.349127 4.8608747 20.467432Q4.948616 20.585737 5.030447 20.708206Q5.112278 20.830673 5.1880016 20.957012Q5.2637243 21.083347 5.3331575 21.213249Q5.4025903 21.343147 5.4655657 21.476297Q5.5285406 21.609447 5.5849066 21.745527Q5.6412725 21.881607 5.6908937 22.02029Q5.7405152 22.158972 5.7832718 22.299923Q5.8260283 22.440872 5.8618174 22.58375Q5.8976059 22.726627 5.9263411 22.871088Q5.9550762 23.015549 5.9766884 23.161247Q5.998301 23.306946 6.0127382 23.453529Q6.0271749 23.600111 6.0344024 23.747227Q6.0416298 23.894339 6.0416298 24.04163ZM18.062447 36.062447Q18.062447 36.20974 18.055218 36.356853Q18.047993 36.503967 18.033556 36.650551Q18.019117 36.797134 17.997505 36.942829Q17.975891 37.088524 17.947157 37.232986Q17.918421 37.377449 17.882633 37.520329Q17.846844 37.663208 17.804089 37.804157Q17.76133 37.945107 17.711708 38.083782Q17.662086 38.222466 17.605721 38.358543Q17.549355 38.494621 17.486378 38.627773Q17.423405 38.760925 17.353971 38.890823Q17.284538 39.020721 17.208817 39.147057Q17.133093 39.273392 17.051262 39.395863Q16.969431 39.51833 16.881689 39.636635Q16.793949 39.754944 16.700508 39.868805Q16.607067 39.982658 16.508152 40.091797Q16.409237 40.200932 16.305086 40.305084Q16.200935 40.409233 16.091799 40.508148Q15.982663 40.607063 15.868806 40.700508Q15.754948 40.793945 15.636642 40.881691Q15.518335 40.969429 15.395867 41.051262Q15.273399 41.133095 15.147062 41.208817Q15.020726 41.284542 14.890826 41.353973Q14.760926 41.423405 14.627776 41.486382Q14.494626 41.549355 14.358546 41.605721Q14.222466 41.662086 14.083785 41.711708Q13.945105 41.76133 13.804155 41.804085Q13.663206 41.84684 13.520329 41.882629Q13.377451 41.918419 13.232988 41.947155Q13.088527 41.975891 12.942829 41.997501Q12.797132 42.019115 12.65055 42.033554Q12.503967 42.047989 12.356853 42.055218Q12.209738 42.062447 12.062447 42.062447Q11.915155 42.062447 11.768041 42.055218Q11.620926 42.047989 11.474344 42.033554Q11.327761 42.019115 11.182063 41.997501Q11.036366 41.975891 10.891904 41.947155Q10.747442 41.918419 10.604565 41.882633Q10.461687 41.846844 10.320738 41.804089Q10.179789 41.76133 10.041107 41.711708Q9.9024248 41.662086 9.766345 41.605721Q9.6302652 41.549355 9.4971151 41.486378Q9.363966 41.423405 9.234066 41.353973Q9.104166 41.284538 8.9778309 41.208817Q8.8514938 41.133091 8.7290249 41.051258Q8.6065559 40.969429 8.4882507 40.881687Q8.3699455 40.793945 8.2560873 40.700508Q8.1422291 40.607063 8.0330925 40.508148Q7.9239569 40.409233 7.8198056 40.305084Q7.7156544 40.200932 7.6167397 40.091797Q7.5178251 39.982658 7.4243841 39.868805Q7.3309431 39.754944 7.2432013 39.636635Q7.1554594 39.51833 7.0736289 39.395863Q6.9917979 39.273392 6.9160752 39.147057Q6.8403521 39.020721 6.7709193 38.890823Q6.7014861 38.760925 6.6385107 38.627773Q6.5755348 38.494621 6.5191689 38.358543Q6.4628029 38.222466 6.4131818 38.083782Q6.3635607 37.945103 6.3208046 37.804153Q6.2780476 37.663204 6.242259 37.520329Q6.2064695 37.377449 6.1777349 37.23299Q6.1489997 37.088528 6.1273875 36.942829Q6.1057758 36.797134 6.0913386 36.650551Q6.0769014 36.503967 6.0696735 36.356853Q6.0624466 36.20974 6.0624466 36.062447Q6.0624466 35.915154 6.0696735 35.76804Q6.076901 35.620926 6.0913382 35.474346Q6.1057754 35.327766 6.1273875 35.182068Q6.1489997 35.036369 6.1777349 34.891907Q6.2064695 34.747444 6.2422585 34.604568Q6.2780476 34.461689 6.3208046 34.32074Q6.3635607 34.179787 6.4131823 34.041107Q6.4628029 33.90242 6.5191689 33.766342Q6.5755348 33.63026 6.6385107 33.497108Q6.7014856 33.36396 6.7709188 33.234062Q6.8403521 33.104164 6.9160748 32.977825Q6.9917979 32.851486 7.0736289 32.729019Q7.1554594 32.606552 7.2432013 32.488243Q7.3309431 32.369942 7.4243841 32.256084Q7.5178251 32.142227 7.6167397 32.033089Q7.7156544 31.923954 7.8198056 31.819803Q7.9239569 31.715652 8.0330925 31.616739Q8.1422291 31.517822 8.2560873 31.424383Q8.3699455 31.33094 8.4882507 31.243198Q8.6065559 31.155457 8.7290249 31.073624Q8.8514938 30.991793 8.9778309 30.916071Q9.104167 30.840349 9.234067 30.770918Q9.363966 30.701485 9.4971161 30.63851Q9.6302652 30.575533 9.766345 30.519167Q9.9024248 30.462801 10.041106 30.413179Q10.179789 30.36356 10.320738 30.320805Q10.461687 30.278048 10.604565 30.24226Q10.747442 30.20647 10.891904 30.177734Q11.036366 30.148998 11.182063 30.127388Q11.327761 30.105778 11.474344 30.091341Q11.620926 30.076902 11.768042 30.069675Q11.915155 30.062447 12.062447 30.062447Q12.209738 30.062447 12.356853 30.069674Q12.503967 30.0769 12.65055 30.091339Q12.797132 30.105776 12.942829 30.127388Q13.088527 30.148998 13.232988 30.177734Q13.377451 30.20647 13.520328 30.24226Q13.663206 30.278049 13.804155 30.320807Q13.945105 30.36356 14.083785 30.413181Q14.222466 30.462801 14.358546 30.519167Q14.494626 30.575533 14.627776 30.638508Q14.760927 30.701485 14.890827 30.77092Q15.020726 30.840351 15.147062 30.916075Q15.273397 30.991796 15.395866 31.073627Q15.518335 31.155458 15.636642 31.2432Q15.754947 31.330944 15.868805 31.424385Q15.982662 31.517826 16.091799 31.616739Q16.200935 31.715654 16.305086 31.819805Q16.409237 31.923958 16.508152 32.033092Q16.607067 32.142227 16.700508 32.256088Q16.793949 32.369946 16.881689 32.488251Q16.969431 32.60656 17.051262 32.729027Q17.133093 32.851494 17.208817 32.977833Q17.28454 33.104168 17.353973 33.234066Q17.423407 33.363964 17.486382 33.497116Q17.549356 33.630264 17.605722 33.766342Q17.662088 33.90242 17.71171 34.041107Q17.761332 34.179787 17.804089 34.32074Q17.846844 34.461685 17.882631 34.604565Q17.918421 34.74744 17.947157 34.891903Q17.975891 35.036366 17.997505 35.18206Q18.019117 35.327759 18.033556 35.474342Q18.047993 35.620926 18.05522 35.76804Q18.062447 35.915154 18.062447 36.062447ZM30 47.882248Q30 48.029541 29.992771 48.176655Q29.985544 48.323769 29.971107 48.470352Q29.956669 48.616936 29.935057 48.76263Q29.913445 48.908325 29.884708 49.052788Q29.855972 49.19725 29.820185 49.340126Q29.784393 49.483006 29.741638 49.623955Q29.698881 49.7649 29.64926 49.90358Q29.59964 50.042263 29.543272 50.178341Q29.486906 50.314423 29.423931 50.447571Q29.360954 50.580727 29.291523 50.710625Q29.22209 50.840523 29.146366 50.966858Q29.070644 51.093193 28.988813 51.215664Q28.906982 51.338135 28.819241 51.45644Q28.731501 51.574745 28.638062 51.688606Q28.544621 51.80246 28.445705 51.911598Q28.346788 52.020733 28.242638 52.124886Q28.138489 52.229034 28.029354 52.327957Q27.920218 52.426872 27.80636 52.520313Q27.692501 52.613754 27.574194 52.701492Q27.455889 52.78923 27.33342 52.871063Q27.210953 52.952896 27.084614 53.028618Q26.958279 53.10434 26.828381 53.173775Q26.698479 53.243206 26.565329 53.306179Q26.432177 53.369156 26.296097 53.425522Q26.160017 53.481888 26.021336 53.531513Q25.882656 53.581135 25.741707 53.623894Q25.600758 53.666649 25.45788 53.702438Q25.315002 53.738224 25.17054 53.76696Q25.026077 53.795696 24.880381 53.81731Q24.734684 53.838924 24.588102 53.853363Q24.441521 53.867794 24.294407 53.875019Q24.147291 53.882248 24 53.882248Q23.852707 53.882248 23.705591 53.875015Q23.558479 53.86779 23.411896 53.853359Q23.265312 53.838921 23.119616 53.817307Q22.973919 53.795692 22.829456 53.766956Q22.684994 53.73822 22.542118 53.702435Q22.399239 53.666641 22.258291 53.623886Q22.11734 53.581131 21.978657 53.531509Q21.839975 53.481888 21.703896 53.425518Q21.567818 53.369152 21.434668 53.306175Q21.301517 53.243202 21.171619 53.173775Q21.041719 53.10434 20.915382 53.028614Q20.789045 52.952888 20.666576 52.871059Q20.544107 52.78923 20.4258 52.701488Q20.307495 52.61375 20.193638 52.520309Q20.079781 52.426868 19.970644 52.327953Q19.861507 52.229034 19.757359 52.124886Q19.653206 52.020733 19.554291 51.911598Q19.455376 51.80246 19.361937 51.688606Q19.268494 51.574745 19.180754 51.45644Q19.093012 51.338135 19.011183 51.215664Q18.929352 51.093193 18.85363 50.966858Q18.777906 50.840523 18.708473 50.710625Q18.63904 50.580727 18.576063 50.447571Q18.513088 50.314423 18.456722 50.178341Q18.400356 50.042263 18.350737 49.903584Q18.301117 49.7649 18.25836 49.623955Q18.215603 49.483006 18.179813 49.34013Q18.144024 49.19725 18.115288 49.052788Q18.086552 48.908325 18.064941 48.76263Q18.043329 48.616936 18.028893 48.470352Q18.014454 48.323769 18.007227 48.176655Q18 48.029541 18 47.882248Q18 47.734955 18.007227 47.587841Q18.014454 47.440727 18.028893 47.294144Q18.043329 47.14756 18.064941 47.001862Q18.086552 46.856163 18.115288 46.7117Q18.144024 46.567238 18.179813 46.424366Q18.215601 46.281487 18.258358 46.140541Q18.301113 45.999588 18.350735 45.860909Q18.400356 45.722221 18.456722 45.586143Q18.513088 45.450062 18.576065 45.31691Q18.63904 45.183762 18.708473 45.053864Q18.777905 44.923962 18.853628 44.797626Q18.92935 44.671288 19.011181 44.54882Q19.09301 44.426353 19.180752 44.308048Q19.268494 44.189743 19.361937 44.075882Q19.455376 43.962025 19.554291 43.85289Q19.653206 43.743752 19.757359 43.639603Q19.861507 43.535454 19.970644 43.436539Q20.079781 43.337624 20.19364 43.244183Q20.307497 43.150742 20.425802 43.063004Q20.544107 42.975258 20.666576 42.893425Q20.789045 42.811592 20.915382 42.73587Q21.041719 42.660149 21.171619 42.590717Q21.301517 42.521282 21.434669 42.458305Q21.567818 42.395332 21.703897 42.338966Q21.839977 42.2826 21.978659 42.232979Q22.11734 42.183361 22.258291 42.140606Q22.399239 42.097847 22.542118 42.062057Q22.684994 42.026268 22.829454 41.997536Q22.973915 41.9688 23.119614 41.947189Q23.265312 41.925575 23.411896 41.91114Q23.558479 41.896702 23.705593 41.889473Q23.852707 41.882248 24 41.882248Q24.147291 41.882248 24.294407 41.889473Q24.441521 41.896702 24.588104 41.91114Q24.734684 41.925575 24.880381 41.947189Q25.026077 41.9688 25.17054 41.997536Q25.315002 42.026268 25.457878 42.062057Q25.600758 42.097847 25.741707 42.140606Q25.882656 42.183361 26.021336 42.232979Q26.160017 42.2826 26.296097 42.338966Q26.432177 42.395332 26.565327 42.458305Q26.698479 42.521282 26.828379 42.590717Q26.958279 42.660149 27.084616 42.73587Q27.210953 42.811592 27.33342 42.893425Q27.455889 42.975258 27.574194 43.063Q27.692501 43.150742 27.806358 43.244186Q27.920216 43.337624 28.029352 43.436539Q28.138489 43.535454 28.242638 43.639603Q28.346788 43.743752 28.445704 43.85289Q28.544621 43.962029 28.638062 44.075886Q28.731501 44.189743 28.819241 44.308048Q28.906982 44.426353 28.988811 44.54882Q29.070642 44.671288 29.146366 44.797623Q29.222088 44.923962 29.291523 45.053864Q29.360954 45.183762 29.423929 45.316914Q29.486906 45.450062 29.543272 45.586143Q29.59964 45.722221 29.64926 45.860909Q29.698881 45.999588 29.741638 46.140541Q29.784393 46.281487 29.820185 46.424366Q29.855972 46.567238 29.884708 46.7117Q29.913445 46.856163 29.935055 47.001862Q29.956669 47.14756 29.971107 47.294144Q29.985544 47.440727 29.992771 47.587841Q30 47.734955 30 47.882248ZM30 30.882248Q30 31.029541 29.992771 31.176655Q29.985544 31.323769 29.971107 31.470352Q29.956669 31.616934 29.935057 31.76263Q29.913445 31.908325 29.884708 32.052788Q29.855972 32.19725 29.820185 32.340126Q29.784393 32.483006 29.741638 32.623955Q29.698881 32.764904 29.64926 32.903584Q29.59964 33.042267 29.543272 33.178345Q29.486906 33.314423 29.423931 33.447575Q29.360954 33.580723 29.291523 33.710625Q29.22209 33.840523 29.146366 33.966858Q29.070644 34.093193 28.988813 34.215664Q28.906982 34.338135 28.819241 34.45644Q28.731501 34.574745 28.638062 34.688602Q28.544621 34.80246 28.445705 34.911598Q28.346788 35.020737 28.242638 35.124886Q28.138489 35.229034 28.029354 35.327953Q27.920218 35.426865 27.80636 35.520309Q27.692501 35.61375 27.574194 35.701492Q27.455889 35.789234 27.33342 35.871063Q27.210953 35.952896 27.084614 36.028618Q26.958279 36.10434 26.828381 36.173775Q26.698479 36.243202 26.565329 36.306179Q26.432177 36.369156 26.296097 36.425522Q26.160017 36.481888 26.021336 36.531513Q25.882656 36.581135 25.741707 36.623894Q25.600758 36.666649 25.45788 36.702438Q25.315002 36.738224 25.17054 36.76696Q25.026077 36.795692 24.880381 36.817307Q24.734684 36.838921 24.588102 36.853359Q24.441521 36.867794 24.294407 36.875023Q24.147291 36.882248 24 36.882248Q23.852707 36.882248 23.705591 36.875019Q23.558479 36.867794 23.411896 36.853359Q23.265312 36.838921 23.119616 36.817307Q22.973919 36.795692 22.829456 36.76696Q22.684994 36.738224 22.542118 36.702435Q22.399239 36.666649 22.258291 36.623894Q22.11734 36.581135 21.978657 36.531509Q21.839975 36.481888 21.703896 36.425522Q21.567818 36.369156 21.434668 36.306179Q21.301517 36.243202 21.171619 36.173775Q21.041719 36.10434 20.915382 36.028618Q20.789045 35.952896 20.666576 35.871063Q20.544107 35.789234 20.4258 35.701492Q20.307495 35.61375 20.193638 35.520309Q20.079781 35.426865 19.970644 35.327953Q19.861507 35.229034 19.757359 35.124886Q19.653206 35.020737 19.554291 34.911598Q19.455376 34.802464 19.361937 34.688606Q19.268494 34.574745 19.180754 34.45644Q19.093012 34.338135 19.011183 34.215664Q18.929352 34.093193 18.85363 33.966858Q18.777906 33.840523 18.708473 33.710625Q18.63904 33.580723 18.576063 33.447571Q18.513088 33.314423 18.456722 33.178345Q18.400356 33.042267 18.350737 32.903584Q18.301117 32.764904 18.25836 32.623955Q18.215603 32.483006 18.179813 32.340126Q18.144024 32.19725 18.115288 32.052788Q18.086552 31.908325 18.064941 31.76263Q18.043329 31.616934 18.028893 31.470352Q18.014454 31.323769 18.007227 31.176655Q18 31.029541 18 30.882248Q18 30.734955 18.007227 30.587841Q18.014454 30.440727 18.028893 30.294147Q18.043329 30.147564 18.064941 30.001865Q18.086552 29.856167 18.115288 29.711706Q18.144024 29.567245 18.179813 29.424368Q18.215601 29.28149 18.258358 29.140541Q18.301113 28.999592 18.350735 28.860909Q18.400356 28.722227 18.456722 28.586147Q18.513088 28.450066 18.576065 28.316916Q18.63904 28.183765 18.708473 28.053867Q18.777905 27.923965 18.853628 27.79763Q18.92935 27.671293 19.011181 27.548824Q19.09301 27.426357 19.180752 27.308052Q19.268494 27.189747 19.361937 27.07589Q19.455376 26.962029 19.554291 26.852894Q19.653206 26.743755 19.757359 26.639606Q19.861507 26.535454 19.970644 26.436541Q20.079781 26.337627 20.19364 26.244186Q20.307497 26.150745 20.425802 26.063002Q20.544107 25.97526 20.666576 25.893429Q20.789045 25.811598 20.915382 25.735874Q21.041719 25.660152 21.171619 25.590719Q21.301517 25.521286 21.434669 25.458311Q21.567818 25.395336 21.703897 25.338968Q21.839977 25.282602 21.978659 25.232981Q22.11734 25.183361 22.258291 25.140606Q22.399239 25.097849 22.542118 25.062061Q22.684994 25.026272 22.829454 24.997536Q22.973915 24.968801 23.119614 24.947189Q23.265312 24.925577 23.411896 24.91114Q23.558479 24.896702 23.705593 24.889475Q23.852707 24.882248 24 24.882248Q24.147291 24.882248 24.294407 24.889473Q24.441521 24.896702 24.588104 24.91114Q24.734684 24.925577 24.880381 24.947189Q25.026077 24.968801 25.17054 24.997536Q25.315002 25.026272 25.457878 25.062059Q25.600758 25.097849 25.741707 25.140606Q25.882656 25.183361 26.021336 25.232983Q26.160017 25.282604 26.296097 25.33897Q26.432177 25.395336 26.565327 25.458311Q26.698479 25.521286 26.828379 25.590721Q26.958279 25.660152 27.084616 25.735876Q27.210953 25.8116 27.33342 25.893429Q27.455889 25.97526 27.574194 26.063002Q27.692501 26.150745 27.806358 26.244186Q27.920216 26.337627 28.029352 26.436539Q28.138489 26.535454 28.242638 26.639606Q28.346788 26.743755 28.445704 26.852894Q28.544621 26.962029 28.638062 27.07589Q28.731501 27.189747 28.819241 27.308052Q28.906982 27.426357 28.988811 27.548824Q29.070642 27.671293 29.146366 27.79763Q29.222088 27.923965 29.291523 28.053867Q29.360954 28.183765 29.423929 28.316916Q29.486906 28.450066 29.543272 28.586147Q29.59964 28.722227 29.64926 28.86091Q29.698881 28.999592 29.741638 29.140541Q29.784393 29.28149 29.820185 29.424366Q29.855972 29.567245 29.884708 29.711706Q29.913445 29.856167 29.935055 30.001865Q29.956669 30.147564 29.971107 30.294147Q29.985544 30.440727 29.992771 30.587841Q30 30.734955 30 30.882248ZM30 13.882248Q30 14.029539 29.992771 14.176655Q29.985544 14.323769 29.971107 14.470351Q29.956669 14.616933 29.935057 14.76263Q29.913445 14.908327 29.884708 15.05279Q29.855972 15.197251 29.820185 15.34013Q29.784393 15.483007 29.741638 15.623957Q29.698881 15.764906 29.64926 15.903587Q29.59964 16.042269 29.543272 16.178349Q29.486906 16.314426 29.423931 16.447578Q29.360954 16.580729 29.291523 16.710629Q29.22209 16.840528 29.146366 16.966864Q29.070644 17.093201 28.988813 17.215668Q28.906982 17.338137 28.819241 17.456442Q28.731501 17.574749 28.638062 17.688606Q28.544621 17.802464 28.445705 17.9116Q28.346788 18.020737 28.242638 18.124887Q28.138489 18.229038 28.029354 18.327953Q27.920218 18.426868 27.80636 18.520311Q27.692501 18.61375 27.574194 18.701492Q27.455889 18.789234 27.33342 18.871063Q27.210953 18.952894 27.084614 19.028618Q26.958279 19.104342 26.828381 19.173775Q26.698479 19.243208 26.565329 19.306183Q26.432177 19.369158 26.296097 19.425524Q26.160017 19.48189 26.021336 19.531511Q25.882656 19.581133 25.741707 19.62389Q25.600758 19.666645 25.45788 19.702435Q25.315002 19.738222 25.17054 19.766958Q25.026077 19.795694 24.880381 19.817307Q24.734684 19.838921 24.588102 19.853357Q24.441521 19.867794 24.294407 19.875019Q24.147291 19.882248 24 19.882248Q23.852707 19.882248 23.705591 19.875021Q23.558479 19.867794 23.411896 19.853357Q23.265312 19.838921 23.119616 19.817307Q22.973919 19.795694 22.829456 19.766958Q22.684994 19.738222 22.542118 19.702435Q22.399239 19.666645 22.258291 19.62389Q22.11734 19.581133 21.978657 19.531511Q21.839975 19.48189 21.703896 19.425524Q21.567818 19.369158 21.434668 19.306183Q21.301517 19.24321 21.171619 19.173777Q21.041719 19.104343 20.915382 19.028622Q20.789045 18.952896 20.666576 18.871065Q20.544107 18.789234 20.4258 18.701492Q20.307495 18.61375 20.193638 18.520311Q20.079781 18.426868 19.970644 18.327953Q19.861507 18.229038 19.757359 18.124889Q19.653206 18.020737 19.554291 17.911602Q19.455376 17.802465 19.361937 17.688608Q19.268494 17.574749 19.180754 17.456444Q19.093012 17.338137 19.011183 17.215668Q18.929352 17.093201 18.85363 16.966866Q18.777906 16.840528 18.708473 16.710629Q18.63904 16.580729 18.576063 16.447578Q18.513088 16.314426 18.456722 16.178347Q18.400356 16.042267 18.350737 15.903585Q18.301117 15.764904 18.25836 15.623956Q18.215603 15.483006 18.179813 15.340128Q18.144024 15.19725 18.115288 15.05279Q18.086552 14.908327 18.064941 14.76263Q18.043329 14.616933 18.028893 14.470351Q18.014454 14.323769 18.007227 14.176654Q18 14.029539 18 13.882248Q18 13.734957 18.007227 13.587841Q18.014454 13.440727 18.028893 13.294146Q18.043329 13.147562 18.064941 13.001864Q18.086552 12.856166 18.115288 12.711704Q18.144024 12.567243 18.179813 12.424365Q18.215601 12.281487 18.258358 12.140538Q18.301113 11.999589 18.350735 11.860908Q18.400356 11.722226 18.456722 11.586146Q18.513088 11.450066 18.576065 11.316916Q18.63904 11.183765 18.708473 11.053866Q18.777905 10.923966 18.853628 10.79763Q18.92935 10.671293 19.011181 10.548825Q19.09301 10.426357 19.180752 10.308051Q19.268494 10.189746 19.361937 10.075888Q19.455376 9.9620295 19.554291 9.8528938Q19.653206 9.7437572 19.757359 9.6396065Q19.861507 9.5354557 19.970644 9.4365406Q20.079781 9.3376255 20.19364 9.2441845Q20.307497 9.1507435 20.425802 9.0630016Q20.544107 8.9752598 20.666576 8.8934288Q20.789045 8.8115978 20.915382 8.7358751Q21.041719 8.6601524 21.171619 8.5907202Q21.301517 8.521287 21.434669 8.4583111Q21.567818 8.3953362 21.703897 8.3389702Q21.839977 8.2826042 21.978659 8.2329836Q22.11734 8.183363 22.258291 8.1406059Q22.399239 8.0978489 22.542118 8.0620604Q22.684994 8.0262709 22.829454 7.9975357Q22.973915 7.9688005 23.119614 7.9471889Q23.265312 7.9255772 23.411896 7.91114Q23.558479 7.8967028 23.705593 7.8894753Q23.852707 7.8822479 24 7.8822479Q24.147291 7.8822479 24.294407 7.8894753Q24.441521 7.8967028 24.588104 7.91114Q24.734684 7.9255772 24.880381 7.9471889Q25.026077 7.968801 25.17054 7.9975362Q25.315002 8.0262718 25.457878 8.0620604Q25.600758 8.0978489 25.741707 8.1406059Q25.882656 8.183363 26.021336 8.2329836Q26.160017 8.2826042 26.296097 8.3389702Q26.432177 8.3953362 26.565327 8.458312Q26.698479 8.521287 26.828379 8.5907202Q26.958279 8.6601524 27.084616 8.7358751Q27.210953 8.8115978 27.33342 8.8934288Q27.455889 8.9752598 27.574194 9.0630016Q27.692501 9.1507435 27.806358 9.2441845Q27.920216 9.3376255 28.029352 9.4365406Q28.138489 9.5354557 28.242638 9.6396065Q28.346788 9.7437572 28.445704 9.8528929Q28.544621 9.9620295 28.638062 10.075888Q28.731501 10.189746 28.819241 10.308052Q28.906982 10.426357 28.988811 10.548825Q29.070642 10.671293 29.146366 10.79763Q29.222088 10.923966 29.291523 11.053866Q29.360954 11.183765 29.423929 11.316916Q29.486906 11.450066 29.543272 11.586146Q29.59964 11.722226 29.64926 11.860908Q29.698881 11.99959 29.741638 12.140539Q29.784393 12.281488 29.820185 12.424365Q29.855972 12.567244 29.884708 12.711705Q29.913445 12.856167 29.935055 13.001863Q29.956669 13.147562 29.971107 13.294146Q29.985544 13.440727 29.992771 13.587842Q30 13.734957 30 13.882248ZM30 -3.1177521Q30 -2.9704602 29.992771 -2.8233459Q29.985544 -2.6762316 29.971107 -2.5296493Q29.956669 -2.3830667 29.935057 -2.2373691Q29.913445 -2.0916715 29.884708 -1.94721Q29.855972 -1.8027484 29.820185 -1.6598709Q29.784393 -1.5169933 29.741638 -1.3760438Q29.698881 -1.2350943 29.64926 -1.0964128Q29.59964 -0.95773113 29.543272 -0.82165134Q29.486906 -0.68557155 29.423931 -0.55242139Q29.360954 -0.41927123 29.291523 -0.28937155Q29.22209 -0.15947184 29.146366 -0.033135518Q29.070644 0.093200803 28.988813 0.21566939Q28.906982 0.33813801 28.819241 0.45644385Q28.731501 0.57474971 28.638062 0.68860775Q28.544621 0.8024658 28.445705 0.91160172Q28.346788 1.0207376 28.242638 1.1248887Q28.138489 1.2290397 28.029354 1.3279548Q27.920218 1.4268699 27.80636 1.5203108Q27.692501 1.6137516 27.574194 1.7014931Q27.455889 1.7892346 27.33342 1.8710655Q27.210953 1.9528964 27.084614 2.0286193Q26.958279 2.1043425 26.828381 2.1737754Q26.698479 2.2432084 26.565329 2.3061836Q26.432177 2.369159 26.296097 2.425525Q26.160017 2.4818912 26.021336 2.5315123Q25.882656 2.5811334 25.741707 2.6238899Q25.600758 2.6666465 25.45788 2.7024355Q25.315002 2.7382243 25.17054 2.7669597Q25.026077 2.7956948 24.880381 2.817307Q24.734684 2.8389192 24.588102 2.8533564Q24.441521 2.8677933 24.294407 2.8750205Q24.147291 2.8822479 24 2.8822479Q23.852707 2.8822479 23.705591 2.8750205Q23.558479 2.8677933 23.411896 2.8533564Q23.265312 2.8389192 23.119616 2.817307Q22.973919 2.7956946 22.829456 2.7669594Q22.684994 2.738224 22.542118 2.7024353Q22.399239 2.6666465 22.258291 2.6238899Q22.11734 2.5811334 21.978657 2.5315123Q21.839975 2.4818912 21.703896 2.425525Q21.567818 2.369159 21.434668 2.3061838Q21.301517 2.2432084 21.171619 2.1737757Q21.041719 2.1043425 20.915382 2.0286193Q20.789045 1.9528962 20.666576 1.8710654Q20.544107 1.7892345 20.4258 1.701493Q20.307495 1.6137514 20.193638 1.5203106Q20.079781 1.4268696 19.970644 1.3279545Q19.861507 1.2290394 19.757359 1.1248884Q19.653206 1.0207374 19.554291 0.91160148Q19.455376 0.80246556 19.361937 0.68860757Q19.268494 0.57474953 19.180754 0.4564437Q19.093012 0.33813787 19.011183 0.21566924Q18.929352 0.093200661 18.85363 -0.033135664Q18.777906 -0.15947199 18.708473 -0.2893717Q18.63904 -0.41927138 18.576063 -0.55242151Q18.513088 -0.68557167 18.456722 -0.82165146Q18.400356 -0.95773125 18.350737 -1.0964129Q18.301117 -1.2350945 18.25836 -1.376044Q18.215603 -1.5169933 18.179813 -1.6598709Q18.144024 -1.8027484 18.115288 -1.9472101Q18.086552 -2.0916715 18.064941 -2.2373691Q18.043329 -2.3830667 18.028893 -2.5296493Q18.014454 -2.6762316 18.007227 -2.8233459Q18 -2.9704602 18 -3.1177521Q18 -3.2650437 18.007227 -3.412158Q18.014454 -3.5592723 18.028893 -3.7058549Q18.043329 -3.8524375 18.064941 -3.9981346Q18.086552 -4.1438322 18.115288 -4.2882938Q18.144024 -4.4327555 18.179813 -4.575633Q18.215601 -4.7185106 18.258358 -4.8594599Q18.301113 -5.0004091 18.350735 -5.1390905Q18.400356 -5.2777719 18.456722 -5.4138517Q18.513088 -5.5499315 18.576065 -5.6830816Q18.63904 -5.8162317 18.708473 -5.9461322Q18.777905 -6.0760317 18.853628 -6.2023678Q18.92935 -6.3287039 19.011181 -6.4511728Q19.09301 -6.5736418 19.180752 -6.6919475Q19.268494 -6.8102536 19.361937 -6.9241118Q19.455376 -7.0379696 19.554291 -7.1471057Q19.653206 -7.2562418 19.757359 -7.3603926Q19.861507 -7.4645433 19.970644 -7.5634589Q20.079781 -7.6623735 20.19364 -7.755815Q20.307497 -7.8492556 20.425802 -7.9369965Q20.544107 -8.0247383 20.666576 -8.1065693Q20.789045 -8.1884003 20.915382 -8.2641239Q21.041719 -8.3398476 21.171619 -8.4092798Q21.301517 -8.4787121 21.434669 -8.541688Q21.567818 -8.6046629 21.703897 -8.6610289Q21.839977 -8.7173958 21.978659 -8.7670164Q22.11734 -8.816638 22.258291 -8.859395Q22.399239 -8.9021511 22.542118 -8.9379396Q22.684994 -8.9737282 22.829454 -9.0024643Q22.973915 -9.0311985 23.119614 -9.0528116Q23.265312 -9.0744238 23.411896 -9.0888615Q23.558479 -9.1032982 23.705593 -9.1105242Q23.852707 -9.1177521 24 -9.1177521Q24.147291 -9.1177521 24.294407 -9.1105242Q24.441521 -9.1032982 24.588104 -9.0888615Q24.734684 -9.0744238 24.880381 -9.0528116Q25.026077 -9.0311985 25.17054 -9.0024633Q25.315002 -8.9737282 25.457878 -8.9379387Q25.600758 -8.9021511 25.741707 -8.8593941Q25.882656 -8.816637 26.021336 -8.7670164Q26.160017 -8.7173958 26.296097 -8.6610289Q26.432177 -8.6046629 26.565327 -8.541688Q26.698479 -8.4787121 26.828379 -8.4092798Q26.958279 -8.3398476 27.084616 -8.2641239Q27.210953 -8.1884003 27.33342 -8.1065693Q27.455889 -8.0247383 27.574194 -7.9369969Q27.692501 -7.8492556 27.806358 -7.755815Q27.920216 -7.6623735 28.029352 -7.5634589Q28.138489 -7.4645433 28.242638 -7.3603926Q28.346788 -7.2562418 28.445704 -7.1471052Q28.544621 -7.0379696 28.638062 -6.9241118Q28.731501 -6.8102536 28.819241 -6.6919479Q28.906982 -6.5736418 28.988811 -6.4511728Q29.070642 -6.3287044 29.146366 -6.2023678Q29.222088 -6.0760317 29.291523 -5.9461322Q29.360954 -5.8162322 29.423929 -5.6830821Q29.486906 -5.549932 29.543272 -5.4138522Q29.59964 -5.2777729 29.64926 -5.139091Q29.698881 -5.0004091 29.741638 -4.8594599Q29.784393 -4.7185106 29.820185 -4.575633Q29.855972 -4.4327555 29.884708 -4.2882938Q29.913445 -4.1438322 29.935055 -3.9981346Q29.956669 -3.8524375 29.971107 -3.7058549Q29.985544 -3.5592723 29.992771 -3.412158Q30 -3.2650437 30 -3.1177521ZM30 -20.117752Q30 -19.970459 29.992771 -19.823345Q29.985544 -19.676231 29.971107 -19.529648Q29.956669 -19.383064 29.935057 -19.237366Q29.913445 -19.091669 29.884708 -18.947208Q29.855972 -18.802746 29.820185 -18.65987Q29.784393 -18.516993 29.741638 -18.376043Q29.698881 -18.235094 29.64926 -18.096413Q29.59964 -17.957729 29.543272 -17.82165Q29.486906 -17.68557 29.423931 -17.552422Q29.360954 -17.41927 29.291523 -17.289371Q29.22209 -17.15947 29.146366 -17.033134Q29.070644 -16.906797 28.988813 -16.784328Q28.906982 -16.66186 28.819241 -16.543554Q28.731501 -16.425247 28.638062 -16.31139Q28.544621 -16.197533 28.445705 -16.088396Q28.346788 -15.97926 28.242638 -15.87511Q28.138489 -15.770959 28.029354 -15.672044Q27.920218 -15.57313 27.80636 -15.479689Q27.692501 -15.386248 27.574194 -15.298506Q27.455889 -15.210764 27.33342 -15.128933Q27.210953 -15.047102 27.084614 -14.97138Q26.958279 -14.895657 26.828381 -14.826223Q26.698479 -14.75679 26.565329 -14.693814Q26.432177 -14.630839 26.296097 -14.574473Q26.160017 -14.518106 26.021336 -14.468487Q25.882656 -14.418865 25.741707 -14.37611Q25.600758 -14.333353 25.45788 -14.297564Q25.315002 -14.261775 25.17054 -14.23304Q25.026077 -14.204305 24.880381 -14.182693Q24.734684 -14.161081 24.588102 -14.146645Q24.441521 -14.132207 24.294407 -14.124979Q24.147291 -14.117752 24 -14.117752Q23.852707 -14.117752 23.705591 -14.12498Q23.558479 -14.132207 23.411896 -14.146645Q23.265312 -14.161081 23.119616 -14.182693Q22.973919 -14.204306 22.829456 -14.233041Q22.684994 -14.261776 22.542118 -14.297565Q22.399239 -14.333354 22.258291 -14.376111Q22.11734 -14.418867 21.978657 -14.468488Q21.839975 -14.518108 21.703896 -14.574474Q21.567818 -14.63084 21.434668 -14.693816Q21.301517 -14.756791 21.171619 -14.826224Q21.041719 -14.895657 20.915382 -14.971379Q20.789045 -15.047102 20.666576 -15.128933Q20.544107 -15.210764 20.4258 -15.298507Q20.307495 -15.386248 20.193638 -15.47969Q20.079781 -15.57313 19.970644 -15.672046Q19.861507 -15.77096 19.757359 -15.875111Q19.653206 -15.979261 19.554291 -16.088396Q19.455376 -16.197533 19.361937 -16.31139Q19.268494 -16.425247 19.180754 -16.543552Q19.093012 -16.66186 19.011183 -16.784328Q18.929352 -16.906797 18.85363 -17.033134Q18.777906 -17.15947 18.708473 -17.289371Q18.63904 -17.41927 18.576063 -17.552422Q18.513088 -17.68557 18.456722 -17.82165Q18.400356 -17.957729 18.350737 -18.096413Q18.301117 -18.235094 18.25836 -18.376043Q18.215603 -18.516993 18.179813 -18.65987Q18.144024 -18.802746 18.115288 -18.947208Q18.086552 -19.091669 18.064941 -19.237366Q18.043329 -19.383064 18.028893 -19.529648Q18.014454 -19.676231 18.007227 -19.823345Q18 -19.970459 18 -20.117752Q18 -20.265043 18.007227 -20.412159Q18.014454 -20.559273 18.028893 -20.705856Q18.043329 -20.852436 18.064941 -20.998135Q18.086552 -21.143829 18.115288 -21.288292Q18.144024 -21.432755 18.179813 -21.57563Q18.215601 -21.71851 18.258358 -21.859459Q18.301113 -22.000408 18.350735 -22.139088Q18.400356 -22.277769 18.456722 -22.413849Q18.513088 -22.549929 18.576065 -22.683079Q18.63904 -22.816231 18.708473 -22.946129Q18.777905 -23.076031 18.853628 -23.202366Q18.92935 -23.328701 19.011181 -23.45117Q19.09301 -23.573639 19.180752 -23.691946Q19.268494 -23.810253 19.361937 -23.92411Q19.455376 -24.03797 19.554291 -24.147104Q19.653206 -24.256241 19.757359 -24.360392Q19.861507 -24.464542 19.970644 -24.563457Q20.079781 -24.662373 20.19364 -24.755814Q20.307497 -24.849255 20.425802 -24.936996Q20.544107 -25.024738 20.666576 -25.106567Q20.789045 -25.188396 20.915382 -25.264122Q21.041719 -25.339844 21.171619 -25.409279Q21.301517 -25.478712 21.434669 -25.541687Q21.567818 -25.604662 21.703897 -25.661028Q21.839977 -25.717394 21.978659 -25.767015Q22.11734 -25.816639 22.258291 -25.859396Q22.399239 -25.902153 22.542118 -25.937941Q22.684994 -25.973728 22.829454 -26.002464Q22.973915 -26.0312 23.119614 -26.052813Q23.265312 -26.074425 23.411896 -26.088861Q23.558479 -26.103298 23.705593 -26.110525Q23.852707 -26.117752 24 -26.117752Q24.147291 -26.117752 24.294407 -26.110525Q24.441521 -26.103298 24.588104 -26.088863Q24.734684 -26.074425 24.880381 -26.052813Q25.026077 -26.0312 25.17054 -26.002464Q25.315002 -25.973728 25.457878 -25.937941Q25.600758 -25.902153 25.741707 -25.859396Q25.882656 -25.816639 26.021336 -25.767015Q26.160017 -25.717394 26.296097 -25.661028Q26.432177 -25.604662 26.565327 -25.541687Q26.698479 -25.478712 26.828379 -25.409279Q26.958279 -25.339846 27.084616 -25.264122Q27.210953 -25.1884 27.33342 -25.106569Q27.455889 -25.024738 27.574194 -24.936998Q27.692501 -24.849257 27.806358 -24.755817Q27.920216 -24.662374 28.029352 -24.563457Q28.138489 -24.464542 28.242638 -24.360392Q28.346788 -24.256241 28.445704 -24.147106Q28.544621 -24.037971 28.638062 -23.924112Q28.731501 -23.810253 28.819241 -23.691948Q28.906982 -23.573641 28.988811 -23.451172Q29.070642 -23.328705 29.146366 -23.202368Q29.222088 -23.076033 29.291523 -22.946133Q29.360954 -22.816233 29.423929 -22.683083Q29.486906 -22.549931 29.543272 -22.413851Q29.59964 -22.277771 29.64926 -22.139091Q29.698881 -22.000408 29.741638 -21.859459Q29.784393 -21.71851 29.820185 -21.57563Q29.855972 -21.432755 29.884708 -21.288292Q29.913445 -21.143829 29.935055 -20.998133Q29.956669 -20.852436 29.971107 -20.705854Q29.985544 -20.559273 29.992771 -20.412157Q30 -20.265043 30 -20.117752ZM30 -37.117752Q30 -36.970459 29.992771 -36.823345Q29.985544 -36.676231 29.971107 -36.529648Q29.956669 -36.383064 29.935057 -36.237366Q29.913445 -36.091667 29.884708 -35.947208Q29.855972 -35.80275 29.820185 -35.65987Q29.784393 -35.516991 29.741638 -35.376041Q29.698881 -35.235092 29.64926 -35.096409Q29.59964 -34.957726 29.543272 -34.821648Q29.486906 -34.68557 29.423931 -34.552418Q29.360954 -34.419266 29.291523 -34.289368Q29.22209 -34.15947 29.146366 -34.033131Q29.070644 -33.906796 28.988813 -33.784328Q28.906982 -33.661858 28.819241 -33.543552Q28.731501 -33.425247 28.638062 -33.31139Q28.544621 -33.197529 28.445705 -33.08839Q28.346788 -32.979256 28.242638 -32.875107Q28.138489 -32.770954 28.029354 -32.672043Q27.920218 -32.573128 27.80636 -32.479687Q27.692501 -32.386246 27.574194 -32.298504Q27.455889 -32.210762 27.33342 -32.128929Q27.210953 -32.047096 27.084614 -31.971375Q26.958279 -31.895653 26.828381 -31.826221Q26.698479 -31.756788 26.565329 -31.693813Q26.432177 -31.630836 26.296097 -31.574471Q26.160017 -31.518105 26.021336 -31.468487Q25.882656 -31.418865 25.741707 -31.37611Q25.600758 -31.333353 25.45788 -31.297564Q25.315002 -31.261774 25.17054 -31.233038Q25.026077 -31.204304 24.880381 -31.182693Q24.734684 -31.161081 24.588102 -31.146645Q24.441521 -31.132206 24.294407 -31.124981Q24.147291 -31.117752 24 -31.117752Q23.852707 -31.117752 23.705591 -31.124979Q23.558479 -31.132206 23.411896 -31.146645Q23.265312 -31.161081 23.119616 -31.182693Q22.973919 -31.204306 22.829456 -31.23304Q22.684994 -31.261776 22.542118 -31.297565Q22.399239 -31.333353 22.258291 -31.37611Q22.11734 -31.418865 21.978657 -31.468485Q21.839975 -31.518105 21.703896 -31.574471Q21.567818 -31.630836 21.434668 -31.693813Q21.301517 -31.75679 21.171619 -31.826223Q21.041719 -31.895655 20.915382 -31.971375Q20.789045 -32.047096 20.666576 -32.128929Q20.544107 -32.210762 20.4258 -32.2985Q20.307495 -32.386246 20.193638 -32.479687Q20.079781 -32.573128 19.970644 -32.672039Q19.861507 -32.770954 19.757359 -32.875107Q19.653206 -32.979256 19.554291 -33.088394Q19.455376 -33.197529 19.361937 -33.31139Q19.268494 -33.425247 19.180754 -33.543552Q19.093012 -33.661858 19.011183 -33.784328Q18.929352 -33.906796 18.85363 -34.033131Q18.777906 -34.15947 18.708473 -34.289368Q18.63904 -34.419266 18.576063 -34.552418Q18.513088 -34.68557 18.456722 -34.821648Q18.400356 -34.957726 18.350737 -35.096409Q18.301117 -35.235092 18.25836 -35.376041Q18.215603 -35.516991 18.179813 -35.65987Q18.144024 -35.80275 18.115288 -35.947208Q18.086552 -36.091667 18.064941 -36.237366Q18.043329 -36.383064 18.028893 -36.529648Q18.014454 -36.676231 18.007227 -36.823345Q18 -36.970459 18 -37.117752Q18 -37.265041 18.007227 -37.412155Q18.014454 -37.559273 18.028893 -37.705856Q18.043329 -37.85244 18.064941 -37.998135Q18.086552 -38.143829 18.115288 -38.288292Q18.144024 -38.432755 18.179813 -38.57563Q18.215601 -38.71851 18.258358 -38.859459Q18.301113 -39.000404 18.350735 -39.139084Q18.400356 -39.277767 18.456722 -39.413849Q18.513088 -39.549927 18.576065 -39.683075Q18.63904 -39.816227 18.708473 -39.946129Q18.777905 -40.076027 18.853628 -40.202362Q18.92935 -40.328697 19.011181 -40.451168Q19.09301 -40.573639 19.180752 -40.691944Q19.268494 -40.810249 19.361937 -40.92411Q19.455376 -41.037968 19.554291 -41.147102Q19.653206 -41.256241 19.757359 -41.36039Q19.861507 -41.464539 19.970644 -41.563454Q20.079781 -41.662369 20.19364 -41.755814Q20.307497 -41.849251 20.425802 -41.936993Q20.544107 -42.024734 20.666576 -42.106564Q20.789045 -42.188393 20.915382 -42.264118Q21.041719 -42.33984 21.171619 -42.409275Q21.301517 -42.478706 21.434669 -42.541683Q21.567818 -42.60466 21.703897 -42.661026Q21.839977 -42.717392 21.978659 -42.767014Q22.11734 -42.816631 22.258291 -42.85939Q22.399239 -42.902145 22.542118 -42.937931Q22.684994 -42.973721 22.829454 -43.002457Q22.973915 -43.031193 23.119614 -43.052807Q23.265312 -43.074421 23.411896 -43.08886Q23.558479 -43.103294 23.705593 -43.110523Q23.852707 -43.117752 24 -43.117752Q24.147291 -43.117752 24.294407 -43.110527Q24.441521 -43.103298 24.588104 -43.088863Q24.734684 -43.074425 24.880381 -43.052811Q25.026077 -43.031197 25.17054 -43.00246Q25.315002 -42.973724 25.457878 -42.937935Q25.600758 -42.902145 25.741707 -42.859394Q25.882656 -42.816635 26.021336 -42.767014Q26.160017 -42.717392 26.296097 -42.661026Q26.432177 -42.60466 26.565327 -42.541683Q26.698479 -42.478706 26.828379 -42.409275Q26.958279 -42.33984 27.084616 -42.264118Q27.210953 -42.188393 27.33342 -42.106564Q27.455889 -42.024734 27.574194 -41.936993Q27.692501 -41.849251 27.806358 -41.755814Q27.920216 -41.662369 28.029352 -41.563454Q28.138489 -41.464539 28.242638 -41.36039Q28.346788 -41.256241 28.445704 -41.147102Q28.544621 -41.037968 28.638062 -40.92411Q28.731501 -40.810249 28.819241 -40.691944Q28.906982 -40.573639 28.988811 -40.451168Q29.070642 -40.328697 29.146366 -40.202362Q29.222088 -40.076027 29.291523 -39.946129Q29.360954 -39.816227 29.423929 -39.683075Q29.486906 -39.549927 29.543272 -39.413849Q29.59964 -39.277767 29.64926 -39.139088Q29.698881 -39.000404 29.741638 -38.859459Q29.784393 -38.71851 29.820185 -38.57563Q29.855972 -38.432755 29.884708 -38.288292Q29.913445 -38.143829 29.935055 -37.998135Q29.956669 -37.85244 29.971107 -37.705856Q29.985544 -37.559273 29.992771 -37.412155Q30 -37.265041 30 -37.117752ZM25.674095 -43.674095Q25.674095 -43.526802 25.666866 -43.379688Q25.659639 -43.232574 25.645203 -43.085991Q25.630764 -42.939407 25.60915 -42.793705Q25.58754 -42.64801 25.558804 -42.503548Q25.530067 -42.359085 25.49428 -42.216209Q25.458492 -42.073334 25.415735 -41.932388Q25.372978 -41.791435 25.323357 -41.652756Q25.273735 -41.514069 25.217369 -41.377991Q25.161003 -41.241909 25.098028 -41.108757Q25.035053 -40.975609 24.96562 -40.845711Q24.896187 -40.715809 24.820465 -40.589474Q24.744741 -40.463135 24.66291 -40.340668Q24.581081 -40.218201 24.493338 -40.099895Q24.405598 -39.98159 24.312157 -39.867729Q24.218716 -39.753872 24.119801 -39.644737Q24.020885 -39.535599 23.916735 -39.43145Q23.812584 -39.327301 23.703449 -39.228386Q23.594313 -39.129471 23.480455 -39.036034Q23.366596 -38.942589 23.248289 -38.854847Q23.129984 -38.767105 23.007515 -38.685272Q22.885048 -38.603439 22.758711 -38.527718Q22.632374 -38.451996 22.502476 -38.382565Q22.372574 -38.313129 22.239426 -38.250153Q22.106274 -38.18718 21.970194 -38.130814Q21.834114 -38.074448 21.695435 -38.024826Q21.556751 -37.975208 21.415802 -37.932453Q21.274853 -37.889694 21.131973 -37.853905Q20.989098 -37.818119 20.844635 -37.789383Q20.700172 -37.760651 20.554476 -37.739037Q20.408779 -37.717422 20.262197 -37.702988Q20.115616 -37.688549 19.9685 -37.68132Q19.821386 -37.674095 19.674095 -37.674095Q19.526802 -37.674095 19.379688 -37.68132Q19.232574 -37.688549 19.085993 -37.702988Q18.939409 -37.717422 18.793711 -37.739037Q18.648014 -37.760651 18.503551 -37.789383Q18.359089 -37.818119 18.216213 -37.853909Q18.073336 -37.889694 17.932386 -37.932453Q17.791437 -37.975208 17.652756 -38.02483Q17.514072 -38.074448 17.377993 -38.130814Q17.241913 -38.18718 17.108763 -38.250153Q16.975613 -38.313129 16.845715 -38.382565Q16.715813 -38.451996 16.589478 -38.527718Q16.46314 -38.603439 16.340672 -38.685272Q16.218204 -38.767105 16.099897 -38.854847Q15.981592 -38.942589 15.867735 -39.036034Q15.753877 -39.129471 15.644741 -39.228386Q15.535604 -39.327301 15.431454 -39.43145Q15.327303 -39.535599 15.228388 -39.644737Q15.129473 -39.753876 15.036032 -39.867733Q14.942591 -39.98159 14.854849 -40.099895Q14.767107 -40.218201 14.685277 -40.340668Q14.603445 -40.463135 14.527723 -40.58947Q14.452 -40.715809 14.382568 -40.845711Q14.313135 -40.975609 14.250159 -41.108757Q14.187183 -41.241909 14.130817 -41.377991Q14.074451 -41.514069 14.02483 -41.652752Q13.975209 -41.791435 13.932453 -41.932384Q13.889696 -42.073334 13.853907 -42.216209Q13.818117 -42.359085 13.789383 -42.503548Q13.760647 -42.64801 13.739036 -42.793705Q13.717424 -42.939407 13.702988 -43.085991Q13.688551 -43.232574 13.681323 -43.379684Q13.674095 -43.526802 13.674095 -43.674095Q13.674095 -43.821388 13.681323 -43.968498Q13.688551 -44.115616 13.702988 -44.262199Q13.717424 -44.408783 13.739036 -44.554478Q13.760647 -44.700172 13.789383 -44.844635Q13.818117 -44.989098 13.853907 -45.131977Q13.889696 -45.274857 13.932452 -45.415806Q13.975208 -45.556751 14.024829 -45.695431Q14.07445 -45.834114 14.130816 -45.970192Q14.187183 -46.10627 14.250158 -46.239422Q14.313133 -46.372574 14.382566 -46.502476Q14.452 -46.632374 14.527722 -46.758709Q14.603445 -46.885048 14.685276 -47.007515Q14.767107 -47.129982 14.854849 -47.248291Q14.942591 -47.366596 15.036032 -47.480457Q15.129473 -47.594315 15.228388 -47.703453Q15.327303 -47.812584 15.431454 -47.916737Q15.535604 -48.020889 15.644741 -48.119804Q15.753877 -48.218719 15.867735 -48.31216Q15.981592 -48.405602 16.099897 -48.49334Q16.218204 -48.581081 16.340672 -48.662914Q16.46314 -48.744743 16.589478 -48.820469Q16.715813 -48.896187 16.845715 -48.965622Q16.975613 -49.035053 17.108763 -49.098026Q17.241913 -49.161003 17.377993 -49.217369Q17.514072 -49.273735 17.652754 -49.323357Q17.791437 -49.372982 17.932386 -49.415741Q18.073336 -49.458496 18.216213 -49.494282Q18.359089 -49.530075 18.503551 -49.558807Q18.648014 -49.58754 18.793711 -49.609154Q18.939409 -49.630764 19.085993 -49.645203Q19.232574 -49.659637 19.379688 -49.666866Q19.526802 -49.674095 19.674095 -49.674095Q19.821386 -49.674095 19.9685 -49.666866Q20.115616 -49.659637 20.262197 -49.645206Q20.408779 -49.630768 20.554478 -49.609154Q20.700172 -49.58754 20.844635 -49.558807Q20.989098 -49.530075 21.131973 -49.494282Q21.274853 -49.458496 21.415802 -49.415737Q21.556751 -49.372978 21.695431 -49.323357Q21.834114 -49.273735 21.970192 -49.217369Q22.106274 -49.161003 22.239422 -49.098022Q22.372574 -49.035049 22.502474 -48.965618Q22.632374 -48.896183 22.758709 -48.820457Q22.885048 -48.744736 23.007515 -48.662907Q23.129984 -48.581078 23.248291 -48.49334Q23.366596 -48.405594 23.480455 -48.312157Q23.594313 -48.218712 23.703447 -48.119797Q23.812584 -48.020882 23.916735 -47.916733Q24.020885 -47.81258 24.119801 -47.703445Q24.218716 -47.594307 24.312159 -47.480453Q24.405598 -47.366592 24.49334 -47.248287Q24.581081 -47.129982 24.66291 -47.007511Q24.744741 -46.88504 24.820465 -46.758705Q24.896189 -46.63237 24.965622 -46.502472Q25.035053 -46.37257 25.098028 -46.239418Q25.161003 -46.10627 25.217371 -45.970192Q25.273735 -45.834114 25.323359 -45.695435Q25.372978 -45.556747 25.415735 -45.415802Q25.458492 -45.274853 25.494282 -45.131977Q25.530069 -44.989098 25.558805 -44.844635Q25.58754 -44.700172 25.609154 -44.554482Q25.630764 -44.408783 25.645205 -44.262199Q25.659641 -44.115616 25.666868 -43.968498Q25.674095 -43.821388 25.674095 -43.674095ZM13.653278 -31.653278Q13.653278 -31.505985 13.646051 -31.358871Q13.638824 -31.211758 13.624387 -31.065176Q13.609949 -30.918591 13.588336 -30.772892Q13.566724 -30.627193 13.537989 -30.482733Q13.509253 -30.338272 13.473465 -30.195396Q13.437675 -30.052517 13.394919 -29.911568Q13.352162 -29.770618 13.302541 -29.631935Q13.25292 -29.493254 13.196554 -29.357174Q13.140188 -29.221094 13.077213 -29.087944Q13.014238 -28.954796 12.944806 -28.824896Q12.875372 -28.694996 12.799648 -28.568659Q12.723927 -28.442322 12.642096 -28.319855Q12.560265 -28.197386 12.472523 -28.079081Q12.384781 -27.960775 12.291341 -27.846918Q12.197901 -27.733059 12.098985 -27.623922Q12.000071 -27.514786 11.895919 -27.410637Q11.791767 -27.306486 11.682632 -27.207571Q11.573496 -27.108658 11.459638 -27.015217Q11.345779 -26.921776 11.227472 -26.834032Q11.109167 -26.74629 10.986698 -26.664459Q10.864229 -26.58263 10.737894 -26.506908Q10.611558 -26.431187 10.481659 -26.361753Q10.351759 -26.29232 10.218609 -26.229343Q10.085459 -26.166367 9.949378 -26.110001Q9.8132992 -26.053635 9.6746168 -26.004013Q9.5359354 -25.954391 9.3949862 -25.911636Q9.2540359 -25.868879 9.1111584 -25.83309Q8.9682817 -25.797302 8.8238201 -25.768566Q8.6793594 -25.73983 8.5336618 -25.71822Q8.3879642 -25.696609 8.2413816 -25.682173Q8.094799 -25.667734 7.9476848 -25.660507Q7.8005705 -25.653278 7.6532784 -25.653278Q7.5059867 -25.653278 7.3588724 -25.660505Q7.2117577 -25.667732 7.0651751 -25.682171Q6.9185925 -25.696608 6.7728949 -25.71822Q6.6271973 -25.73983 6.4827356 -25.768566Q6.338274 -25.797302 6.1953969 -25.833092Q6.0525188 -25.868881 5.9115696 -25.911638Q5.7706203 -25.954393 5.6319385 -26.004013Q5.4932566 -26.053635 5.3571768 -26.110001Q5.221097 -26.166367 5.0879469 -26.229343Q4.9547968 -26.292318 4.8248973 -26.361752Q4.6949973 -26.431185 4.5686612 -26.506907Q4.4423246 -26.58263 4.3198566 -26.664461Q4.1973877 -26.746292 4.0790825 -26.834034Q3.9607763 -26.921776 3.8469186 -27.015217Q3.7330604 -27.108658 3.6239243 -27.207571Q3.5147884 -27.306486 3.4106374 -27.410637Q3.3064864 -27.514786 3.2075715 -27.62392Q3.1086564 -27.733059 3.0152154 -27.846916Q2.9217744 -27.960773 2.834033 -28.079079Q2.7462912 -28.197384 2.6644604 -28.319853Q2.5826294 -28.442322 2.5069063 -28.568657Q2.4311833 -28.694994 2.3617506 -28.824894Q2.2923176 -28.954794 2.2293425 -29.087944Q2.1663671 -29.221094 2.1100008 -29.357174Q2.0536349 -29.493254 2.0040138 -29.631935Q1.9543928 -29.770618 1.9116364 -29.911568Q1.8688798 -30.052517 1.8330908 -30.195395Q1.7973018 -30.338272 1.7685666 -30.482735Q1.7398314 -30.627197 1.7182193 -30.772896Q1.6966071 -30.918592 1.68217 -31.065176Q1.6677328 -31.211758 1.6605055 -31.358871Q1.6532784 -31.505985 1.6532784 -31.653278Q1.6532784 -31.800568 1.6605055 -31.947681Q1.6677327 -32.094795 1.6821699 -32.241379Q1.6966071 -32.387962 1.7182193 -32.533657Q1.7398314 -32.679356 1.7685666 -32.823818Q1.7973018 -32.968277 1.8330908 -33.111156Q1.8688798 -33.254036 1.9116364 -33.394985Q1.9543928 -33.535934 2.0040138 -33.674614Q2.0536349 -33.813297 2.1100008 -33.949375Q2.1663671 -34.085457 2.2293422 -34.218605Q2.2923176 -34.351753 2.3617506 -34.481655Q2.4311833 -34.611553 2.5069065 -34.737888Q2.5826294 -34.864227 2.6644604 -34.986694Q2.7462912 -35.109161 2.834033 -35.22747Q2.9217746 -35.345779 3.0152156 -35.459637Q3.1086564 -35.573494 3.2075715 -35.682629Q3.3064864 -35.791763 3.4106374 -35.895916Q3.5147884 -36.000069 3.6239245 -36.098984Q3.7330604 -36.197899 3.8469186 -36.29134Q3.9607763 -36.384781 4.079082 -36.472519Q4.1973877 -36.560257 4.3198566 -36.64209Q4.4423246 -36.723919 4.5686612 -36.799644Q4.6949973 -36.875366 4.8248973 -36.944801Q4.9547968 -37.014229 5.0879469 -37.077206Q5.221097 -37.140182 5.3571768 -37.196548Q5.4932566 -37.252914 5.6319385 -37.302536Q5.7706203 -37.352158 5.9115696 -37.394917Q6.0525188 -37.437675 6.1953969 -37.473465Q6.338274 -37.509254 6.4827356 -37.537987Q6.6271973 -37.566719 6.7728949 -37.588333Q6.9185925 -37.609947 7.0651751 -37.624386Q7.2117577 -37.638821 7.3588724 -37.646049Q7.5059867 -37.653275 7.6532784 -37.653278Q7.8005705 -37.653275 7.9476852 -37.646049Q8.094799 -37.638821 8.2413816 -37.624386Q8.3879642 -37.609947 8.5336609 -37.588333Q8.6793594 -37.566719 8.8238201 -37.537987Q8.9682817 -37.509254 9.1111593 -37.473465Q9.2540369 -37.437675 9.3949871 -37.39492Q9.5359364 -37.352165 9.6746178 -37.30254Q9.8132992 -37.252922 9.949378 -37.196552Q10.085459 -37.140186 10.218609 -37.077213Q10.351759 -37.014236 10.481658 -36.944805Q10.611557 -36.87537 10.737894 -36.799644Q10.864229 -36.723923 10.986698 -36.64209Q11.109167 -36.560257 11.227472 -36.472519Q11.345779 -36.384777 11.459638 -36.291336Q11.573495 -36.197899 11.682631 -36.09898Q11.791767 -36.000069 11.895918 -35.895916Q12.000069 -35.791763 12.098985 -35.682629Q12.1979 -35.573494 12.291341 -35.459637Q12.384781 -35.345779 12.472523 -35.22747Q12.560265 -35.109161 12.642096 -34.986694Q12.723927 -34.864227 12.799649 -34.737892Q12.875372 -34.611553 12.944806 -34.481655Q13.014238 -34.351753 13.077213 -34.218605Q13.140188 -34.085457 13.196554 -33.949375Q13.25292 -33.813297 13.302542 -33.674614Q13.352162 -33.535934 13.394919 -33.394985Q13.437675 -33.254036 13.473464 -33.111156Q13.509253 -32.968277 13.537989 -32.823818Q13.566724 -32.679356 13.588337 -32.533657Q13.609949 -32.387962 13.624387 -32.241379Q13.638824 -32.094795 13.646051 -31.947681Q13.653278 -31.800568 13.653278 -31.653278ZM1.6324654 -19.632465Q1.6324654 -19.485172 1.6252382 -19.338058Q1.6180109 -19.190945 1.6035738 -19.044363Q1.5891366 -18.897779 1.5675244 -18.752081Q1.5459123 -18.606384 1.5171771 -18.461922Q1.4884419 -18.317459 1.4526529 -18.174582Q1.4168639 -18.031704 1.3741075 -17.890757Q1.3313508 -17.749805 1.2817297 -17.611124Q1.2321086 -17.472443 1.1757425 -17.336363Q1.1193764 -17.200283 1.056401 -17.067133Q0.99342573 -16.933983 0.92399293 -16.804085Q0.85456008 -16.674185 0.77883697 -16.547848Q0.70311391 -16.421511 0.62128299 -16.299042Q0.53945208 -16.176571 0.45171055 -16.058266Q0.36396897 -15.93996 0.27052811 -15.826104Q0.17708722 -15.712246 0.078172147 -15.60311Q-0.020742927 -15.493975 -0.1248939 -15.389824Q-0.2290449 -15.285673 -0.3381809 -15.186758Q-0.44731688 -15.087843 -0.56117499 -14.994403Q-0.67503297 -14.900962 -0.79333878 -14.813221Q-0.91164458 -14.725478 -1.0341132 -14.643647Q-1.1565818 -14.561816 -1.2829182 -14.486094Q-1.4092546 -14.410371 -1.5391543 -14.340939Q-1.669054 -14.271505 -1.8022041 -14.208529Q-1.9353542 -14.145554 -2.071434 -14.089188Q-2.2075138 -14.032822 -2.3461957 -13.983202Q-2.4848773 -13.93358 -2.6258268 -13.890824Q-2.7667761 -13.848067 -2.9096537 -13.812278Q-3.0525312 -13.776489 -3.1969929 -13.747753Q-3.3414543 -13.719018 -3.4871519 -13.697406Q-3.6328495 -13.675795 -3.7794321 -13.661358Q-3.9260147 -13.646919 -4.0731287 -13.639692Q-4.220243 -13.632465 -4.3675346 -13.632465Q-4.5148258 -13.632465 -4.6619406 -13.639692Q-4.8090549 -13.646919 -4.9556375 -13.661357Q-5.1022201 -13.675794 -5.2479177 -13.697406Q-5.3936148 -13.719018 -5.5380764 -13.747753Q-5.682538 -13.776488 -5.8254156 -13.812277Q-5.9682932 -13.848066 -6.1092424 -13.890823Q-6.2501917 -13.933578 -6.3888731 -13.9832Q-6.5275545 -14.032821 -6.6636343 -14.089187Q-6.7997141 -14.145554 -6.9328642 -14.208529Q-7.0660148 -14.271504 -7.1959147 -14.340938Q-7.3258142 -14.41037 -7.4521503 -14.486093Q-7.5784864 -14.561815 -7.7009554 -14.643646Q-7.8234234 -14.725477 -7.9417295 -14.813219Q-8.0600348 -14.900961 -8.173893 -14.994403Q-8.2877512 -15.087843 -8.3968878 -15.186758Q-8.5060234 -15.285673 -8.6101742 -15.389824Q-8.714325 -15.493975 -8.8132401 -15.603109Q-8.9121552 -15.712246 -9.0055962 -15.826105Q-9.0990372 -15.939961 -9.1867781 -16.058266Q-9.2745199 -16.176571 -9.3563509 -16.299042Q-9.4381819 -16.421511 -9.5139055 -16.547848Q-9.5896282 -16.674185 -9.6590614 -16.804085Q-9.7284937 -16.933983 -9.7914686 -17.067133Q-9.8544445 -17.200283 -9.9108105 -17.336363Q-9.9671764 -17.472443 -10.016798 -17.611124Q-10.06642 -17.749805 -10.109176 -17.890757Q-10.151932 -18.031704 -10.187721 -18.174582Q-10.22351 -18.317459 -10.252245 -18.461922Q-10.280981 -18.606384 -10.302593 -18.752081Q-10.324205 -18.897779 -10.338643 -19.044363Q-10.35308 -19.190945 -10.360307 -19.338058Q-10.367535 -19.485172 -10.367535 -19.632465Q-10.367535 -19.779758 -10.360308 -19.92687Q-10.35308 -20.073986 -10.338643 -20.220568Q-10.324205 -20.367149 -10.302593 -20.512844Q-10.280981 -20.658543 -10.252245 -20.803005Q-10.22351 -20.947468 -10.187721 -21.090345Q-10.151932 -21.233223 -10.109176 -21.374172Q-10.06642 -21.515121 -10.016798 -21.653803Q-9.9671764 -21.792484 -9.9108105 -21.928564Q-9.8544445 -22.064644 -9.7914686 -22.197794Q-9.7284937 -22.330944 -9.6590614 -22.460846Q-9.5896282 -22.590744 -9.5139065 -22.717079Q-9.4381828 -22.843418 -9.3563519 -22.965885Q-9.2745209 -23.088354 -9.186779 -23.206659Q-9.0990372 -23.324966 -9.0055971 -23.438826Q-8.9121552 -23.552683 -8.813241 -23.661819Q-8.7143259 -23.770954 -8.6101751 -23.875107Q-8.5060244 -23.979256 -8.3968887 -24.078173Q-8.2877522 -24.177088 -8.1738949 -24.270531Q-8.0600357 -24.36397 -7.9417305 -24.45171Q-7.8234239 -24.539452 -7.7009554 -24.621283Q-7.5784864 -24.703114 -7.4521503 -24.778837Q-7.3258142 -24.854561 -7.1959143 -24.923994Q-7.0660143 -24.993427 -6.9328647 -25.0564Q-6.7997141 -25.119377 -6.6636343 -25.175743Q-6.5275545 -25.232109 -6.3888731 -25.281733Q-6.2501917 -25.331354 -6.1092429 -25.374111Q-5.9682932 -25.416866 -5.8254161 -25.452656Q-5.682538 -25.488443 -5.5380764 -25.517178Q-5.3936148 -25.545914 -5.2479177 -25.567524Q-5.1022205 -25.589138 -4.9556379 -25.603575Q-4.8090553 -25.618011 -4.6619406 -25.625238Q-4.5148258 -25.632465 -4.3675346 -25.632465Q-4.220243 -25.632465 -4.0731287 -25.625237Q-3.9260144 -25.618011 -3.7794318 -25.603577Q-3.6328492 -25.589138 -3.4871519 -25.567526Q-3.341454 -25.545912 -3.1969926 -25.517176Q-3.052531 -25.488441 -2.9096534 -25.452652Q-2.7667758 -25.416866 -2.6258264 -25.374109Q-2.4848771 -25.331352 -2.3461955 -25.281729Q-2.2075138 -25.232109 -2.0714338 -25.175741Q-1.935354 -25.119375 -1.8022039 -25.0564Q-1.6690538 -24.993423 -1.5391541 -24.923992Q-1.4092543 -24.854557 -1.282918 -24.778833Q-1.1565816 -24.703112 -1.034113 -24.621281Q-0.91164446 -24.539452 -0.79333866 -24.45171Q-0.67503291 -24.363968 -0.56117493 -24.270527Q-0.44731688 -24.177086 -0.3381809 -24.078171Q-0.22904493 -23.979256 -0.12489393 -23.875105Q-0.020742957 -23.770954 0.078172117 -23.661818Q0.17708719 -23.552681 0.27052808 -23.438824Q0.36396897 -23.324965 0.45171052 -23.206657Q0.53945208 -23.088352 0.62128299 -22.965883Q0.70311385 -22.843414 0.77883697 -22.717079Q0.85456002 -22.59074 0.92399293 -22.460842Q0.99342573 -22.330942 1.056401 -22.197792Q1.1193762 -22.064642 1.1757424 -21.928562Q1.2321086 -21.792482 1.2817297 -21.653801Q1.3313508 -21.515121 1.3741074 -21.374172Q1.4168639 -21.233223 1.4526528 -21.090343Q1.4884417 -20.947468 1.517177 -20.803005Q1.5459123 -20.658543 1.5675246 -20.512846Q1.5891367 -20.367149 1.6035739 -20.220568Q1.618011 -20.073986 1.6252382 -19.92687Q1.6324654 -19.779758 1.6324654 -19.632465ZM-10.388351 -7.6116486Q-10.388351 -7.4643564 -10.395578 -7.3172426Q-10.402805 -7.1701279 -10.417244 -7.0235457Q-10.431681 -6.8769631 -10.453292 -6.7312655Q-10.474904 -6.585568 -10.503639 -6.4411063Q-10.532373 -6.2966447 -10.568163 -6.1537671Q-10.603951 -6.0108895 -10.646708 -5.8699403Q-10.689465 -5.7289906 -10.739086 -5.5903091Q-10.788707 -5.4516277 -10.845073 -5.3155475Q-10.901439 -5.1794682 -10.964415 -5.0463176Q-11.02739 -4.913168 -11.096823 -4.783268Q-11.166256 -4.653368 -11.24198 -4.5270319Q-11.317702 -4.4006953 -11.399533 -4.2782269Q-11.481363 -4.1557584 -11.569105 -4.0374527Q-11.656847 -3.9191468 -11.750288 -3.8052888Q-11.843729 -3.6914306 -11.942644 -3.5822949Q-12.041559 -3.4731588 -12.14571 -3.3690078Q-12.249861 -3.2648568 -12.358997 -3.1659417Q-12.468134 -3.0670266 -12.581992 -2.9735858Q-12.695849 -2.8801451 -12.814156 -2.7924035Q-12.932461 -2.7046618 -13.05493 -2.6228309Q-13.177398 -2.5409999 -13.303734 -2.465277Q-13.430071 -2.389554 -13.559971 -2.320121Q-13.689871 -2.2506881 -13.82302 -2.1877127Q-13.956171 -2.1247375 -14.09225 -2.0683713Q-14.22833 -2.0120053 -14.367011 -1.9623841Q-14.505693 -1.9127631 -14.646643 -1.8700066Q-14.787592 -1.82725 -14.930469 -1.791461Q-15.073346 -1.755672 -15.217808 -1.7269368Q-15.36227 -1.6982017 -15.507967 -1.6765895Q-15.653666 -1.6549773 -15.800249 -1.6405402Q-15.946831 -1.626103 -16.093945 -1.6188759Q-16.241058 -1.6116486 -16.388351 -1.6116486Q-16.535645 -1.6116486 -16.682758 -1.6188757Q-16.829872 -1.626103 -16.976456 -1.6405402Q-17.123037 -1.6549773 -17.268734 -1.6765895Q-17.414433 -1.6982017 -17.558893 -1.7269369Q-17.703356 -1.7556722 -17.846233 -1.7914612Q-17.989109 -1.8272501 -18.130058 -1.8700067Q-18.271008 -1.9127631 -18.409689 -1.9623841Q-18.54837 -2.0120053 -18.68445 -2.0683713Q-18.82053 -2.1247375 -18.95368 -2.1877129Q-19.08683 -2.2506881 -19.216732 -2.320121Q-19.34663 -2.3895538 -19.472967 -2.4652767Q-19.599304 -2.5409999 -19.721771 -2.6228306Q-19.84424 -2.7046614 -19.962545 -2.7924032Q-20.080853 -2.8801446 -20.194712 -2.9735856Q-20.308569 -3.0670266 -20.417706 -3.1659415Q-20.52684 -3.2648566 -20.630991 -3.3690076Q-20.735142 -3.4731584 -20.834057 -3.5822945Q-20.932972 -3.6914303 -21.026413 -3.8052886Q-21.119854 -3.9191465 -21.207596 -4.0374527Q-21.295338 -4.1557584 -21.377167 -4.2782269Q-21.458998 -4.4006953 -21.534719 -4.5270314Q-21.610443 -4.653368 -21.679878 -4.783268Q-21.74931 -4.913168 -21.812286 -5.0463176Q-21.875261 -5.1794682 -21.931627 -5.3155475Q-21.987995 -5.4516277 -22.037617 -5.5903091Q-22.087238 -5.7289906 -22.129995 -5.8699403Q-22.172752 -6.0108895 -22.20854 -6.1537671Q-22.244329 -6.2966447 -22.273064 -6.4411063Q-22.3018 -6.585568 -22.32341 -6.7312655Q-22.345024 -6.8769636 -22.359463 -7.0235462Q-22.373898 -7.1701283 -22.381124 -7.3172426Q-22.388351 -7.4643564 -22.388351 -7.6116486Q-22.388351 -7.7589397 -22.381124 -7.9060545Q-22.373898 -8.0531693 -22.359461 -8.1997519Q-22.345024 -8.3463345 -22.32341 -8.4920311Q-22.3018 -8.6377287 -22.273064 -8.7821903Q-22.244329 -8.926652 -22.208542 -9.0695295Q-22.172752 -9.2124071 -22.129993 -9.3533564Q-22.087238 -9.4943056 -22.037617 -9.632987Q-21.987995 -9.7716684 -21.931629 -9.9077482Q-21.875263 -10.043828 -21.812286 -10.176978Q-21.749313 -10.310129 -21.67988 -10.440028Q-21.610447 -10.569927 -21.534723 -10.696263Q-21.459 -10.822599 -21.377169 -10.945068Q-21.295338 -11.067537 -21.207596 -11.185843Q-21.119854 -11.30415 -21.026415 -11.418008Q-20.932972 -11.531866 -20.834057 -11.641002Q-20.735142 -11.750137 -20.630991 -11.854289Q-20.52684 -11.95844 -20.417704 -12.057356Q-20.308569 -12.156271 -20.194712 -12.249712Q-20.080853 -12.343153 -19.962547 -12.430894Q-19.84424 -12.518635 -19.721771 -12.600466Q-19.599304 -12.682297 -19.472967 -12.758019Q-19.34663 -12.833742 -19.216732 -12.903175Q-19.08683 -12.972609 -18.95368 -13.035583Q-18.82053 -13.098558 -18.68445 -13.154924Q-18.54837 -13.21129 -18.409689 -13.260912Q-18.271008 -13.310534 -18.130058 -13.353291Q-17.989109 -13.396047 -17.846233 -13.431835Q-17.703356 -13.467624 -17.558893 -13.496359Q-17.414433 -13.525095 -17.268736 -13.546707Q-17.123037 -13.56832 -16.976456 -13.582758Q-16.829872 -13.597195 -16.682758 -13.604421Q-16.535645 -13.611649 -16.388351 -13.611649Q-16.241058 -13.611649 -16.093945 -13.604421Q-15.946831 -13.597195 -15.800248 -13.582758Q-15.653666 -13.56832 -15.507968 -13.546708Q-15.36227 -13.525095 -15.217808 -13.49636Q-15.073346 -13.467625 -14.930469 -13.431835Q-14.787592 -13.396048 -14.646643 -13.353291Q-14.505692 -13.310534 -14.367011 -13.260912Q-14.228329 -13.21129 -14.092249 -13.154924Q-13.956169 -13.098558 -13.823019 -13.035583Q-13.689869 -12.972609 -13.55997 -12.903175Q-13.43007 -12.833742 -13.303734 -12.758019Q-13.177397 -12.682297 -13.054929 -12.600465Q-12.932461 -12.518635 -12.814155 -12.430893Q-12.695848 -12.343151 -12.581991 -12.24971Q-12.468133 -12.156269 -12.358997 -12.057354Q-12.249861 -11.958439 -12.14571 -11.854288Q-12.041559 -11.750137 -11.942644 -11.641001Q-11.843729 -11.531866 -11.750288 -11.418008Q-11.656847 -11.30415 -11.569105 -11.185843Q-11.481363 -11.067537 -11.399532 -10.945068Q-11.317701 -10.822599 -11.241978 -10.696263Q-11.166256 -10.569927 -11.096823 -10.440028Q-11.02739 -10.310129 -10.964415 -10.176978Q-10.901439 -10.043828 -10.845073 -9.9077482Q-10.788707 -9.7716684 -10.739086 -9.632987Q-10.689465 -9.4943056 -10.646709 -9.3533564Q-10.603952 -9.2124071 -10.568163 -9.0695295Q-10.532373 -8.926652 -10.503639 -8.7821903Q-10.474904 -8.6377287 -10.453293 -8.4920311Q-10.431681 -8.3463345 -10.417244 -8.1997519Q-10.402805 -8.0531693 -10.395578 -7.9060545Q-10.388351 -7.7589397 -10.388351 -7.6116486Z"/> + <path fill="#1040FF" transform="translate(102 102)" d="M30 90Q30 90.147293 29.992771 90.294411Q29.985544 90.441521 29.971107 90.588104Q29.956669 90.73468 29.935057 90.880371Q29.913445 91.02607 29.884708 91.170532Q29.855972 91.314987 29.820185 91.45787Q29.784393 91.600746 29.741638 91.741699Q29.698881 91.882645 29.64926 92.021332Q29.59964 92.160011 29.543272 92.296089Q29.486906 92.432175 29.423931 92.565323Q29.360954 92.698471 29.291523 92.828369Q29.22209 92.958267 29.146366 93.084602Q29.070644 93.210938 28.988813 93.333405Q28.906982 93.455872 28.819241 93.574181Q28.731501 93.69249 28.638062 93.806358Q28.544621 93.920212 28.445705 94.029343Q28.346788 94.138481 28.242638 94.24263Q28.138489 94.346786 28.029354 94.445702Q27.920218 94.544617 27.80636 94.638062Q27.692501 94.731499 27.574194 94.819237Q27.455889 94.906982 27.33342 94.988808Q27.210953 95.070641 27.084614 95.146362Q26.958279 95.222092 26.828381 95.291519Q26.698479 95.360954 26.565329 95.423927Q26.432177 95.4869 26.296097 95.543266Q26.160017 95.599625 26.021336 95.649246Q25.882656 95.698868 25.741707 95.741631Q25.600758 95.784386 25.45788 95.820175Q25.315002 95.855965 25.17054 95.884705Q25.026077 95.913437 24.880381 95.935051Q24.734684 95.956665 24.588102 95.971107Q24.441521 95.985542 24.294407 95.992767Q24.147291 96 24 96Q23.852707 96 23.705591 95.992767Q23.558479 95.985542 23.411896 95.971107Q23.265312 95.956665 23.119616 95.935051Q22.973919 95.913437 22.829456 95.884705Q22.684994 95.855965 22.542118 95.820175Q22.399239 95.784393 22.258291 95.741638Q22.11734 95.698875 21.978657 95.649246Q21.839975 95.599625 21.703896 95.543266Q21.567818 95.486908 21.434668 95.423935Q21.301517 95.360962 21.171619 95.291534Q21.041719 95.222099 20.915382 95.14637Q20.789045 95.070648 20.666576 94.988815Q20.544107 94.906982 20.4258 94.819244Q20.307495 94.731506 20.193638 94.638062Q20.079781 94.544617 19.970644 94.445702Q19.861507 94.346786 19.757359 94.242638Q19.653206 94.138489 19.554291 94.02935Q19.455376 93.920212 19.361937 93.806351Q19.268494 93.69249 19.180754 93.574181Q19.093012 93.455872 19.011183 93.333405Q18.929352 93.210938 18.85363 93.084602Q18.777906 92.958267 18.708473 92.828369Q18.63904 92.698471 18.576063 92.565323Q18.513088 92.432175 18.456722 92.296089Q18.400356 92.160011 18.350737 92.021332Q18.301117 91.882645 18.25836 91.741699Q18.215603 91.600746 18.179813 91.45787Q18.144024 91.314987 18.115288 91.170532Q18.086552 91.02607 18.064941 90.880379Q18.043329 90.73468 18.028893 90.588104Q18.014454 90.441521 18.007227 90.294403Q18 90.147293 18 90Q18 89.852707 18.007227 89.705597Q18.014454 89.558472 18.028893 89.411896Q18.043329 89.265305 18.064941 89.119606Q18.086552 88.973907 18.115288 88.829445Q18.144024 88.684982 18.179813 88.542107Q18.215601 88.399231 18.258358 88.258286Q18.301113 88.11734 18.350735 87.978653Q18.400356 87.839966 18.456722 87.703888Q18.513088 87.56781 18.576065 87.434662Q18.63904 87.301506 18.708473 87.171608Q18.777905 87.041702 18.853628 86.915367Q18.92935 86.789032 19.011181 86.666565Q19.09301 86.544098 19.180752 86.425789Q19.268494 86.307487 19.361937 86.193634Q19.455376 86.079773 19.554291 85.970642Q19.653206 85.861504 19.757359 85.757355Q19.861507 85.653198 19.970644 85.554291Q20.079781 85.455376 20.19364 85.361938Q20.307497 85.268494 20.425802 85.180756Q20.544107 85.09301 20.666576 85.011177Q20.789045 84.929352 20.915382 84.853622Q21.041719 84.777901 21.171619 84.708466Q21.301517 84.639038 21.434669 84.576065Q21.567818 84.513084 21.703897 84.456718Q21.839977 84.400345 21.978659 84.350731Q22.11734 84.301109 22.258291 84.258362Q22.399239 84.215599 22.542118 84.17981Q22.684994 84.14402 22.829454 84.115288Q22.973915 84.086548 23.119614 84.064941Q23.265312 84.043327 23.411896 84.028893Q23.558479 84.01445 23.705593 84.007225Q23.852707 84 24 84Q24.147291 84 24.294407 84.007225Q24.441521 84.01445 24.588104 84.028893Q24.734684 84.043327 24.880381 84.064941Q25.026077 84.086548 25.17054 84.115288Q25.315002 84.14402 25.457878 84.17981Q25.600758 84.215599 25.741707 84.258362Q25.882656 84.301109 26.021336 84.350723Q26.160017 84.400345 26.296097 84.456718Q26.432177 84.513084 26.565327 84.576057Q26.698479 84.639038 26.828379 84.708466Q26.958279 84.777893 27.084616 84.853622Q27.210953 84.929337 27.33342 85.011169Q27.455889 85.093002 27.574194 85.180756Q27.692501 85.268494 27.806358 85.361938Q27.920216 85.455376 28.029352 85.554291Q28.138489 85.653198 28.242638 85.757355Q28.346788 85.861504 28.445704 85.970642Q28.544621 86.079773 28.638062 86.193634Q28.731501 86.307487 28.819241 86.425789Q28.906982 86.544098 28.988811 86.666565Q29.070642 86.789032 29.146366 86.915375Q29.222088 87.041702 29.291523 87.171608Q29.360954 87.301506 29.423929 87.434654Q29.486906 87.56781 29.543272 87.703888Q29.59964 87.839966 29.64926 87.978653Q29.698881 88.11734 29.741638 88.258293Q29.784393 88.399239 29.820185 88.542114Q29.855972 88.68499 29.884708 88.829453Q29.913445 88.973907 29.935055 89.119614Q29.956669 89.265305 29.971107 89.411896Q29.985544 89.558472 29.992771 89.705589Q30 89.852707 30 90ZM24.170935 105.67855Q24.170935 105.82584 24.163708 105.97296Q24.156479 106.12007 24.142042 106.26665Q24.127605 106.41324 24.105991 106.55894Q24.084381 106.70463 24.055645 106.84909Q24.026909 106.99355 23.991121 107.13643Q23.955334 107.27931 23.912577 107.42026Q23.86982 107.5612 23.820198 107.69988Q23.770576 107.83856 23.714211 107.97464Q23.657845 108.11072 23.59487 108.24387Q23.531895 108.37702 23.462461 108.50693Q23.393028 108.63683 23.317305 108.76315Q23.241581 108.8895 23.15975 109.01196Q23.077919 109.13443 22.990177 109.25275Q22.902437 109.37106 22.808998 109.48492Q22.715555 109.59877 22.616638 109.70791Q22.517723 109.81704 22.413574 109.92119Q22.309423 110.02534 22.200289 110.12426Q22.091152 110.22318 21.977295 110.31661Q21.863436 110.41005 21.745129 110.49779Q21.626823 110.58553 21.504354 110.66736Q21.381886 110.74919 21.25555 110.82491Q21.129211 110.90063 20.999313 110.97008Q20.869411 111.03951 20.736263 111.10248Q20.603111 111.16545 20.467033 111.22182Q20.330952 111.27818 20.192272 111.3278Q20.053591 111.37743 19.912643 111.4202Q19.771692 111.46294 19.628815 111.49873Q19.485937 111.53452 19.341476 111.56326Q19.197014 111.592 19.051317 111.61361Q18.905621 111.63522 18.759037 111.64966Q18.612453 111.66409 18.46534 111.67132Q18.318226 111.67854 18.170935 111.67855Q18.023643 111.67854 17.87653 111.67132Q17.729416 111.66409 17.582832 111.64966Q17.436249 111.63522 17.290552 111.61361Q17.144855 111.592 17.000393 111.56326Q16.85593 111.53452 16.713055 111.49873Q16.570177 111.46294 16.429228 111.4202Q16.288279 111.37743 16.149597 111.32781Q16.010914 111.27818 15.874833 111.22182Q15.738752 111.16545 15.605602 111.10248Q15.472452 111.03951 15.342553 110.97007Q15.212654 110.90063 15.086317 110.82491Q14.95998 110.74919 14.837512 110.66736Q14.715043 110.58553 14.596737 110.49779Q14.478432 110.41005 14.364574 110.31662Q14.250715 110.22318 14.14158 110.12426Q14.032444 110.02534 13.928293 109.92119Q13.824142 109.81704 13.725227 109.7079Q13.626312 109.59877 13.532872 109.48491Q13.43943 109.37105 13.351689 109.25275Q13.263947 109.13443 13.182117 109.01196Q13.100285 108.8895 13.024563 108.76315Q12.948839 108.63683 12.879408 108.50693Q12.809975 108.37702 12.746999 108.24387Q12.684023 108.11072 12.627657 107.97464Q12.57129 107.83856 12.521669 107.69987Q12.472048 107.5612 12.429293 107.42026Q12.386536 107.27931 12.350746 107.13643Q12.314957 106.99355 12.286222 106.84909Q12.257486 106.70463 12.235875 106.55893Q12.214264 106.41324 12.199827 106.26666Q12.18539 106.12007 12.178163 105.97296Q12.170935 105.82584 12.170935 105.67855Q12.170935 105.53127 12.178163 105.38415Q12.18539 105.23703 12.199827 105.09045Q12.214264 104.94386 12.235875 104.79816Q12.257486 104.65247 12.286222 104.508Q12.314957 104.36354 12.350746 104.22066Q12.386536 104.07779 12.429293 103.93684Q12.472048 103.79588 12.521668 103.65721Q12.57129 103.51852 12.627657 103.38245Q12.684023 103.24637 12.746999 103.11321Q12.809974 102.98006 12.879407 102.85017Q12.948839 102.72026 13.024562 102.59393Q13.100285 102.46759 13.182116 102.34512Q13.263947 102.22266 13.351688 102.10435Q13.43943 101.98605 13.532872 101.87219Q13.626312 101.75833 13.725227 101.64919Q13.824142 101.54005 13.928293 101.43591Q14.032444 101.33176 14.14158 101.23284Q14.250715 101.13393 14.364574 101.04048Q14.478432 100.94704 14.596737 100.8593Q14.715042 100.77156 14.837511 100.68973Q14.95998 100.60789 15.086316 100.53217Q15.212653 100.45645 15.342553 100.38702Q15.472452 100.31759 15.605602 100.25462Q15.738752 100.19164 15.874832 100.13527Q16.010912 100.0789 16.149595 100.02928Q16.288277 99.97966 16.429226 99.936913Q16.570175 99.89415 16.713053 99.85836Q16.85593 99.822571 17.000393 99.793839Q17.144855 99.765106 17.290552 99.7435Q17.436249 99.721886 17.582832 99.707451Q17.729416 99.693008 17.87653 99.685783Q18.023643 99.678551 18.170935 99.678551Q18.318226 99.678551 18.46534 99.685776Q18.612453 99.693008 18.759037 99.707451Q18.905621 99.721886 19.051315 99.743492Q19.197014 99.765106 19.341475 99.793839Q19.485935 99.822571 19.628815 99.858368Q19.771692 99.89415 19.912643 99.936913Q20.053591 99.97966 20.192272 100.02928Q20.330952 100.0789 20.467033 100.13527Q20.603111 100.19164 20.736263 100.25461Q20.869411 100.31759 20.999313 100.38702Q21.129211 100.45645 21.255548 100.53217Q21.381886 100.60789 21.504354 100.68973Q21.626823 100.77156 21.745131 100.8593Q21.863436 100.94704 21.977295 101.04049Q22.091152 101.13393 22.200287 101.23284Q22.309423 101.33176 22.413574 101.43591Q22.517723 101.54005 22.616638 101.6492Q22.715553 101.75833 22.808996 101.87219Q22.902435 101.98605 22.990177 102.10435Q23.077919 102.22266 23.15975 102.34512Q23.241581 102.46759 23.317305 102.59393Q23.393028 102.72026 23.46246 102.85017Q23.531893 102.98006 23.594868 103.11321Q23.657843 103.24636 23.714209 103.38244Q23.770575 103.51852 23.820196 103.6572Q23.86982 103.79588 23.912577 103.93684Q23.955334 104.07778 23.991123 104.22066Q24.026909 104.36353 24.055645 104.508Q24.084381 104.65246 24.105991 104.79816Q24.127605 104.94386 24.142042 105.09045Q24.156479 105.23704 24.163708 105.38416Q24.170935 105.53127 24.170935 105.67855ZM9.3389568 113.7666Q9.3389568 113.91389 9.3317299 114.061Q9.3245029 114.20811 9.3100662 114.35471Q9.2956285 114.50128 9.2740154 114.64699Q9.2524033 114.79268 9.2236681 114.93714Q9.1949329 115.0816 9.1591444 115.22448Q9.1233559 115.36736 9.0805988 115.50832Q9.0378418 115.64926 8.9882212 115.78793Q8.9385996 115.92662 8.8822336 116.0627Q8.8258667 116.19878 8.7628918 116.33192Q8.6999168 116.46507 8.6304855 116.59498Q8.5610523 116.72487 8.4853287 116.85121Q8.409605 116.97754 8.327774 117.10001Q8.2459431 117.22249 8.1582022 117.34079Q8.0704613 117.4591 7.9770198 117.57296Q7.8835783 117.68681 7.7846632 117.79595Q7.6857481 117.90508 7.5815973 118.00923Q7.4774461 118.11339 7.36831 118.2123Q7.2591743 118.31122 7.1453161 118.40466Q7.0314579 118.49809 6.9131527 118.58584Q6.7948465 118.67358 6.6723776 118.75541Q6.5499091 118.83723 6.4235735 118.91296Q6.2972369 118.98869 6.1673374 119.05813Q6.0374374 119.12756 5.9042873 119.19054Q5.7711372 119.25351 5.6350574 119.30988Q5.4989777 119.36624 5.3602958 119.41586Q5.2216144 119.46548 5.0806651 119.50825Q4.9397154 119.55099 4.7968378 119.58678Q4.6539602 119.62257 4.5094986 119.65131Q4.365037 119.68005 4.2193398 119.70166Q4.0736423 119.72327 3.9270597 119.73772Q3.780477 119.75214 3.6333628 119.75937Q3.4862485 119.7666 3.3389571 119.7666Q3.1916652 119.7666 3.0445509 119.75937Q2.8974369 119.75214 2.7508545 119.73772Q2.6042719 119.72327 2.4585743 119.70166Q2.3128767 119.68005 2.1684151 119.65131Q2.0239534 119.62257 1.881076 119.58679Q1.7381985 119.551 1.5972492 119.50826Q1.4562997 119.46549 1.3176179 119.41587Q1.1789362 119.36624 1.0428565 119.30988Q0.90677667 119.25351 0.77362657 119.19054Q0.64047641 119.12756 0.51057673 119.05814Q0.38067698 118.98869 0.25434068 118.91296Q0.12800434 118.83725 0.0055357516 118.75542Q-0.11693284 118.67358 -0.23523866 118.58585Q-0.35354447 118.4981 -0.46740252 118.40466Q-0.58126056 118.31122 -0.69039649 118.2123Q-0.79953247 118.11339 -0.90368348 118.00924Q-1.0078344 117.90509 -1.1067495 117.79594Q-1.2056646 117.68681 -1.2991055 117.57296Q-1.3925464 117.4591 -1.4802879 117.34079Q-1.5680294 117.22249 -1.6498604 117.10001Q-1.7316914 116.97754 -1.8074143 116.85121Q-1.8831375 116.72488 -1.9525704 116.59499Q-2.0220032 116.46507 -2.0849786 116.33192Q-2.1479537 116.19878 -2.20432 116.0627Q-2.2606859 115.92662 -2.3103073 115.78793Q-2.3599284 115.64926 -2.4026849 115.50832Q-2.4454415 115.36736 -2.4812303 115.22448Q-2.5170193 115.0816 -2.5457544 114.93714Q-2.5744896 114.79268 -2.5961018 114.64698Q-2.6177142 114.50128 -2.6321514 114.35471Q-2.6465886 114.20811 -2.6538157 114.061Q-2.6610432 113.91389 -2.6610429 113.7666Q-2.6610432 113.61931 -2.6538157 113.4722Q-2.6465886 113.32508 -2.6321514 113.1785Q-2.6177142 113.03191 -2.5961018 112.88622Q-2.5744896 112.74052 -2.5457544 112.59605Q-2.5170193 112.45159 -2.4812303 112.30872Q-2.4454412 112.16584 -2.4026847 112.02489Q-2.3599281 111.88394 -2.310307 111.74525Q-2.2606859 111.60657 -2.2043197 111.47049Q-2.1479535 111.3344 -2.0849783 111.20126Q-2.0220029 111.06811 -1.9525703 110.93822Q-1.8831373 110.8083 -1.8074143 110.68196Q-1.7316911 110.55563 -1.6498604 110.43317Q-1.5680294 110.3107 -1.480288 110.1924Q-1.3925464 110.0741 -1.2991055 109.96024Q-1.2056646 109.84638 -1.1067495 109.73724Q-1.0078344 109.62811 -0.90368354 109.52396Q-0.79953253 109.41981 -0.69039655 109.32089Q-0.58126062 109.22198 -0.46740261 109.12854Q-0.35354453 109.0351 -0.2352387 108.94735Q-0.11693288 108.8596 0.0055357218 108.77777Q0.12800431 108.69594 0.25434065 108.62022Q0.38067698 108.54449 0.51057673 108.47507Q0.64047641 108.40563 0.77362651 108.34265Q0.90677667 108.27968 1.0428565 108.22331Q1.1789362 108.16695 1.3176179 108.11733Q1.4562995 108.06771 1.597249 108.02496Q1.7381984 107.9822 1.8810759 107.94641Q2.0239534 107.91061 2.1684151 107.88188Q2.3128767 107.85315 2.4585743 107.83154Q2.6042719 107.80993 2.7508543 107.79549Q2.8974366 107.78105 3.0445509 107.77383Q3.1916652 107.7666 3.3389571 107.7666Q3.4862485 107.7666 3.6333628 107.77383Q3.780477 107.78105 3.9270597 107.7955Q4.0736423 107.80994 4.2193398 107.83154Q4.365037 107.85315 4.5094986 107.88188Q4.6539602 107.91061 4.7968378 107.94641Q4.9397154 107.9822 5.0806646 108.02496Q5.2216139 108.06772 5.3602958 108.11733Q5.4989772 108.16695 5.635057 108.22332Q5.7711368 108.27968 5.9042873 108.34266Q6.0374374 108.40564 6.1673374 108.47507Q6.2972364 108.5445 6.4235725 108.62022Q6.5499086 108.69595 6.6723776 108.77778Q6.7948465 108.8596 6.9131522 108.94735Q7.0314584 109.0351 7.1453166 109.12855Q7.2591743 109.22198 7.3683105 109.32089Q7.4774461 109.41981 7.5815973 109.52396Q7.6857481 109.62811 7.7846632 109.73724Q7.8835783 109.84638 7.9770198 109.96024Q8.0704613 110.0741 8.1582022 110.1924Q8.2459431 110.3107 8.327774 110.43317Q8.409605 110.55563 8.4853287 110.68197Q8.5610523 110.8083 8.6304855 110.93822Q8.6999168 111.06811 8.7628918 111.20126Q8.8258667 111.3344 8.8822336 111.47049Q8.9385996 111.60657 8.9882212 111.74525Q9.0378418 111.88393 9.0805988 112.02489Q9.1233559 112.16583 9.1591444 112.30872Q9.1949329 112.45159 9.2236681 112.59605Q9.2524033 112.74052 9.2740164 112.88622Q9.2956285 113.03191 9.3100662 113.1785Q9.3245029 113.32508 9.3317299 113.4722Q9.3389568 113.61931 9.3389568 113.7666ZM-6.8639793 110.26124Q-6.8639793 110.40852 -6.8712072 110.55564Q-6.8784342 110.70276 -6.8928719 110.84934Q-6.9073086 110.99592 -6.9289207 111.14161Q-6.9505329 111.28731 -6.9792676 111.43176Q-7.0080028 111.57623 -7.0437918 111.71911Q-7.0795808 111.86198 -7.1223373 112.00294Q-7.1650934 112.14388 -7.2147145 112.28256Q-7.2643356 112.42123 -7.3207016 112.55732Q-7.3770676 112.6934 -7.4400434 112.82655Q-7.5030184 112.9597 -7.5724511 113.08961Q-7.6418839 113.2195 -7.717607 113.34584Q-7.7933302 113.47218 -7.8751607 113.59465Q-7.9569912 113.71712 -8.044733 113.83543Q-8.1324749 113.95374 -8.2259159 114.0676Q-8.3193569 114.18146 -8.418272 114.29059Q-8.5171871 114.39973 -8.6213379 114.50388Q-8.7254887 114.60803 -8.8346243 114.70695Q-8.9437599 114.80586 -9.0576191 114.8993Q-9.1714764 114.99274 -9.2897825 115.08047Q-9.4080887 115.16821 -9.5305567 115.25005Q-9.6530247 115.33188 -9.7793617 115.4076Q-9.9056988 115.48332 -10.035599 115.55276Q-10.165499 115.62219 -10.298648 115.68517Q-10.431799 115.74814 -10.567878 115.8045Q-10.703959 115.86087 -10.84264 115.91049Q-10.981321 115.96011 -11.122271 116.00288Q-11.26322 116.04563 -11.406097 116.08142Q-11.548975 116.1172 -11.693437 116.14594Q-11.837898 116.17468 -11.983596 116.1963Q-12.129293 116.21791 -12.275877 116.23235Q-12.42246 116.24678 -12.569573 116.25401Q-12.716688 116.26123 -12.863979 116.26124Q-13.011271 116.26123 -13.158385 116.25401Q-13.3055 116.24678 -13.452083 116.23235Q-13.598665 116.2179 -13.744362 116.19629Q-13.890059 116.17467 -14.034521 116.14594Q-14.178982 116.1172 -14.321859 116.08141Q-14.464737 116.04562 -14.605687 116.00287Q-14.746635 115.96011 -14.885317 115.91048Q-15.023998 115.86086 -15.160078 115.8045Q-15.296158 115.74813 -15.429309 115.68515Q-15.562459 115.62218 -15.692359 115.55276Q-15.822258 115.48332 -15.948595 115.4076Q-16.07493 115.33188 -16.197399 115.25005Q-16.319868 115.16821 -16.438175 115.08047Q-16.55648 114.99274 -16.67034 114.89931Q-16.784199 114.80586 -16.893333 114.70695Q-17.002468 114.60803 -17.106619 114.50388Q-17.21077 114.39973 -17.309685 114.29059Q-17.4086 114.18146 -17.502041 114.0676Q-17.595482 113.95374 -17.683224 113.83542Q-17.770964 113.71712 -17.852795 113.59465Q-17.934624 113.47218 -18.010347 113.34583Q-18.086071 113.2195 -18.155504 113.08961Q-18.224937 112.9597 -18.28791 112.82655Q-18.350887 112.69341 -18.407253 112.55733Q-18.463619 112.42125 -18.513241 112.28256Q-18.562862 112.14389 -18.605619 112.00294Q-18.648376 111.86198 -18.684164 111.7191Q-18.719952 111.57623 -18.748688 111.43177Q-18.777424 111.28731 -18.799036 111.14162Q-18.820648 110.99592 -18.835087 110.84933Q-18.849525 110.70275 -18.85675 110.55563Q-18.863979 110.40852 -18.863979 110.26124Q-18.863979 110.11395 -18.856752 109.96684Q-18.849525 109.81972 -18.835087 109.67313Q-18.820648 109.52655 -18.799036 109.38084Q-18.777424 109.23515 -18.748688 109.09068Q-18.719952 108.94622 -18.684164 108.80334Q-18.648376 108.66046 -18.605619 108.51952Q-18.562862 108.37856 -18.513241 108.23988Q-18.463619 108.1012 -18.407253 107.96512Q-18.350887 107.82904 -18.287912 107.69589Q-18.224937 107.56274 -18.155504 107.43285Q-18.086071 107.30295 -18.010349 107.17661Q-17.934624 107.05028 -17.852795 106.9278Q-17.770964 106.80534 -17.683224 106.68703Q-17.595482 106.56873 -17.502041 106.45487Q-17.4086 106.34101 -17.309685 106.23188Q-17.21077 106.12274 -17.106619 106.01859Q-17.002468 105.91444 -16.893333 105.81553Q-16.784197 105.71661 -16.670338 105.62318Q-16.55648 105.52973 -16.438175 105.44199Q-16.319868 105.35425 -16.197399 105.27242Q-16.07493 105.19058 -15.948594 105.11486Q-15.822258 105.03914 -15.692359 104.96971Q-15.562459 104.90028 -15.429308 104.8373Q-15.296158 104.77432 -15.160078 104.71796Q-15.023998 104.66159 -14.885317 104.61197Q-14.746635 104.56235 -14.605687 104.5196Q-14.464737 104.47684 -14.321859 104.44105Q-14.178982 104.40526 -14.03452 104.37653Q-13.890059 104.34779 -13.744362 104.32619Q-13.598665 104.30457 -13.452083 104.29014Q-13.3055 104.2757 -13.158385 104.26847Q-13.011271 104.26124 -12.863979 104.26124Q-12.716688 104.26124 -12.569573 104.26846Q-12.42246 104.2757 -12.275877 104.29014Q-12.129293 104.30457 -11.983597 104.32618Q-11.837898 104.34779 -11.693437 104.37653Q-11.548975 104.40526 -11.406097 104.44106Q-11.26322 104.47684 -11.122271 104.5196Q-10.981321 104.56235 -10.842639 104.61197Q-10.703959 104.66159 -10.567878 104.71796Q-10.431799 104.77432 -10.298648 104.8373Q-10.165499 104.90028 -10.035599 104.96971Q-9.9056988 105.03914 -9.7793617 105.11486Q-9.6530256 105.19058 -9.5305576 105.27241Q-9.4080887 105.35423 -9.2897835 105.44199Q-9.1714773 105.52972 -9.05762 105.62317Q-8.9437618 105.71661 -8.8346252 105.81552Q-8.7254896 105.91444 -8.6213388 106.01859Q-8.5171881 106.12274 -8.418273 106.23187Q-8.3193579 106.341 -8.2259169 106.45486Q-8.1324759 106.56873 -8.044734 106.68703Q-7.9569921 106.80533 -7.8751612 106.9278Q-7.7933302 107.05026 -7.7176075 107.1766Q-7.6418848 107.30294 -7.5724525 107.43285Q-7.5030193 107.56274 -7.4400439 107.69588Q-7.377068 107.82904 -7.3207016 107.96512Q-7.2643356 108.1012 -7.214715 108.23988Q-7.1650939 108.37857 -7.1223378 108.51952Q-7.0795813 108.66047 -7.0437918 108.80334Q-7.0080028 108.94623 -6.9792676 109.09069Q-6.9505329 109.23515 -6.9289207 109.38085Q-6.9073086 109.52655 -6.8928719 109.67313Q-6.8784342 109.81972 -6.8712072 109.96683Q-6.8639793 110.11395 -6.8639793 110.26124ZM-17.025269 96.770302Q-17.025269 96.917587 -17.032495 97.064705Q-17.039722 97.211823 -17.054161 97.358406Q-17.068598 97.50499 -17.09021 97.650681Q-17.11182 97.796379 -17.140556 97.940842Q-17.169292 98.085304 -17.205082 98.228188Q-17.240871 98.371063 -17.283627 98.512009Q-17.326382 98.652954 -17.376003 98.791634Q-17.425625 98.930313 -17.481991 99.066391Q-17.538357 99.202469 -17.601334 99.335625Q-17.664309 99.468781 -17.733742 99.598679Q-17.803175 99.728577 -17.878897 99.854919Q-17.95462 99.981255 -18.036449 100.10372Q-18.118279 100.2262 -18.20602 100.3445Q-18.293762 100.46281 -18.387205 100.57667Q-18.480644 100.69052 -18.579559 100.79965Q-18.678474 100.90878 -18.782627 101.01294Q-18.886776 101.11709 -18.995913 101.216Q-19.105049 101.31493 -19.218908 101.40836Q-19.332766 101.5018 -19.451071 101.58955Q-19.569376 101.67728 -19.691845 101.75911Q-19.814314 101.84094 -19.940651 101.91666Q-20.066988 101.99239 -20.196888 102.06183Q-20.326786 102.13126 -20.459938 102.19423Q-20.593086 102.2572 -20.729166 102.31357Q-20.865246 102.36993 -21.003927 102.41956Q-21.142609 102.46918 -21.28356 102.51195Q-21.424507 102.5547 -21.567387 102.59048Q-21.710262 102.62628 -21.854725 102.65501Q-21.999187 102.68375 -22.144886 102.70537Q-22.290583 102.72697 -22.437166 102.74142Q-22.583748 102.75584 -22.730864 102.76308Q-22.877977 102.77029 -23.025269 102.7703Q-23.172562 102.77029 -23.319675 102.76307Q-23.466789 102.75584 -23.613371 102.74141Q-23.759953 102.72697 -23.905649 102.70535Q-24.051346 102.68375 -24.195808 102.65501Q-24.340271 102.62627 -24.483149 102.59048Q-24.626026 102.5547 -24.766975 102.51195Q-24.907925 102.46918 -25.046604 102.41956Q-25.185286 102.36993 -25.321365 102.31357Q-25.457445 102.2572 -25.590595 102.19423Q-25.723747 102.13126 -25.853647 102.06182Q-25.983547 101.99239 -26.109882 101.91666Q-26.236217 101.84094 -26.358686 101.75911Q-26.481155 101.67728 -26.599461 101.58954Q-26.717768 101.5018 -26.831627 101.40836Q-26.945484 101.31491 -27.054619 101.216Q-27.163754 101.11708 -27.267906 101.01293Q-27.372055 100.90878 -27.470972 100.79964Q-27.569887 100.69051 -27.66333 100.57665Q-27.756769 100.46279 -27.844509 100.34448Q-27.932251 100.22618 -28.014082 100.10371Q-28.095913 99.981247 -28.171638 99.854904Q-28.24736 99.728569 -28.316795 99.598671Q-28.386227 99.468765 -28.4492 99.335617Q-28.512177 99.202469 -28.568542 99.066391Q-28.624908 98.930313 -28.674532 98.791634Q-28.724154 98.652954 -28.766911 98.512009Q-28.809666 98.371056 -28.845455 98.228172Q-28.881245 98.085297 -28.909979 97.940834Q-28.938713 97.796371 -28.960327 97.650681Q-28.981939 97.504982 -28.996378 97.358398Q-29.010815 97.211823 -29.01804 97.064705Q-29.025269 96.917587 -29.025269 96.770302Q-29.025269 96.623009 -29.01804 96.475891Q-29.010813 96.328781 -28.996376 96.182198Q-28.981937 96.035614 -28.960325 95.889908Q-28.938713 95.744217 -28.909977 95.599747Q-28.881243 95.455284 -28.845455 95.312408Q-28.809666 95.169533 -28.766911 95.028587Q-28.724154 94.887634 -28.67453 94.748955Q-28.624908 94.610275 -28.568542 94.47419Q-28.512177 94.338112 -28.449203 94.204964Q-28.386227 94.071815 -28.316795 93.941917Q-28.24736 93.812012 -28.171638 93.685684Q-28.095915 93.559341 -28.014084 93.436874Q-27.932255 93.314407 -27.844513 93.196106Q-27.756771 93.077805 -27.663332 92.963943Q-27.569889 92.850082 -27.470974 92.740944Q-27.372059 92.631805 -27.267908 92.527657Q-27.163757 92.423508 -27.054621 92.324585Q-26.945484 92.225677 -26.831627 92.132233Q-26.71777 92.038788 -26.599463 91.95105Q-26.481157 91.863304 -26.358688 91.781479Q-26.236221 91.699646 -26.109886 91.623924Q-25.983547 91.548203 -25.853649 91.478775Q-25.723747 91.40934 -25.590597 91.346359Q-25.457447 91.283386 -25.321367 91.22702Q-25.185287 91.170654 -25.046606 91.12104Q-24.907925 91.071419 -24.766975 91.028664Q-24.626026 90.985901 -24.483149 90.950119Q-24.340271 90.914322 -24.195808 90.88559Q-24.051346 90.856857 -23.905649 90.835243Q-23.759953 90.813637 -23.613373 90.799202Q-23.466789 90.78476 -23.319675 90.777527Q-23.172562 90.770302 -23.025269 90.770302Q-22.877977 90.770302 -22.730862 90.777534Q-22.583748 90.78476 -22.437166 90.799202Q-22.290583 90.813637 -22.144884 90.835251Q-21.999187 90.856857 -21.854725 90.88559Q-21.710262 90.914322 -21.567387 90.950119Q-21.424507 90.985901 -21.28356 91.028664Q-21.142609 91.071419 -21.003929 91.121033Q-20.865246 91.170654 -20.729166 91.22702Q-20.593086 91.283386 -20.459936 91.346367Q-20.326786 91.40934 -20.196888 91.478775Q-20.066988 91.548203 -19.940651 91.623924Q-19.814314 91.699646 -19.691845 91.781479Q-19.569376 91.863304 -19.451071 91.951042Q-19.332766 92.038788 -19.218908 92.132233Q-19.105049 92.225677 -18.995913 92.324585Q-18.886776 92.423508 -18.782627 92.527657Q-18.678474 92.631805 -18.579559 92.740944Q-18.480644 92.850082 -18.387205 92.963943Q-18.293762 93.077805 -18.20602 93.196106Q-18.118279 93.314407 -18.036449 93.436874Q-17.95462 93.559341 -17.878897 93.685684Q-17.803175 93.812012 -17.733742 93.941917Q-17.664309 94.071815 -17.601332 94.204964Q-17.538357 94.338112 -17.481991 94.47419Q-17.425625 94.610275 -17.376003 94.748955Q-17.326382 94.887634 -17.283627 95.028587Q-17.240871 95.169533 -17.205082 95.312408Q-17.169292 95.455292 -17.140556 95.599754Q-17.11182 95.744217 -17.09021 95.889923Q-17.068598 96.035614 -17.054161 96.182205Q-17.039722 96.328781 -17.032495 96.475899Q-17.025269 96.623009 -17.025269 96.770302ZM-15.896496 80.174347Q-15.896496 80.32164 -15.903723 80.468758Q-15.91095 80.615875 -15.925387 80.762451Q-15.939824 80.909027 -15.961435 81.054718Q-15.983047 81.200417 -16.011782 81.344879Q-16.040516 81.489342 -16.076305 81.632217Q-16.112095 81.775101 -16.154852 81.916046Q-16.197609 82.056992 -16.247231 82.195679Q-16.296852 82.334358 -16.353216 82.470436Q-16.409584 82.606522 -16.472559 82.73967Q-16.535534 82.872818 -16.604967 83.002724Q-16.6744 83.132622 -16.750122 83.258957Q-16.825846 83.385284 -16.907677 83.507751Q-16.989508 83.630219 -17.07725 83.748535Q-17.164993 83.866837 -17.258434 83.980705Q-17.351875 84.094559 -17.45079 84.20369Q-17.549704 84.312828 -17.653854 84.416977Q-17.758007 84.521133 -17.867142 84.620041Q-17.976278 84.718964 -18.090137 84.812401Q-18.203995 84.905838 -18.3223 84.993576Q-18.440605 85.081314 -18.563074 85.163147Q-18.685543 85.24498 -18.811878 85.320709Q-18.938213 85.396431 -19.068115 85.465866Q-19.198013 85.535294 -19.331163 85.598267Q-19.464314 85.66124 -19.600393 85.717613Q-19.736473 85.773972 -19.875156 85.823593Q-20.01384 85.873222 -20.154789 85.915985Q-20.295738 85.95874 -20.438614 85.994522Q-20.58149 86.030319 -20.725952 86.059052Q-20.870415 86.087784 -21.016111 86.109406Q-21.16181 86.131012 -21.308393 86.145462Q-21.454975 86.159897 -21.602089 86.167114Q-21.749203 86.174347 -21.896496 86.174347Q-22.043787 86.174347 -22.190901 86.167114Q-22.338015 86.159897 -22.484596 86.145462Q-22.63118 86.131012 -22.776875 86.109398Q-22.922573 86.087784 -23.067036 86.059052Q-23.211498 86.030319 -23.354376 85.994522Q-23.497253 85.95874 -23.638205 85.915985Q-23.779152 85.873222 -23.917833 85.823601Q-24.056515 85.773972 -24.192593 85.717613Q-24.328674 85.66124 -24.461824 85.598267Q-24.594976 85.535294 -24.724876 85.465866Q-24.854776 85.396431 -24.981112 85.320709Q-25.107449 85.24498 -25.229916 85.163147Q-25.352385 85.081314 -25.470692 84.993576Q-25.588997 84.905838 -25.702856 84.812401Q-25.816713 84.718964 -25.925848 84.620041Q-26.034985 84.521133 -26.139135 84.416977Q-26.243286 84.312828 -26.342201 84.20369Q-26.441116 84.094559 -26.534557 83.980698Q-26.627998 83.866837 -26.71574 83.748528Q-26.803482 83.630219 -26.885311 83.507751Q-26.96714 83.385284 -27.042866 83.258957Q-27.118587 83.132614 -27.188023 83.002716Q-27.257456 82.872818 -27.320431 82.73967Q-27.383406 82.606522 -27.439772 82.470436Q-27.496138 82.334358 -27.545759 82.195679Q-27.595381 82.056992 -27.638138 81.916046Q-27.680893 81.775101 -27.716682 81.632217Q-27.75247 81.489342 -27.781206 81.344879Q-27.809942 81.200417 -27.831554 81.054726Q-27.853168 80.909027 -27.867607 80.762451Q-27.882042 80.615875 -27.889269 80.468758Q-27.896496 80.32164 -27.896496 80.174347Q-27.896496 80.027054 -27.889271 79.879944Q-27.882042 79.732826 -27.867603 79.586243Q-27.853165 79.439651 -27.831553 79.293961Q-27.80994 79.148254 -27.781204 79.003799Q-27.752468 78.859337 -27.716681 78.716461Q-27.680893 78.573578 -27.638138 78.432632Q-27.595379 78.291679 -27.545759 78.153Q-27.496136 78.014313 -27.43977 77.878235Q-27.383404 77.742157 -27.320429 77.609009Q-27.257454 77.475861 -27.188023 77.345963Q-27.118587 77.216057 -27.042864 77.089714Q-26.96714 76.963379 -26.885311 76.840912Q-26.80348 76.718445 -26.715738 76.600143Q-26.627998 76.481842 -26.534557 76.367981Q-26.441116 76.254128 -26.342199 76.144989Q-26.243284 76.035858 -26.139133 75.931702Q-26.034983 75.827545 -25.925848 75.728638Q-25.816711 75.629723 -25.702856 75.536285Q-25.588997 75.442841 -25.47069 75.355095Q-25.352385 75.267349 -25.229916 75.185516Q-25.107449 75.103683 -24.981112 75.027962Q-24.854776 74.95224 -24.724876 74.882812Q-24.594976 74.813385 -24.461826 74.750412Q-24.328674 74.687431 -24.192595 74.631065Q-24.056515 74.574707 -23.917835 74.525085Q-23.779152 74.475464 -23.638203 74.432709Q-23.497253 74.389954 -23.354374 74.354164Q-23.211498 74.318375 -23.067036 74.289635Q-22.922573 74.260895 -22.776876 74.239288Q-22.63118 74.217682 -22.484596 74.203247Q-22.338015 74.188812 -22.190901 74.18158Q-22.043787 74.174347 -21.896496 74.174347Q-21.749203 74.174347 -21.602089 74.18158Q-21.454975 74.188812 -21.308392 74.203247Q-21.161808 74.217682 -21.016111 74.239288Q-20.870415 74.260895 -20.725952 74.289635Q-20.58149 74.318375 -20.438614 74.354164Q-20.295736 74.389954 -20.154787 74.432709Q-20.013838 74.475464 -19.875156 74.525085Q-19.736473 74.574707 -19.600393 74.631065Q-19.464314 74.687431 -19.331163 74.750412Q-19.198013 74.813385 -19.068115 74.88282Q-18.938213 74.952255 -18.811878 75.027969Q-18.685543 75.103691 -18.563074 75.185524Q-18.440605 75.267349 -18.3223 75.355103Q-18.203995 75.442841 -18.090137 75.536285Q-17.976278 75.629723 -17.867142 75.728638Q-17.758007 75.827545 -17.653854 75.931702Q-17.549704 76.035858 -17.450788 76.144989Q-17.351875 76.254128 -17.258434 76.367981Q-17.164993 76.481842 -17.077251 76.600143Q-16.989508 76.718445 -16.907677 76.840912Q-16.825846 76.963379 -16.750122 77.089722Q-16.6744 77.216057 -16.604967 77.345963Q-16.535534 77.475861 -16.472557 77.609009Q-16.409584 77.742157 -16.353216 77.878235Q-16.296852 78.014313 -16.247231 78.152992Q-16.197609 78.291679 -16.154852 78.432632Q-16.112095 78.573578 -16.076307 78.716461Q-16.04052 78.859337 -16.011784 79.003799Q-15.983049 79.148254 -15.961437 79.293961Q-15.939825 79.439651 -15.925388 79.586243Q-15.910951 79.732826 -15.903723 79.879944Q-15.896496 80.027054 -15.896496 80.174347ZM-4.0784254 68.218697Q-4.0784254 68.36599 -4.0856524 68.513107Q-4.0928798 68.660225 -4.107317 68.806808Q-4.1217537 68.953384 -4.1433663 69.099083Q-4.164978 69.244781 -4.1937137 69.389236Q-4.2224488 69.533699 -4.2582378 69.676575Q-4.2940269 69.819458 -4.3367834 69.960403Q-4.37954 70.101349 -4.4291606 70.240036Q-4.4787817 70.378716 -4.5351477 70.514793Q-4.5915136 70.650879 -4.654489 70.784027Q-4.7174644 70.917175 -4.7868977 71.047073Q-4.8563299 71.176971 -4.9320531 71.303307Q-5.0077763 71.429642 -5.0896072 71.552109Q-5.1714382 71.674576 -5.2591801 71.792892Q-5.3469214 71.911201 -5.4403625 72.025063Q-5.5338035 72.138916 -5.632719 72.248055Q-5.7316337 72.357185 -5.8357844 72.461334Q-5.9399357 72.565491 -6.0490713 72.664406Q-6.1582079 72.763321 -6.2720661 72.856758Q-6.3859234 72.950195 -6.5042295 73.037933Q-6.6225348 73.125671 -6.7450037 73.207504Q-6.8674722 73.289337 -6.9938087 73.365059Q-7.1201448 73.440781 -7.2500448 73.510223Q-7.3799438 73.579651 -7.5130939 73.642624Q-7.646244 73.705597 -7.7823238 73.761963Q-7.9184036 73.818329 -8.057085 73.86795Q-8.1957664 73.917572 -8.3367167 73.960335Q-8.4776659 74.00309 -8.6205435 74.038879Q-8.7634211 74.074661 -8.9078827 74.103401Q-9.0523453 74.132141 -9.1980419 74.153748Q-9.3437405 74.175369 -9.4903231 74.189804Q-9.6369057 74.204239 -9.7840195 74.211464Q-9.9311333 74.218689 -10.078425 74.218697Q-10.225718 74.218689 -10.372831 74.211472Q-10.519946 74.204239 -10.666529 74.189804Q-10.813111 74.175369 -10.958809 74.153748Q-11.104506 74.132141 -11.248968 74.103401Q-11.393429 74.074661 -11.536306 74.038879Q-11.679184 74.003082 -11.820134 73.960327Q-11.961083 73.917572 -12.099764 73.86795Q-12.238446 73.818329 -12.374526 73.761963Q-12.510606 73.705597 -12.643756 73.642624Q-12.776907 73.579651 -12.906807 73.510216Q-13.036706 73.440781 -13.163042 73.365059Q-13.289379 73.289337 -13.411847 73.207504Q-13.534316 73.125671 -13.652622 73.037933Q-13.770927 72.950195 -13.884787 72.856758Q-13.998644 72.763321 -14.10778 72.664406Q-14.216915 72.565491 -14.321067 72.461334Q-14.425218 72.357185 -14.524133 72.248047Q-14.623048 72.138916 -14.716489 72.025055Q-14.809929 71.911194 -14.897671 71.792885Q-14.985413 71.674576 -15.067244 71.552109Q-15.149075 71.429642 -15.224797 71.303299Q-15.300521 71.176971 -15.369954 71.047073Q-15.439386 70.917175 -15.502361 70.784027Q-15.565336 70.650879 -15.621702 70.514793Q-15.678068 70.378716 -15.72769 70.240036Q-15.777311 70.101349 -15.820068 69.960403Q-15.862825 69.819458 -15.898614 69.676575Q-15.934403 69.533699 -15.963139 69.389236Q-15.991875 69.244781 -16.013487 69.099083Q-16.035099 68.953384 -16.049536 68.806808Q-16.063972 68.660225 -16.071199 68.513107Q-16.078426 68.36599 -16.078426 68.218697Q-16.078426 68.071404 -16.071199 67.924294Q-16.063972 67.777176 -16.049536 67.630592Q-16.035099 67.484009 -16.013485 67.33831Q-15.991873 67.192612 -15.963137 67.048149Q-15.934402 66.903687 -15.898613 66.760818Q-15.862823 66.617935 -15.820067 66.47699Q-15.777311 66.336037 -15.72769 66.19735Q-15.678068 66.05867 -15.621702 65.922592Q-15.565336 65.786514 -15.502361 65.653358Q-15.439386 65.520218 -15.369953 65.39032Q-15.30052 65.260414 -15.224796 65.134079Q-15.149074 65.007736 -15.067243 64.885269Q-14.985412 64.762802 -14.897671 64.644493Q-14.809929 64.526184 -14.716488 64.412331Q-14.623047 64.298477 -14.524132 64.189331Q-14.425217 64.0802 -14.321066 63.976051Q-14.216915 63.871902 -14.107779 63.772987Q-13.998642 63.674072 -13.884785 63.580635Q-13.770926 63.487194 -13.652621 63.399452Q-13.534315 63.311707 -13.411846 63.229877Q-13.289377 63.148048 -13.16304 63.072323Q-13.036705 62.996605 -12.906806 62.92717Q-12.776905 62.857735 -12.643755 62.794758Q-12.510605 62.731781 -12.374525 62.675415Q-12.238445 62.619049 -12.099763 62.569431Q-11.961082 62.519814 -11.820133 62.477058Q-11.679184 62.434299 -11.536305 62.39851Q-11.393428 62.36272 -11.248966 62.333984Q-11.104505 62.305248 -10.958807 62.283638Q-10.81311 62.262024 -10.666528 62.247593Q-10.519945 62.233154 -10.372831 62.225925Q-10.225718 62.218697 -10.078425 62.218697Q-9.9311333 62.218697 -9.7840195 62.225922Q-9.6369057 62.23315 -9.4903231 62.247589Q-9.3437405 62.262024 -9.1980429 62.283638Q-9.0523453 62.305248 -8.9078836 62.333984Q-8.763422 62.36272 -8.6205444 62.39851Q-8.4776669 62.434299 -8.3367176 62.477058Q-8.1957684 62.519814 -8.0570869 62.569431Q-7.9184046 62.619049 -7.7823248 62.675415Q-7.646245 62.731781 -7.5130949 62.794758Q-7.3799448 62.857735 -7.2500458 62.92717Q-7.1201458 62.996601 -6.9938087 63.072323Q-6.8674726 63.148041 -6.7450037 63.229874Q-6.6225352 63.311707 -6.5042295 63.399452Q-6.3859239 63.48719 -6.2720661 63.580635Q-6.1582079 63.674072 -6.0490723 63.772987Q-5.9399357 63.871902 -5.8357849 63.976051Q-5.7316341 64.0802 -5.632719 64.189339Q-5.5338039 64.298477 -5.4403629 64.412331Q-5.3469219 64.526184 -5.2591801 64.644493Q-5.1714382 64.762802 -5.0896077 64.885269Q-5.0077767 65.007736 -4.9320536 65.134071Q-4.8563309 65.260414 -4.7868981 65.39032Q-4.7174649 65.520218 -4.6544895 65.653358Q-4.5915136 65.786514 -4.5351477 65.922592Q-4.4787817 66.05867 -4.4291611 66.197357Q-4.37954 66.336037 -4.3367834 66.47699Q-4.2940269 66.617943 -4.2582378 66.760818Q-4.2224488 66.903702 -4.1937137 67.048157Q-4.164978 67.192612 -4.1433663 67.338318Q-4.1217537 67.484009 -4.107317 67.6306Q-4.0928798 67.777176 -4.0856524 67.924294Q-4.0784254 68.071404 -4.0784254 68.218697ZM12.490547 66.89431Q12.490547 67.041611 12.483319 67.188721Q12.476093 67.335838 12.461657 67.482422Q12.447218 67.628998 12.425607 67.774689Q12.403994 67.92038 12.375258 68.064842Q12.346523 68.209305 12.310735 68.35218Q12.274946 68.495064 12.232189 68.636017Q12.189432 68.776962 12.139811 68.915642Q12.090189 69.054321 12.033823 69.190399Q11.977457 69.326477 11.914481 69.459633Q11.851507 69.592781 11.782074 69.722687Q11.712641 69.852585 11.636917 69.97892Q11.561193 70.105255 11.479363 70.227722Q11.397532 70.350189 11.309791 70.468506Q11.22205 70.586807 11.12861 70.700668Q11.035169 70.814529 10.936254 70.92366Q10.837337 71.032799 10.733187 71.136948Q10.629036 71.241104 10.519899 71.340012Q10.410764 71.438934 10.296906 71.532372Q10.183048 71.625809 10.064742 71.713547Q9.9464369 71.801285 9.8239679 71.883118Q9.7014999 71.964951 9.5751638 72.040672Q9.4488277 72.116394 9.3189278 72.185829Q9.1890278 72.255264 9.0558767 72.318237Q8.9227266 72.38121 8.7866468 72.437576Q8.6505671 72.493942 8.5118856 72.543564Q8.3732052 72.593185 8.2322559 72.635941Q8.0913067 72.678696 7.9484282 72.714493Q7.8055506 72.750275 7.6610889 72.779015Q7.5166273 72.807739 7.3709302 72.829361Q7.2252331 72.850967 7.0786505 72.865417Q6.9320679 72.879852 6.7849531 72.887077Q6.6378384 72.89431 6.4905467 72.89431Q6.343255 72.89431 6.1961403 72.887085Q6.0490265 72.879852 5.9024439 72.865417Q5.7558613 72.850967 5.6101637 72.829361Q5.4644661 72.807739 5.3200045 72.779015Q5.1755428 72.750275 5.0326653 72.714493Q4.8897877 72.678703 4.7488384 72.635948Q4.6078892 72.593185 4.4692073 72.543564Q4.3305254 72.493942 4.1944456 72.437576Q4.0583658 72.38121 3.9252155 72.318237Q3.7920656 72.255264 3.6621659 72.185829Q3.5322661 72.116394 3.4059296 72.040665Q3.2795935 71.964951 3.1571248 71.883118Q3.0346563 71.801285 2.9163506 71.713547Q2.7980447 71.625809 2.6841869 71.532372Q2.5703287 71.438934 2.4611928 71.340012Q2.352057 71.241104 2.2479057 71.136948Q2.143755 71.032799 2.0448399 70.92366Q1.9459246 70.814529 1.8524837 70.700668Q1.7590429 70.586807 1.6713012 70.468498Q1.5835598 70.350189 1.5017288 70.227722Q1.4198978 70.105255 1.3441747 69.97892Q1.2684517 69.852585 1.199019 69.722687Q1.1295861 69.592781 1.0666108 69.459625Q1.0036354 69.326477 0.94726932 69.190399Q0.89090323 69.054321 0.84128213 68.915642Q0.79166114 68.776962 0.74890465 68.636017Q0.70614803 68.495064 0.67035908 68.352188Q0.63457012 68.209305 0.60583496 68.064842Q0.5770998 67.92038 0.55548763 67.774689Q0.53387547 67.628998 0.51943839 67.482414Q0.50500125 67.335831 0.497774 67.188721Q0.4905467 67.041611 0.4905467 66.89431Q0.4905467 66.747017 0.49777398 66.599899Q0.50500125 66.452789 0.51943839 66.306206Q0.53387547 66.159622 0.55548763 66.013924Q0.5770998 65.868225 0.60583496 65.72377Q0.63457012 65.579308 0.67035913 65.436432Q0.70614803 65.293549 0.74890465 65.152603Q0.79166114 65.01165 0.84128219 64.872971Q0.89090323 64.734283 0.94726932 64.598206Q1.0036354 64.462128 1.0666108 64.328979Q1.1295861 64.195831 1.199019 64.065933Q1.2684517 63.936031 1.3441747 63.809692Q1.4198978 63.683357 1.5017288 63.560886Q1.5835598 63.438416 1.6713012 63.32011Q1.759043 63.201805 1.8524839 63.087948Q1.9459248 62.974091 2.0448399 62.864952Q2.143755 62.755814 2.2479057 62.651665Q2.352057 62.547516 2.4611928 62.448601Q2.5703287 62.349686 2.6841869 62.256248Q2.7980447 62.162804 2.9163504 62.075062Q3.0346565 61.98732 3.157125 61.905487Q3.2795935 61.823654 3.40593 61.747936Q3.5322661 61.672211 3.6621661 61.602783Q3.7920656 61.533348 3.9252157 61.470371Q4.0583658 61.407394 4.1944456 61.351028Q4.3305254 61.294662 4.4692073 61.245041Q4.6078892 61.195423 4.7488384 61.152668Q4.8897877 61.109909 5.0326653 61.07412Q5.1755428 61.038334 5.3200045 61.009598Q5.4644661 60.980865 5.6101637 60.959251Q5.7558613 60.937641 5.9024444 60.923206Q6.0490265 60.908768 6.1961403 60.901539Q6.343255 60.89431 6.4905467 60.89431Q6.6378384 60.89431 6.7849531 60.901539Q6.9320674 60.908768 7.07865 60.923206Q7.2252326 60.937641 7.3709297 60.959251Q7.5166273 60.980865 7.6610889 61.009598Q7.8055506 61.038334 7.9484282 61.07412Q8.0913067 61.109909 8.2322559 61.152668Q8.3732052 61.195423 8.5118856 61.245045Q8.6505671 61.294662 8.7866468 61.351028Q8.9227266 61.407394 9.0558767 61.470371Q9.1890278 61.533348 9.3189278 61.602783Q9.4488268 61.672211 9.5751629 61.747936Q9.701499 61.823654 9.823967 61.905487Q9.9464359 61.98732 10.064742 62.075062Q10.183048 62.162804 10.296906 62.256245Q10.410764 62.349686 10.519899 62.448601Q10.629036 62.547516 10.733187 62.651665Q10.837337 62.755814 10.936253 62.864952Q11.035169 62.974091 11.12861 63.087952Q11.22205 63.201805 11.309792 63.32011Q11.397532 63.438416 11.479363 63.560883Q11.561193 63.683357 11.636917 63.809692Q11.712641 63.936031 11.782074 64.065933Q11.851507 64.195831 11.914482 64.328979Q11.977457 64.462128 12.033823 64.598206Q12.090189 64.734283 12.139811 64.872971Q12.189432 65.01165 12.232189 65.152603Q12.274946 65.293549 12.310735 65.436424Q12.346523 65.5793 12.375258 65.723763Q12.403994 65.868225 12.425606 66.013916Q12.447218 66.159622 12.461657 66.306206Q12.476093 66.452789 12.483319 66.599899Q12.490547 66.747017 12.490547 66.89431ZM26.107824 76.897507Q26.107822 77.0448 26.100597 77.19191Q26.093369 77.33902 26.078932 77.485611Q26.064495 77.632187 26.042881 77.777885Q26.021271 77.923584 25.992535 78.068047Q25.963799 78.212509 25.928011 78.355377Q25.892223 78.49826 25.849468 78.639214Q25.806709 78.780159 25.757088 78.918839Q25.707466 79.057518 25.6511 79.193596Q25.594734 79.329681 25.531757 79.46283Q25.468784 79.595978 25.399351 79.725883Q25.329918 79.855782 25.254194 79.982117Q25.178471 80.108452 25.09664 80.230919Q25.014809 80.353394 24.927067 80.471695Q24.839325 80.589996 24.745886 80.703857Q24.652443 80.817719 24.553528 80.926849Q24.454613 81.035988 24.350464 81.140137Q24.246311 81.244293 24.137177 81.343201Q24.028042 81.442123 23.914185 81.535568Q23.800325 81.629013 23.682018 81.716751Q23.563713 81.804489 23.441244 81.886322Q23.318775 81.968155 23.19244 82.043877Q23.066105 82.119598 22.936205 82.189034Q22.806305 82.258469 22.673153 82.321434Q22.540003 82.384415 22.403923 82.440773Q22.267841 82.497139 22.129162 82.546761Q21.990479 82.596382 21.849531 82.639145Q21.70858 82.6819 21.565704 82.717682Q21.422825 82.753479 21.278364 82.782211Q21.133904 82.810944 20.988207 82.832558Q20.84251 82.854172 20.695929 82.868622Q20.549347 82.883057 20.402231 82.890274Q20.255116 82.897507 20.107824 82.897507Q19.960533 82.897507 19.813419 82.890282Q19.666306 82.883057 19.519722 82.868622Q19.373138 82.854172 19.22744 82.832558Q19.081741 82.810944 18.937281 82.782211Q18.79282 82.753479 18.649942 82.71769Q18.507065 82.6819 18.366116 82.639145Q18.225166 82.59639 18.086485 82.546768Q17.947802 82.497147 17.811722 82.440781Q17.67564 82.384415 17.542492 82.321442Q17.409342 82.258469 17.279444 82.189034Q17.149542 82.119598 17.023205 82.043877Q16.89687 81.968155 16.774401 81.886322Q16.651932 81.804489 16.533627 81.716751Q16.415321 81.629013 16.301464 81.535568Q16.187607 81.442123 16.07847 81.343208Q15.969334 81.244293 15.865183 81.140144Q15.761032 81.035995 15.662117 80.926857Q15.563202 80.817719 15.469761 80.703865Q15.37632 80.590004 15.288578 80.471695Q15.200835 80.353394 15.119004 80.230927Q15.037172 80.108459 14.961451 79.982117Q14.885727 79.855782 14.816296 79.725883Q14.746862 79.595978 14.683887 79.46283Q14.620912 79.329681 14.564546 79.193604Q14.508179 79.057526 14.458559 78.918839Q14.408937 78.780159 14.366182 78.639214Q14.323425 78.49826 14.287636 78.355385Q14.251846 78.212509 14.223111 78.068047Q14.194376 77.923584 14.172764 77.777878Q14.151153 77.632187 14.136716 77.485611Q14.122278 77.33902 14.115051 77.19191Q14.107824 77.0448 14.107824 76.897507Q14.107824 76.750214 14.115051 76.603104Q14.122278 76.455986 14.136716 76.309402Q14.151153 76.162819 14.172764 76.01712Q14.194376 75.871422 14.223111 75.726959Q14.251846 75.582497 14.287636 75.439621Q14.323425 75.296738 14.366181 75.155792Q14.408937 75.014847 14.458558 74.87616Q14.508179 74.737473 14.564546 74.601395Q14.620912 74.465317 14.683887 74.332169Q14.746862 74.19902 14.816296 74.069122Q14.885728 73.939217 14.961452 73.812881Q15.037174 73.686539 15.119005 73.564072Q15.200836 73.441605 15.288578 73.323303Q15.37632 73.205002 15.469761 73.091141Q15.563202 72.97728 15.662117 72.868149Q15.761032 72.759018 15.865183 72.654861Q15.969334 72.550713 16.07847 72.451797Q16.187607 72.352882 16.301464 72.259445Q16.415321 72.166 16.533627 72.078262Q16.651932 71.990517 16.774401 71.908684Q16.89687 71.826859 17.023205 71.751129Q17.149542 71.675407 17.279444 71.605972Q17.409342 71.536545 17.542492 71.473564Q17.67564 71.410583 17.811722 71.354218Q17.947802 71.297852 18.086483 71.24823Q18.225166 71.198608 18.366116 71.155861Q18.507065 71.113098 18.649942 71.077309Q18.79282 71.041519 18.937281 71.012787Q19.081741 70.984055 19.22744 70.962448Q19.373138 70.940834 19.519722 70.926399Q19.666306 70.911957 19.813419 70.904739Q19.960533 70.897507 20.107824 70.897507Q20.255116 70.897507 20.402231 70.904732Q20.549347 70.911957 20.695929 70.926399Q20.84251 70.940834 20.988207 70.962448Q21.133904 70.984055 21.278364 71.012794Q21.422825 71.041527 21.565704 71.077316Q21.70858 71.113098 21.849531 71.155861Q21.990479 71.198608 22.129162 71.24823Q22.267841 71.297852 22.403923 71.354218Q22.540003 71.410583 22.673153 71.473564Q22.806305 71.536545 22.936205 71.605972Q23.066105 71.675407 23.19244 71.751129Q23.318775 71.826859 23.441244 71.908684Q23.563713 71.990517 23.68202 72.078255Q23.800325 72.166 23.914185 72.259445Q24.028042 72.352882 24.137177 72.451797Q24.246311 72.550713 24.350464 72.654861Q24.454613 72.759018 24.553528 72.868149Q24.652443 72.97728 24.745886 73.091141Q24.839325 73.205002 24.927067 73.323303Q25.014809 73.441605 25.096642 73.564072Q25.178473 73.686539 25.254196 73.812881Q25.32992 73.939217 25.399353 74.069122Q25.468784 74.19902 25.531759 74.332169Q25.594734 74.465317 25.6511 74.601395Q25.707466 74.737473 25.757086 74.87616Q25.806709 75.014847 25.849466 75.155792Q25.892223 75.296738 25.928009 75.439621Q25.963799 75.582497 25.992535 75.726959Q26.021271 75.871422 26.042883 76.01712Q26.064495 76.162819 26.078934 76.309402Q26.093369 76.455986 26.100597 76.603104Q26.107822 76.750214 26.107824 76.897507Z"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap new file mode 100644 index 0000000000..a9a4c6b760 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="344" height="294" viewBox="0 0 344 294"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(102 102)" d="M146 -6L0 -6L0 6L134 6L134 84L0 84L0 96L146 96L146 -6Z" clip-rule="evenodd"/> + <path transform="translate(102 102)" d="M-24 0L24 48L24 -48L-24 0Z"/> + <ellipse transform="translate(102 102)" cx="0" cy="90" rx="24" ry="24"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-48" y="-48" width="236" height="186" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 102 102)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap new file mode 100644 index 0000000000..f40de53a37 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(8 8)" d="M104 -4L0 -4L0 4L96 4L96 76L0 76L0 84L104 84L104 -4Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-8" y="-8" width="116" height="96" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 8 8)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap new file mode 100644 index 0000000000..19f8aedab2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"> + <path fill="none" stroke="red" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..0d3d73b1a8 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"> + <path fill="none" stroke="blue" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..8abc827a1f --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96"> + <path fill="none" stroke="green" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap new file mode 100644 index 0000000000..cd08038591 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88"> + <path fill="blue" transform="translate(-6 -6)" d="M28 6L10 6L10 14L28 14L28 6ZM64 6L46 6L46 14L64 14L64 6ZM100 6L82 6L82 14L100 14L100 6ZM114 36L114 18L106 18L106 36L114 36ZM6 28L6 46L14 46L14 28L6 28ZM114 72L114 54L106 54L106 72L114 72ZM6 64L6 82L14 82L14 64L6 64ZM20 94L38 94L38 86L20 86L20 94ZM55.999996 94L74 94L74 86L55.999996 86L55.999996 94ZM92 94L110 94L110 86L92 86L92 94Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap new file mode 100644 index 0000000000..036f56eab7 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100"> + <path fill="red" transform="translate(-10 -10)" d="M20 10L40 10L40 20L20 20L20 10ZM80 20L60 20L60 10L80 10L80 20ZM100 20L100 10L120 10L120 20L100 20ZM120 60L120 40L130 40L130 60L120 60ZM80 100L100 100L100 110L80 110L80 100ZM40 100L59.999996 100L59.999996 110L40 110L40 100ZM20 40L20 60L10 60L10 40L20 40ZM20 80L20 100L10 100L10 80L20 80ZM120 100L120 80L130 80L130 100L120 100Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap new file mode 100644 index 0000000000..82c042efce --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(-6 -6)" d="M10 14C12.666667 14 14 12.666667 14 10C14 7.333333 12.666667 6 10 6C7.333333 6 6 7.333333 6 10C6 12.666667 7.333333 14 10 14ZM23 14C25.666666 14 27 12.666667 27 10C27 7.333333 25.666666 6 23 6C20.333334 6 19 7.333333 19 10C19 12.666667 20.333334 14 23 14ZM36 14C38.666668 14 40 12.666667 40 10C40 7.333333 38.666668 6 36 6C33.333332 6 32 7.333333 32 10C32 12.666667 33.333332 14 36 14ZM49 14C51.666668 14 53 12.666667 53 10C53 7.333333 51.666668 6 49 6C46.333332 6 45 7.333333 45 10C45 12.666667 46.333332 14 49 14ZM62 14C64.666664 14 66 12.666667 66 10C66 7.333333 64.666664 6 62 6C59.333332 6 58 7.333333 58 10C58 12.666667 59.333332 14 62 14ZM75 14C77.666664 14 79 12.666667 79 10C79 7.333333 77.666664 6 75 6C72.333336 6 71 7.333333 71 10C71 12.666667 72.333336 14 75 14ZM88 14C90.666664 14 92 12.666667 92 10C92 7.333333 90.666664 6 88 6C85.333336 6 84 7.333333 84 10C84 12.666667 85.333336 14 88 14ZM101 14C103.66666 14 105 12.666667 105 10C105 7.333333 103.66666 6 101 6C98.333336 6 97 7.333333 97 10C97 12.666667 98.333336 14 101 14ZM110 18C112.66666 18 114 16.666666 114 14C114 11.333333 112.66666 10 110 10C107.33334 10 106 11.333333 106 14C106 16.666666 107.33334 18 110 18ZM10 23C12.666667 23 14 21.666666 14 19C14 16.333334 12.666667 15 10 15C7.333333 15 6 16.333334 6 19C6 21.666666 7.333333 23 10 23ZM110 31C112.66666 31 114 29.666666 114 27C114 24.333334 112.66666 23 110 23C107.33334 23 106 24.333334 106 27C106 29.666666 107.33334 31 110 31ZM10 36C12.666667 36 14 34.666668 14 32C14 29.333334 12.666667 28 10 28C7.333333 28 6 29.333334 6 32C6 34.666668 7.333333 36 10 36ZM110 44C112.66666 44 114 42.666668 114 40C114 37.333332 112.66666 36 110 36C107.33334 36 106 37.333332 106 40C106 42.666668 107.33334 44 110 44ZM10 49C12.666667 49 14 47.666668 14 45C14 42.333332 12.666667 41 10 41C7.333333 41 6 42.333332 6 45C6 47.666668 7.333333 49 10 49ZM110 57C112.66666 57 114 55.666668 114 53C114 50.333332 112.66666 49 110 49C107.33334 49 106 50.333332 106 53C106 55.666668 107.33334 57 110 57ZM10 62C12.666667 62 14 60.666668 14 58C14 55.333332 12.666667 54 10 54C7.333333 54 6 55.333332 6 58C6 60.666668 7.333333 62 10 62ZM110 70C112.66666 70 114 68.666664 114 66C114 63.333332 112.66666 62 110 62C107.33334 62 106 63.333332 106 66C106 68.666664 107.33334 70 110 70ZM10 75C12.666667 75 14 73.666664 14 71C14 68.333336 12.666667 67 10 67C7.333333 67 6 68.333336 6 71C6 73.666664 7.333333 75 10 75ZM110 83C112.66666 83 114 81.666664 114 79C114 76.333336 112.66666 75 110 75C107.33334 75 106 76.333336 106 79C106 81.666664 107.33334 83 110 83ZM10 88C12.666667 88 14 86.666664 14 84C14 81.333336 12.666667 80 10 80C7.333333 80 6 81.333336 6 84C6 86.666664 7.333333 88 10 88ZM17 94C19.666666 94 21 92.666664 21 90C21 87.333336 19.666666 86 17 86C14.333333 86 13 87.333336 13 90C13 92.666664 14.333333 94 17 94ZM30 94C32.666668 94 34 92.666664 34 90C34 87.333336 32.666668 86 30 86C27.333334 86 26 87.333336 26 90C26 92.666664 27.333334 94 30 94ZM43 94C45.666668 94 47 92.666664 47 90C47 87.333336 45.666668 86 43 86C40.333332 86 39 87.333336 39 90C39 92.666664 40.333332 94 43 94ZM55.999996 94C58.666664 94 59.999996 92.666664 59.999996 90C59.999996 87.333336 58.666664 86 55.999996 86C53.333328 86 51.999996 87.333336 51.999996 90C51.999996 92.666664 53.333328 94 55.999996 94ZM69 94C71.666664 94 73 92.666664 73 90C73 87.333336 71.666664 86 69 86C66.333336 86 65 87.333336 65 90C65 92.666664 66.333336 94 69 94ZM82 94C84.666664 94 86 92.666664 86 90C86 87.333336 84.666664 86 82 86C79.333336 86 78 87.333336 78 90C78 92.666664 79.333336 94 82 94ZM95 94C97.666664 94 99 92.666664 99 90C99 87.333336 97.666664 86 95 86C92.333336 86 91 87.333336 91 90C91 92.666664 92.333336 94 95 94ZM108 94C110.66666 94 112 92.666664 112 90C112 87.333336 110.66666 86 108 86C105.33334 86 104 87.333336 104 90C104 92.666664 105.33334 94 108 94Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="2" y="2" width="116" height="96" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -6 -6)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap new file mode 100644 index 0000000000..94e23b3876 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88"> + <path fill="blue" transform="translate(-6 -6)" d="M10 14C12.666667 14 14 12.666667 14 10C14 7.333333 12.666667 6 10 6C7.333333 6 6 7.333333 6 10C6 12.666667 7.333333 14 10 14ZM23 14C25.666666 14 27 12.666667 27 10C27 7.333333 25.666666 6 23 6C20.333334 6 19 7.333333 19 10C19 12.666667 20.333334 14 23 14ZM36 14C38.666668 14 40 12.666667 40 10C40 7.333333 38.666668 6 36 6C33.333332 6 32 7.333333 32 10C32 12.666667 33.333332 14 36 14ZM49 14C51.666668 14 53 12.666667 53 10C53 7.333333 51.666668 6 49 6C46.333332 6 45 7.333333 45 10C45 12.666667 46.333332 14 49 14ZM62 14C64.666664 14 66 12.666667 66 10C66 7.333333 64.666664 6 62 6C59.333332 6 58 7.333333 58 10C58 12.666667 59.333332 14 62 14ZM75 14C77.666664 14 79 12.666667 79 10C79 7.333333 77.666664 6 75 6C72.333336 6 71 7.333333 71 10C71 12.666667 72.333336 14 75 14ZM88 14C90.666664 14 92 12.666667 92 10C92 7.333333 90.666664 6 88 6C85.333336 6 84 7.333333 84 10C84 12.666667 85.333336 14 88 14ZM101 14C103.66666 14 105 12.666667 105 10C105 7.333333 103.66666 6 101 6C98.333336 6 97 7.333333 97 10C97 12.666667 98.333336 14 101 14ZM110 18C112.66666 18 114 16.666666 114 14C114 11.333333 112.66666 10 110 10C107.33334 10 106 11.333333 106 14C106 16.666666 107.33334 18 110 18ZM10 23C12.666667 23 14 21.666666 14 19C14 16.333334 12.666667 15 10 15C7.333333 15 6 16.333334 6 19C6 21.666666 7.333333 23 10 23ZM110 31C112.66666 31 114 29.666666 114 27C114 24.333334 112.66666 23 110 23C107.33334 23 106 24.333334 106 27C106 29.666666 107.33334 31 110 31ZM10 36C12.666667 36 14 34.666668 14 32C14 29.333334 12.666667 28 10 28C7.333333 28 6 29.333334 6 32C6 34.666668 7.333333 36 10 36ZM110 44C112.66666 44 114 42.666668 114 40C114 37.333332 112.66666 36 110 36C107.33334 36 106 37.333332 106 40C106 42.666668 107.33334 44 110 44ZM10 49C12.666667 49 14 47.666668 14 45C14 42.333332 12.666667 41 10 41C7.333333 41 6 42.333332 6 45C6 47.666668 7.333333 49 10 49ZM110 57C112.66666 57 114 55.666668 114 53C114 50.333332 112.66666 49 110 49C107.33334 49 106 50.333332 106 53C106 55.666668 107.33334 57 110 57ZM10 62C12.666667 62 14 60.666668 14 58C14 55.333332 12.666667 54 10 54C7.333333 54 6 55.333332 6 58C6 60.666668 7.333333 62 10 62ZM110 70C112.66666 70 114 68.666664 114 66C114 63.333332 112.66666 62 110 62C107.33334 62 106 63.333332 106 66C106 68.666664 107.33334 70 110 70ZM10 75C12.666667 75 14 73.666664 14 71C14 68.333336 12.666667 67 10 67C7.333333 67 6 68.333336 6 71C6 73.666664 7.333333 75 10 75ZM110 83C112.66666 83 114 81.666664 114 79C114 76.333336 112.66666 75 110 75C107.33334 75 106 76.333336 106 79C106 81.666664 107.33334 83 110 83ZM10 88C12.666667 88 14 86.666664 14 84C14 81.333336 12.666667 80 10 80C7.333333 80 6 81.333336 6 84C6 86.666664 7.333333 88 10 88ZM17 94C19.666666 94 21 92.666664 21 90C21 87.333336 19.666666 86 17 86C14.333333 86 13 87.333336 13 90C13 92.666664 14.333333 94 17 94ZM30 94C32.666668 94 34 92.666664 34 90C34 87.333336 32.666668 86 30 86C27.333334 86 26 87.333336 26 90C26 92.666664 27.333334 94 30 94ZM43 94C45.666668 94 47 92.666664 47 90C47 87.333336 45.666668 86 43 86C40.333332 86 39 87.333336 39 90C39 92.666664 40.333332 94 43 94ZM55.999996 94C58.666664 94 59.999996 92.666664 59.999996 90C59.999996 87.333336 58.666664 86 55.999996 86C53.333328 86 51.999996 87.333336 51.999996 90C51.999996 92.666664 53.333328 94 55.999996 94ZM69 94C71.666664 94 73 92.666664 73 90C73 87.333336 71.666664 86 69 86C66.333336 86 65 87.333336 65 90C65 92.666664 66.333336 94 69 94ZM82 94C84.666664 94 86 92.666664 86 90C86 87.333336 84.666664 86 82 86C79.333336 86 78 87.333336 78 90C78 92.666664 79.333336 94 82 94ZM95 94C97.666664 94 99 92.666664 99 90C99 87.333336 97.666664 86 95 86C92.333336 86 91 87.333336 91 90C91 92.666664 92.333336 94 95 94ZM108 94C110.66666 94 112 92.666664 112 90C112 87.333336 110.66666 86 108 86C105.33334 86 104 87.333336 104 90C104 92.666664 105.33334 94 108 94Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap new file mode 100644 index 0000000000..bf0ba3795e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <path fill="blue" transform="translate(-10 -10)" d="M110 10L10 10L10 90L110 90L110 10ZM16.614376 17.500002C16.928108 17.223316 17.223316 16.928108 17.500002 16.614376C19.490559 18.871458 21.990557 20 25 20C28.009443 20 30.509443 18.871458 32.5 16.614376C34.490559 18.871458 36.990559 20 40 20C43.009441 20 45.509441 18.871458 47.5 16.614376C49.490559 18.871458 51.990559 20 55 20C58.009441 20 60.509441 18.871458 62.5 16.614376C64.490562 18.871458 66.990562 20 70 20C73.009438 20 75.509438 18.871458 77.5 16.614376C79.490562 18.871458 81.990562 20 85 20C88.009438 20 90.509438 18.871458 92.5 16.614376C94.490562 18.871458 96.990562 20 100 20C100.43244 20 100.86308 19.972065 101.2919 19.916197C101.84457 20.895153 102.54248 21.75642 103.38562 22.499998C101.12854 24.490557 100 26.990557 100 30C100 33.009441 101.12854 35.509441 103.38562 37.5C101.12854 39.490559 100 41.990559 100 45C100 48.009441 101.12854 50.509441 103.38562 52.5C101.12854 54.490559 100 56.990559 100 60C100 63.009441 101.12854 65.509438 103.38562 67.5C101.12854 69.490562 100 71.990562 100 75C100 78.009438 101.12854 80.509438 103.38562 82.5C103.07189 82.77668 102.77668 83.071892 102.5 83.38562C100.50944 81.12854 98.009438 80 95 80C91.990562 80 89.490562 81.12854 87.5 83.38562C85.509438 81.12854 83.009438 80 80 80C76.990562 80 74.490562 81.12854 72.5 83.38562C70.509438 81.12854 68.009438 80 65 80C61.990559 80 59.490559 81.12854 57.5 83.385628C55.509441 81.12854 53.009438 80 49.999996 80C46.990555 80 44.490559 81.12854 42.5 83.38562C40.509441 81.12854 38.009441 80 35 80C31.990557 80 29.490559 81.12854 27.500002 83.38562C25.509443 81.12854 23.009443 80 20 80C19.567553 80 19.136921 80.027931 18.708101 80.083801C18.155426 79.104851 17.457518 78.243584 16.614376 77.5C18.871458 75.509438 20 73.009438 20 70C20 66.990562 18.871458 64.490562 16.614376 62.5C18.871458 60.509441 20 58.009441 20 55C20 51.990559 18.871458 49.490559 16.614376 47.5C18.871458 45.509441 20 43.009441 20 40C20 36.990559 18.871458 34.490559 16.614376 32.5C18.871458 30.509443 20 28.009443 20 25C20 21.990557 18.871458 19.490559 16.614376 17.500002Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap new file mode 100644 index 0000000000..b71a5e4ebc --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100"> + <path fill="red" transform="translate(-10 -10)" d="M50 10C46.990559 10 44.490559 11.128541 42.5 13.385624C40.509441 11.128541 38.009441 10 35 10C31.990557 10 29.490559 11.128541 27.500002 13.385624C25.509443 11.128541 23.009443 10 20 10C13.333333 10 10 13.333333 10 20C10 23.009443 11.128541 25.509441 13.385624 27.499998C11.128541 29.490557 10 31.990557 10 35C10 38.009441 11.128541 40.509441 13.385624 42.5C11.128541 44.490559 10 46.990559 10 50C10 53.009441 11.128541 55.509441 13.385624 57.5C11.128541 59.490559 10 61.990559 10 65C10 68.009438 11.128541 70.509438 13.385624 72.5C11.128541 74.490562 10 76.990562 10 80C10 83.009438 11.128541 85.509438 13.385624 87.5C11.128541 89.490562 10 91.990562 10 95C10 101.66666 13.333333 105 20 105C20.432447 105 20.863079 104.97207 21.291899 104.9162C23.205288 108.3054 26.107988 110 30 110C33.009441 110 35.509441 108.87146 37.5 106.61438C39.490559 108.87146 41.990559 110 45 110C48.009441 110 50.509438 108.87146 52.499996 106.61438C54.490555 108.87146 56.990555 110 59.999996 110C63.009438 110 65.509438 108.87146 67.5 106.61437C69.490555 108.87146 71.990555 110 75 110C78.009438 110 80.509438 108.87146 82.5 106.61438C84.490562 108.87146 86.990562 110 90 110C93.009438 110 95.509438 108.87146 97.5 106.61438C99.490562 108.87146 101.99056 110 105 110C108.00944 110 110.50944 108.87146 112.5 106.61438C114.49056 108.87146 116.99056 110 120 110C126.66666 110 130 106.66666 130 100C130 96.990562 128.87146 94.490562 126.61438 92.5C128.87146 90.509438 130 88.009438 130 85C130 81.990562 128.87146 79.490562 126.61438 77.5C128.87146 75.509438 130 73.009438 130 70C130 66.990562 128.87146 64.490562 126.61438 62.5C128.87146 60.509441 130 58.009441 130 55C130 51.990559 128.87146 49.490559 126.61438 47.5C128.87146 45.509441 130 43.009441 130 40C130 36.990559 128.87146 34.490559 126.61438 32.5C128.87146 30.509443 130 28.009443 130 25C130 18.333332 126.66666 15 120 15C119.56756 15 119.13692 15.027935 118.7081 15.083804C116.79472 11.694601 113.89201 10 110 10C106.99056 10 104.49056 11.128541 102.5 13.385624C100.50944 11.128541 98.009438 10 95 10C91.990562 10 89.490562 11.128541 87.5 13.385624C85.509438 11.128541 83.009438 10 80 10C76.990562 10 74.490562 11.128541 72.5 13.385624C70.509438 11.128541 68.009438 10 65 10C61.990559 10 59.490559 11.128541 57.5 13.385624C55.509441 11.128541 53.009441 10 50 10ZM120 20L20 20L20 100L120 100L120 20Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap new file mode 100644 index 0000000000..cac412a2da --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88"> + <path fill="blue" transform="translate(-6 -6)" d="M23 6L10 6L10 14L23 14L23 6ZM45 6L36 6L36 14L45 14L45 6ZM71 6L58 6L58 14L71 14L71 6ZM93 6L84 6L84 14L93 14L93 6ZM114 6L106 6L106 19L114 19L114 6ZM6 21L6 34L14 34L14 21L6 21ZM114 41L114 32L106 32L106 41L114 41ZM6 47L6 56L14 56L14 47L6 47ZM114 67L114 54L106 54L106 67L114 67ZM6 69L6 82L14 82L14 69L6 69ZM114 89L114 80L106 80L106 89L114 89ZM15 94L24 94L24 86L15 86L15 94ZM37 94L49.999996 94L49.999996 86L37 86L37 94ZM63 94L72 94L72 86L63 86L63 94ZM85 94L98 94L98 86L85 86L85 94Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap new file mode 100644 index 0000000000..5f9da0dcc4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="140" height="100" viewBox="0 0 140 100"> + <rect fill="#FFD400" width="140" height="100"/> + <path fill="#1040FF" d="M0 0L140 0L140 100L0 100L0 0ZM40 4L128 4L128 76L40 76L40 4Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap new file mode 100644 index 0000000000..198993db1f --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(-6 -6)" d="M114 6L6 6L6 94L114 94L114 6ZM14 86L14 14L106 14L106 86L14 86Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="2" y="2" width="116" height="96" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -6 -6)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap new file mode 100644 index 0000000000..83935d88e1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88"> + <rect fill="none" stroke="blue" stroke-width="8" stroke-miterlimit="4" transform="translate(-6 -6)" x="10" y="10" width="100" height="80"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap new file mode 100644 index 0000000000..51d4849123 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path d="M100 0L0 0L0 80L100 80L100 0ZM10 10L10 70L90 70L90 10L10 10Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..66caf336f1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <path fill="blue" transform="translate(-10 -10)" d="M110 10L10 10L10 90L110 90L110 10ZM20 20L20 80L100 80L100 20L20 20Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap new file mode 100644 index 0000000000..213181290d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> + <path transform="translate(-10 -10)" d="M10 10L130 10L130 110L10 110L10 10ZM120 20L20 20L20 100L120 100L120 20Z" clip-rule="evenodd"/> +</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="0" y="0" width="140" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..96183dfc50 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100"> + <path fill="red" transform="translate(-10 -10)" d="M10 10L130 10L130 110L10 110L10 10ZM120 20L20 20L20 100L120 100L120 20Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..bfc037fe93 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 454 +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="171.24356" height="156.60254" viewBox="0 0 171.24356 156.60254"> + <path fill="#FFD400" transform="matrix(0.866025 0.5 -0.5 0.866025 50 0)" d="M0 0L140 0L140 100L-9.5367432e-07 100L0 0Z"/> + <path fill="#1040FF" transform="matrix(0.866025 0.5 -0.5 0.866025 50 0)" d="M140 0L0 0L0 100L140 100L140 0ZM11.999999 88L12 12L128 12L128 88L11.999999 88Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..be951f44c1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 367 +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="204.02817" height="189.38715" viewBox="0 0 204.02817 189.38715"> + <rect fill="#FFD400" transform="matrix(0.866025 0.5 -0.5 0.866025 66.3923 16.3923)" width="140" height="100"/> + <path fill="#1040FF" transform="matrix(0.866025 0.5 -0.5 0.866025 66.3923 16.3923)" d="M-12 -12L152 -12L152 112L-12 112L-12 -12ZM140 0L0 0L0 100L140 100L140 0Z" fill-rule="evenodd"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap new file mode 100644 index 0000000000..ed30b2c40d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <rect fill="red" width="100" height="80"/> +</svg> diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 5ed0fc81c3..b7048272a0 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1,6 +1,9 @@ use super::fixtures::*; -use crate::shapes::{BlendMode, Fill, SolidColor}; +use crate::shapes::{ + radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor, + StrokeCap, StrokeKind, +}; use crate::state::ShapesPool; use crate::uuid::Uuid; @@ -83,6 +86,116 @@ fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() { insta::assert_snapshot!(svg); } +#[test] +fn exports_leaf_layer_blur_as_fe_gaussian_blur() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + let blur_value = 10.0; + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, blur_value))); + } + + let svg = render(&pool, id); + let expected_sigma = radius_to_sigma(blur_value * 1.0); + assert!( + svg.contains("<filter") && svg.contains("feGaussianBlur"), + "layer blur must emit an SVG filter: {svg}" + ); + assert!( + svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")), + "stdDeviation must match canvas radius_to_sigma(value * scale): {svg}" + ); + assert!( + svg.contains("filter=\"url(#blur"), + "shape group must reference the blur filter: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn skips_hidden_layer_blur() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0))); + } + + let svg = render(&pool, id); + assert!( + !svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"), + "hidden layer blur must not emit a filter: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_group_layer_blur_wrapping_children() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let a = uid(2); + let b = uid(3); + + add_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 100.0), + &[a, b], + ); + { + let group = pool.get_mut(&group_id).unwrap(); + group.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 6.0))); + } + + add_solid_rect( + &mut pool, + a, + group_id, + (0.0, 0.0, 90.0, 100.0), + skia::Color::from_rgb(0, 0, 255), + ); + add_solid_rect( + &mut pool, + b, + group_id, + (110.0, 0.0, 200.0, 100.0), + skia::Color::from_rgb(0, 200, 0), + ); + + let svg = render(&pool, group_id); + let expected_sigma = radius_to_sigma(6.0); + assert!( + svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")), + "group layer blur stdDeviation: {svg}" + ); + // Filter wrapper must open before child geometry. + let filter_pos = svg + .find("filter=\"url(#blur") + .expect("group filter wrapper"); + let child_pos = svg.find("fill=\"#").expect("child fill"); + assert!( + filter_pos < child_pos, + "group blur must wrap children: {svg}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_a_group_with_two_rects_and_group_opacity() { let mut pool = ShapesPool::new(); @@ -125,6 +238,36 @@ fn exports_a_group_with_two_rects_and_group_opacity() { insta::assert_snapshot!(svg); } +#[test] +fn loads_svg_raw_dom_like_wasm_upload() { + // Production paints svg-raw via Dom::render after set_shape_svg_raw_content. + // Native SkSVGCanvas does not serialize those draws, so the export string + // stays empty here; we assert Dom parse + that export does not panic. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_svg_raw( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 307.0, 243.0), + concat!( + r#"<svg xmlns="http://www.w3.org/2000/svg">"#, + r#"<text x="10" y="24" fill="black">HOLA</text>"#, + r#"</svg>"#, + ), + ); + + let resources = crate::render::RenderResources::try_new_headless().expect("headless"); + let font_manager = skia::FontMgr::from(resources.fonts.font_provider().clone()); + { + let shape = pool.get_mut(&id).unwrap(); + shape.update_svg_raw_content(font_manager); + assert!(shape.svg.is_some(), "Dom must parse like WASM upload"); + } + + let _svg = render(&pool, id); +} + #[test] fn exports_a_clipped_frame_with_overflowing_child() { let mut pool = ShapesPool::new(); @@ -208,6 +351,47 @@ fn exports_an_unclipped_frame_with_overflowing_child() { insta::assert_snapshot!(svg); } +#[test] +fn exports_clipped_frame_with_solid_outer_stroke() { + // Regression: frame content clip must not wrap strokes — outer strokes + // sit outside the selrect and would be fully clipped away. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_frame( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 140.0, 100.0), + skia::Color::from_rgb(0xee, 0xee, 0xee), + true, + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_stroke(solid_stroke( + StrokeKind::Outer, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + )); + } + + let svg = render(&pool, id); + assert!( + svg.contains("clip-path=\"url(#"), + "clipped frame must keep content clip: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "outer stroke must emit an evenodd outline: {svg}" + ); + let stroke_pos = svg.find("fill-rule=\"evenodd\"").expect("stroke outline"); + let clip_close = svg.find("</g>").expect("clip group close"); + assert!( + stroke_pos > clip_close, + "outer stroke must be outside the content clip group: {svg}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_text_with_multiple_solid_fills() { let mut pool = ShapesPool::new(); @@ -240,6 +424,661 @@ fn exports_text_with_multiple_solid_fills() { insta::assert_snapshot!(svg); } +#[test] +fn exports_rect_with_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + solid_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "inner stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_per_side_solid_inner_stroke() { + // Regression: solid Inner/Outer SVG expansion used stroke_to_path with a + // uniform width and ignored stroke.widths. Per-side must use the GPU + // evenodd band (top/right/bottom/left). + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = solid_stroke( + StrokeKind::Inner, + 20.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + stroke.widths = Some([4.0, 12.0, 24.0, 40.0]); // top, right, bottom, left + add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 100.0), stroke); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xff, 0xd4, 0x00, + )))]); + } + + let svg = render(&pool, id); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "per-side inner stroke must emit an evenodd band: {svg}" + ); + // Inner hole for (0,0)-(140,100) with [4,12,24,40]: (40,4)-(128,76). + // Uniform width=20 would incorrectly hole at (20,20)-(120,80). + assert!( + svg.contains("40") && svg.contains("128") && svg.contains("76"), + "per-side hole must reflect left=40 / right=12 / bottom=24, got: {svg}" + ); + assert!( + !svg.contains("M20 20") && !svg.contains("L20 20"), + "must not use uniform width=20 inset: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""), + "center stroke must keep a stroke attribute: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "center stroke must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + solid_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "outer stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rotated_rect_with_solid_outer_stroke() { + // Regression: outline strokes must use local selrect geometry. Baking + // `centered_transform` into the path (via rect_segments) while the leaf + // canvas also concatenates it double-rotates the stroke. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 140.0, 100.0), + solid_stroke( + StrokeKind::Outer, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ), + ); + { + let shape = pool.get_mut(&id).unwrap(); + // 30° rotation (cos≈0.866, sin=0.5), matching a workspace export case. + let c = 0.866_025_4_f32; + let s = 0.5_f32; + shape.set_transform(c, s, -s, c, 0.0, 0.0); + shape.set_rotation(30.0); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xff, 0xd4, 0x00, + )))]); + } + + let svg = render(&pool, id); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "rotated outer stroke must emit an evenodd outline: {svg}" + ); + // Fill rect + stroke path should both carry the same leaf CTM (one rotation). + assert!( + svg.matches("matrix(0.866025").count() >= 2, + "fill and stroke must each use the rotation matrix once: {svg}" + ); + // Outline path data must stay in local selrect space (roughly [-stroke, w+stroke]). + // Double rotation bakes world-space points into `d` before the CTM is applied. + let d_attr = svg + .split("d=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .unwrap_or(""); + let first_num = d_attr + .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .find(|s| !s.is_empty()) + .and_then(|s| s.parse::<f32>().ok()); + assert!( + matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)), + "stroke path d= should start in local coords, got {first_num:?} from {d_attr}: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "closed path inner stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rotated_closed_path_with_solid_inner_stroke() { + // Regression: path content is stored in parent space; stroke outlines must + // apply `to_path_transform` (like fills via get_skia_path) before drawing + // under the leaf `centered_transform`, or the stroke double-rotates. + use crate::shapes::Type; + + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 140.0, 100.0), + solid_stroke( + StrokeKind::Inner, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ), + ); + { + let shape = pool.get_mut(&id).unwrap(); + let c = 0.866_025_4_f32; + let s = 0.5_f32; + shape.set_transform(c, s, -s, c, 0.0, 0.0); + shape.set_rotation(30.0); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xff, 0xd4, 0x00, + )))]); + + // Bake rotation into path points (Penpot path storage model). + let bake = shape.centered_transform(); + if let Type::Path(ref mut path) = shape.shape_type { + path.transform(&bake); + let b = path.bounds(); + shape.set_selrect(b.min_x(), b.min_y(), b.max_x(), b.max_y()); + } + } + + let svg = render(&pool, id); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "rotated path inner stroke must emit an evenodd outline: {svg}" + ); + assert!( + svg.matches("matrix(0.866025").count() >= 2, + "fill and stroke must each use the rotation matrix once: {svg}" + ); + // Stroke outline `d` must stay in local (unrotated) space like the fill. + let stroke_d = svg + .split("fill-rule=\"evenodd\"") + .next() + .and_then(|before| before.rsplit("d=\"").next()) + .and_then(|s| s.split('"').next()) + .unwrap_or(""); + let first_num = stroke_d + .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .find(|s| !s.is_empty()) + .and_then(|s| s.parse::<f32>().ok()); + assert!( + matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)), + "stroke path d= should start in local coords, got {first_num:?} from {stroke_d}: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""), + "closed path center stroke must keep a stroke attribute: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "center stroke must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + // Path outer previously used save_layer+Clear (dropped by SkSVG). Must not + // be a bare stroked path with no visible paint. + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path outer stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_inner_stroke() { + // Open paths force Center alignment regardless of the requested kind. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""), + "open path inner stroke must render as center stroke: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "open path must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"red\"") || svg.to_ascii_lowercase().contains("stroke=\"#ff0000\""), + "open path center stroke must keep a stroke attribute: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "open path must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_outer_stroke() { + // Open paths force Center alignment regardless of the requested kind. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""), + "open path outer stroke must render as center stroke: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "open path must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + dotted_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "dotted inner stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + // PathEffect does not serialize; dots expand to filled outline geometry. + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "dotted center stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + dotted_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "dotted outer stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "closed path dotted inner stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path dotted center stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path dotted outer stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_dotted_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "open path dotted stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_dotted_stroke_and_caps() { + // Regression: dotted/dashed SVG expansion used stroke_to_path and returned + // before draw_stroke_geometry, so open-path caps (triangle/circle/…) were + // dropped. Caps must be overlaid after the expanded outline. + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = dotted_stroke( + StrokeKind::Center, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + stroke.cap_start = Some(StrokeCap::TriangleArrow); + stroke.cap_end = Some(StrokeCap::CircleMarker); + add_stroked_open_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 90.0), stroke); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"#1040FF\"") || svg.to_ascii_lowercase().contains("fill=\"#1040ff\""), + "dotted stroke with caps must emit filled geometry: {svg}" + ); + // Caps are separate filled draws (triangle + circle), not only the dotted outline. + assert!( + svg.matches("<path ").count() >= 2 || svg.contains("<circle"), + "expected separate cap geometry besides the dotted outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dashed_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + dashed_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "dashed center stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dashed_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + dashed_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "dashed outer stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dashed_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dashed_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path dashed inner stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_mixed_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + mixed_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "mixed center stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_mixed_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + mixed_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "closed path mixed outer stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_solid_text_with_font_face() { let mut pool = ShapesPool::new(); @@ -266,3 +1105,502 @@ fn exports_solid_text_with_font_face() { ); insta::assert_snapshot!(svg); } + +#[test] +fn exports_image_fill_on_text() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_image_text( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, + image_id, + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains("<image") && svg.contains(TEST_IMAGE_URL), + "text image fill must emit a linked <image>: {svg}" + ); + assert!( + svg.contains("clip-path=\"url(#"), + "text image fill must be clipped to glyph silhouette: {svg}" + ); + assert!( + svg.contains("<clipPath ") && svg.contains("<text"), + "clipPath must contain text glyphs: {svg}" + ); + assert!( + !svg.contains("data:image"), + "must not base64-embed the image: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_image_fill_as_linked_image() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_image_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_id, + true, + 255, + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains("<image"), + "image fill must emit an <image> element: {svg}" + ); + assert!( + svg.contains(TEST_IMAGE_URL), + "image href must use the registered URL: {svg}" + ); + assert!( + svg.contains("preserveAspectRatio=\"xMidYMid slice\""), + "keep-aspect image fill must slice: {svg}" + ); + assert!( + svg.contains("clip-path=\"url(#"), + "image fill must be clipped to shape geometry: {svg}" + ); + assert!( + !svg.contains("data:image"), + "must not base64-embed the image: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_image_fill_bounds_transform() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_rect_with_fills( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + vec![Fill::Image(ImageFill::new_with_transform( + image_id, + 255, + 200, + 100, + false, + Some(ImageFillTransform { + x: 0.25, + y: 0.5, + width: 0.5, + height: 0.25, + }), + ))], + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains(r#"x="25" y="40" width="50" height="20""#), + "linked image must keep the independent image bounds: {svg}" + ); +} + +#[test] +fn exports_mixed_solid_and_image_fills_in_order() { + // Image under a translucent solid; stretch (keep-aspect off); partial image + // opacity; shape not at the page origin (page translate in CTM). + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_rect_with_fills( + &mut pool, + id, + Uuid::nil(), + (100.0, 50.0, 508.0, 178.0), + vec![ + // fills[0] topmost — solid blue @ 50% + Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))), + // fills[1] underneath — linked image, stretch, ~50% opacity + Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)), + ], + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + let image_pos = svg.find("<image"); + let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\""); + assert!(image_pos.is_some(), "missing image fill: {svg}"); + assert!(blue_pos.is_some(), "missing top solid fill: {svg}"); + assert!( + image_pos.unwrap() < blue_pos.unwrap(), + "image (bottom) must appear before solid (top): {svg}" + ); + assert!( + svg.contains("preserveAspectRatio=\"none\""), + "mixed image fill should stretch when keep-aspect is off: {svg}" + ); + // 128/255 → ~0.50196 as f32 (not a rounded "0.5"). + assert!( + svg.contains("opacity=\"0.5019608\""), + "image fill opacity must be emitted: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_image_fill_on_closed_path() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_image_path(&mut pool, id, Uuid::nil(), true, image_id); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains("<image") && svg.contains(TEST_IMAGE_URL), + "closed path must emit a linked image fill: {svg}" + ); + assert!( + svg.contains("clip-path=\"url(#"), + "image fill must be clipped to the path: {svg}" + ); + // Clip geometry should be a path (triangle), not a plain rect. + assert!( + svg.contains("<path") || svg.contains(" d=\""), + "closed-path clip must use path geometry: {svg}" + ); + assert!( + svg.contains("preserveAspectRatio=\"xMidYMid slice\""), + "keep-aspect image fill on path: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_image_fill_on_open_path() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_image_path(&mut pool, id, Uuid::nil(), false, image_id); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains("<image") && svg.contains(TEST_IMAGE_URL), + "open path must still emit a linked image fill: {svg}" + ); + assert!( + svg.contains("clip-path=\"url(#"), + "image fill must be clipped to the open path geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_image_fill_on_frame() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_image_frame( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + image_id, + false, + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains("<image") && svg.contains(TEST_IMAGE_URL), + "frame background must emit a linked image fill: {svg}" + ); + assert!( + svg.contains("clip-path=\"url(#"), + "frame image fill must be clipped to the frame: {svg}" + ); + assert!( + svg.contains("preserveAspectRatio=\"xMidYMid slice\""), + "frame image fill should keep aspect: {svg}" + ); + // No nested board clip when clip_content is off. + assert!( + !svg.contains("id=\"clip0\""), + "unclipped frame should not wrap children in a board clip: {svg}" + ); + insta::assert_snapshot!(svg); +} + +fn assert_linked_image_stroke(svg: &str) { + assert!( + svg.contains("<image") && svg.contains(TEST_IMAGE_URL), + "image stroke must emit a linked <image>: {svg}" + ); + assert!( + svg.contains("imgstrokeclip") && svg.contains("clip-path=\"url(#"), + "image stroke must clip to the stroke outline: {svg}" + ); + assert!( + !svg.contains("data:image"), + "must not base64-embed the stroke image: {svg}" + ); +} + +fn assert_evenodd_stroke_clip(svg: &str) { + assert!( + svg.contains("clip-rule=\"evenodd\""), + "stroke clip must use clip-rule=evenodd: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "clipPath should rewrite fill-rule to clip-rule: {svg}" + ); +} + +#[test] +fn exports_rect_with_solid_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + image_solid_stroke(StrokeKind::Center, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_inner_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_solid_stroke(StrokeKind::Inner, 10.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + image_solid_stroke(StrokeKind::Outer, 10.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + image_dotted_stroke(StrokeKind::Center, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_solid_stroke(StrokeKind::Outer, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_solid_stroke(StrokeKind::Center, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_image_stroke_and_caps() { + // Caps go into the clip silhouette with the outline. Image dest must grow + // past stroke.delta() so triangle/circle markers stay textured. + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + let mut stroke = image_solid_stroke(StrokeKind::Center, 12.0, image_id); + stroke.cap_start = Some(StrokeCap::TriangleArrow); + stroke.cap_end = Some(StrokeCap::CircleMarker); + add_stroked_open_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 90.0), stroke); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + let clip = svg + .split("<clipPath") + .nth(1) + .and_then(|s| s.split("</clipPath>").next()) + .expect("imgstroke clipPath"); + assert!( + clip.matches("<path ").count() >= 2 + || clip.contains("<circle") + || clip.contains("<ellipse"), + "clip must include cap geometry besides the stroke outline: {svg}" + ); + // TriangleArrow margin is width*4 = 48. + assert!( + svg.contains(r#"x="-48""#) + && svg.contains(r#"y="-48""#) + && svg.contains(r#"width="236""#) + && svg.contains(r#"height="186""#), + "image dest must cover cap margin (48), not only stroke.delta: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_dotted_stroke(StrokeKind::Outer, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} diff --git a/render-wasm/src/render/svg/text.rs b/render-wasm/src/render/svg/text.rs index a7af393461..0be0416842 100644 --- a/render-wasm/src/render/svg/text.rs +++ b/render-wasm/src/render/svg/text.rs @@ -1,19 +1,122 @@ +use std::collections::HashSet; + use crate::error::Result; -use crate::shapes::Shape; +use crate::render::text; +use crate::shapes::{Fill, ImageFill, Shape}; +use crate::uuid::Uuid; use super::document::SvgLayerCanvas; -use crate::render::text; +use super::images::{emit_linked_image_element, xml_escape_attr}; +use crate::render::RenderResources; -/// Emits a text shape's fill as native `<text>` elements. +/// Emits a text shape's fills for SVG export. /// -/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice` -/// silently drops. Text strokes are handled separately in a later PR. -pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> { +/// Linked image fills become `<image href>` clipped to the glyph silhouette; +/// other fills go through Skia as native `<text>`. Strokes are a later PR. +pub(super) fn render_text_fill( + builder: &mut SvgLayerCanvas, + shared: &RenderResources, + element: &Shape, +) -> Result<()> { + let text_content = element.get_text_content(); + let text_content = text_content.new_bounds(element.selrect()); + let max_layers = text_content.max_fill_layers(); + if max_layers == 0 { + return Ok(()); + } + let matrix = element.centered_transform(); - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - text::paint_text_fill(canvas, element); - canvas.restore(); + + for layer in 0..max_layers { + let linked = linked_image_fills_at_layer(&text_content, layer, shared); + let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect(); + + for image_fill in &linked { + emit_text_image_fill(builder, shared, element, image_fill, layer)?; + } + + if layer_has_skia_fills(&text_content, layer, &skip_ids) { + let mut paragraph_builders = if skip_ids.is_empty() { + text_content.paragraph_builder_group_for_fill_layer(layer) + } else { + text_content + .paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids) + }; + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + text::paint_text_paragraphs(canvas, element, &mut paragraph_builders); + canvas.restore(); + } + } + + Ok(()) +} + +fn linked_image_fills_at_layer<'a>( + text_content: &'a crate::shapes::TextContent, + layer: usize, + shared: &RenderResources, +) -> Vec<&'a ImageFill> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for paragraph in text_content.paragraphs() { + for span in paragraph.children() { + if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) { + if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) { + out.push(img); + } + } + } + } + out +} + +fn layer_has_skia_fills( + text_content: &crate::shapes::TextContent, + layer: usize, + skip_ids: &HashSet<Uuid>, +) -> bool { + text_content.paragraphs().iter().any(|paragraph| { + paragraph + .children() + .iter() + .any(|span| match span.fills_from_bottom(layer) { + Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false, + Some(_) => true, + None => false, + }) + }) +} + +/// Linked `<image>` clipped to the opaque glyph silhouette for this image layer. +fn emit_text_image_fill( + builder: &mut SvgLayerCanvas, + shared: &RenderResources, + shape: &Shape, + image_fill: &ImageFill, + layer: usize, +) -> Result<()> { + let Some(url) = shared.images.source_url(&image_fill.id()) else { + return Ok(()); + }; + + let clip_id = builder.unique("txtimgclip"); + let text_content = shape.get_text_content().new_bounds(shape.selrect()); + let mut paragraph_builders = + text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id()); + + let canvas = builder.new_fragment(); + { + let cv: &skia_safe::Canvas = &canvas; + cv.save(); + cv.concat(&shape.centered_transform()); + text::paint_text_paragraphs(cv, shape, &mut paragraph_builders); + cv.restore(); + } + builder.finish_clip_path_fragment(&clip_id, canvas); + + let href = xml_escape_attr(url); + emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id); Ok(()) } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index ef41565af7..b2948f823e 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,15 +3,16 @@ use crate::{ error::Result, math::Rect, shapes::{ - add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, - ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign, + add_text_with_tabs, calculate_text_layout_data, set_paint_fill, Paragraph as TextParagraph, + ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent, + TextDecorationSegment, VerticalAlign, }, utils::{get_fallback_fonts, get_font_collection}, }; use skia_safe::{ self as skia, canvas::SaveLayerRec, - textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle}, + textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration}, Canvas, ImageFilter, Paint, }; @@ -374,85 +375,22 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC }; let mut y_accum = base_y + vertical_offset; - for group in paragraphs.iter() { + for (index, group) in paragraphs.iter().enumerate() { let Some(paragraph) = group.first() else { continue; }; paragraph.paint(canvas, (x, y_accum)); if draw_decorations { - paint_decorations_for_paragraph(canvas, paragraph, x, y_accum); + if let Some(text_paragraph) = text_content.paragraphs().get(index) { + for deco in decoration_segments(paragraph, text_paragraph, x, y_accum) { + draw_decoration_segment(canvas, &deco); + } + } } y_accum += paragraph.height(); } } -fn paint_decorations_for_paragraph( - canvas: &Canvas, - paragraph: &skia::textlayout::Paragraph, - x: f32, - y_accum: f32, -) { - let line_metrics = paragraph.get_line_metrics(); - for line in &line_metrics { - let style_metrics: Vec<_> = line - .get_style_metrics(line.start_index..line.end_index) - .into_iter() - .collect(); - let line_baseline = y_accum + line.baseline as f32; - let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = - calculate_decoration_metrics(&style_metrics, line_baseline); - for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { - let text_style = &style_metric.text_style; - let style_end = style_metrics - .get(i + 1) - .map(|(next_i, _)| *next_i) - .unwrap_or(line.end_index); - let seg_start = (*style_start).max(line.start_index); - let seg_end = style_end.min(line.end_index); - if seg_start >= seg_end { - continue; - } - let rects = paragraph.get_rects_for_range( - seg_start..seg_end, - skia::textlayout::RectHeightStyle::Tight, - skia::textlayout::RectWidthStyle::Tight, - ); - let (segment_width, actual_x_offset) = if !rects.is_empty() { - let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); - let skia_x_offset = rects - .first() - .map(|r| r.rect.left - line.left as f32) - .unwrap_or(0.0); - (total_width, skia_x_offset) - } else { - (0.0, 0.0) - }; - let text_left = x + line.left as f32 + actual_x_offset; - let text_width = segment_width; - if text_style.decoration().ty == TextDecoration::UNDERLINE { - draw_text_decorations( - canvas, - text_style, - Some(underline_y.unwrap_or(line_baseline)), - max_underline_thickness, - text_left, - text_width, - ); - } - if text_style.decoration().ty == TextDecoration::LINE_THROUGH { - draw_text_decorations( - canvas, - text_style, - Some(strike_y.unwrap_or(line_baseline)), - max_strike_thickness, - text_left, - text_width, - ); - } - } - } -} - #[allow(clippy::too_many_arguments)] fn render_text_on_canvas( canvas: &Canvas, @@ -530,22 +468,13 @@ fn render_text_on_canvas( } } -/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that -/// `SkSVGDevice` would drop. -pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) { - let text_content = shape.get_text_content(); - let text_content = text_content.new_bounds(shape.selrect()); - let max_layers = text_content.max_fill_layers(); - if max_layers == 0 { - return; - } - - // Each fill layer is painted separately so SkSVGDevice can emit `fill` - // attributes (merged shaders are dropped). Bottom layer first. - for layer in 0..max_layers { - let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer); - paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false); - } +/// Paints pre-built paragraph groups (SVG export path for selective fill layers). +pub fn paint_text_paragraphs( + canvas: &Canvas, + shape: &Shape, + paragraph_builder_groups: &mut [Vec<ParagraphBuilder>], +) { + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); } /// Lays out and paints paragraph builders without any layer management. @@ -593,14 +522,7 @@ fn paint_text_with_emoji_overlay( } for deco in ¶.decorations { - draw_text_decorations( - canvas, - &deco.text_style, - Some(deco.y), - deco.thickness, - deco.left, - deco.width, - ); + draw_decoration_segment(canvas, deco); } } } @@ -820,17 +742,9 @@ fn paint_emoji_opaque( .paint(canvas, (emoji_para.x, emoji_para.y)); for deco in &deco_para.decorations { - draw_text_decorations( - canvas, - &deco.text_style, - Some(deco.y), - deco.thickness, - deco.left, - deco.width, - ); - let r = decoration_rect(deco.y, deco.thickness, deco.left, deco.width); + draw_decoration_segment(canvas, deco); for (kind, paint) in stroke_decos { - draw_decoration_stroke(canvas, *kind, paint, r); + draw_decoration_stroke(canvas, *kind, paint, deco.rect()); } } canvas.restore(); @@ -1143,40 +1057,128 @@ pub fn render_outer_stroke( ) } -fn decoration_rect(y: f32, thickness: f32, text_left: f32, text_width: f32) -> skia_safe::Rect { - skia_safe::Rect::new( - text_left, - y - thickness / 2.0, - text_left + text_width, - y + thickness / 2.0, - ) +fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) { + let mut decoration_paint = deco.text_style.foreground(); + decoration_paint.set_anti_alias(true); + canvas.draw_rect(deco.rect(), &decoration_paint); } -fn draw_text_decorations( - canvas: &Canvas, - text_style: &TextStyle, - y: Option<f32>, - thickness: f32, - text_left: f32, - text_width: f32, -) { - if let Some(y) = y { - let r = decoration_rect(y, thickness, text_left, text_width); - let mut decoration_paint = text_style.foreground(); - decoration_paint.set_anti_alias(true); - canvas.draw_rect(r, &decoration_paint); +/// One decorated span clipped to a line: UTF-16 range, decoration and the +/// Skia style run it falls in (paint + font metrics). +type LineDecoration<'a> = (usize, usize, TextDecoration, &'a StyleMetrics<'a>); + +/// UTF-16 ranges of the spans that ask for a decoration we draw. +fn decorated_span_ranges(text_paragraph: &TextParagraph) -> Vec<(usize, usize, TextDecoration)> { + let mut ranges = Vec::new(); + let mut offset = 0; + for span in text_paragraph.children() { + let len = span.apply_text_transform().encode_utf16().count(); + match span.text_decoration { + Some(kind) + if kind == TextDecoration::UNDERLINE || kind == TextDecoration::LINE_THROUGH => + { + ranges.push((offset, offset + len, kind)) + } + _ => {} + } + offset += len; } + ranges } -pub fn calculate_decoration_metrics( - style_metrics: &Vec<(usize, &StyleMetrics)>, +/// Style run covering `offset`; runs are keyed by their start index. +fn style_metric_at<'a>( + style_metrics: &[(usize, &'a StyleMetrics<'a>)], + offset: usize, +) -> Option<&'a StyleMetrics<'a>> { + style_metrics + .iter() + .rev() + .find(|(start, _)| *start <= offset) + .map(|(_, metrics)| *metrics) +} + +/// Decoration bars for one laid out paragraph, in shape coordinates. +/// +/// Segmented by the model's spans, so which spans get a bar never depends on +/// how Skia grouped the line into style runs; the runs only supply the paint +/// and font metrics covering each segment. +pub fn decoration_segments( + skia_paragraph: &skia::textlayout::Paragraph, + text_paragraph: &TextParagraph, + x: f32, + y_accum: f32, +) -> Vec<TextDecorationSegment> { + let decorated = decorated_span_ranges(text_paragraph); + if decorated.is_empty() { + return Vec::new(); + } + + let mut segments = Vec::new(); + for line in &skia_paragraph.get_line_metrics() { + let style_metrics: Vec<_> = line + .get_style_metrics(line.start_index..line.end_index) + .into_iter() + .collect(); + let line_baseline = y_accum + line.baseline as f32; + + let line_decorations: Vec<LineDecoration<'_>> = decorated + .iter() + .filter_map(|&(start, end, kind)| { + let seg_start = start.max(line.start_index); + let seg_end = end.min(line.end_index); + if seg_start >= seg_end { + return None; + } + let metrics = style_metric_at(&style_metrics, seg_start)?; + Some((seg_start, seg_end, kind, metrics)) + }) + .collect(); + + let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = + calculate_decoration_metrics(&line_decorations, line_baseline); + + for (seg_start, seg_end, kind, metrics) in line_decorations { + let rects = skia_paragraph.get_rects_for_range( + seg_start..seg_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let (width, x_offset) = match rects.first() { + Some(first) => { + let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); + (total_width, first.rect.left - line.left as f32) + } + None => (0.0, 0.0), + }; + let (y, thickness) = if kind == TextDecoration::LINE_THROUGH { + (strike_y, max_strike_thickness) + } else { + (underline_y, max_underline_thickness) + }; + segments.push(TextDecorationSegment { + kind, + text_style: (*metrics.text_style).clone(), + y: y.unwrap_or(line_baseline), + thickness, + left: x + line.left as f32 + x_offset, + width, + }); + } + } + + segments +} + +fn calculate_decoration_metrics( + line_decorations: &[LineDecoration<'_>], line_baseline: f32, ) -> (f32, Option<f32>, f32, Option<f32>) { let mut max_underline_thickness: f32 = 0.0; let mut underline_y = None; let mut max_strike_thickness: f32 = 0.0; let mut strike_y = None; - for (_style_start, style_metric) in style_metrics.iter() { + for (_seg_start, _seg_end, kind, style_metric) in line_decorations.iter() { let font_metrics = style_metric.font_metrics; let font_size = font_metrics .cap_height @@ -1192,7 +1194,7 @@ pub fn calculate_decoration_metrics( let thickness = (font_metrics.underline_thickness().unwrap_or(1.0) * thickness_factor) .max(min_thickness); - if style_metric.text_style.decoration().ty == TextDecoration::UNDERLINE { + if *kind == TextDecoration::UNDERLINE { // Same gap from baseline to underline as in Chromium // (see https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/render_text.cc let gap_scaling = raw_font_size * 1.0 / 9.0; @@ -1201,7 +1203,7 @@ pub fn calculate_decoration_metrics( max_underline_thickness = max_underline_thickness.max(thickness); underline_y = Some(y); } - if style_metric.text_style.decoration().ty == TextDecoration::LINE_THROUGH { + if *kind == TextDecoration::LINE_THROUGH { let y = line_baseline + font_metrics .strikeout_position() @@ -1243,3 +1245,92 @@ pub fn calculate_decoration_metrics( // shadows::render_text_inner_shadows(self, &shape, &paths, antialias); // } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextSpan, TextTransform}; + use crate::uuid::Uuid; + + fn span( + text: &str, + decoration: Option<TextDecoration>, + transform: Option<TextTransform>, + ) -> TextSpan { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + decoration, + transform, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ) + } + + fn paragraph(spans: Vec<TextSpan>) -> TextParagraph { + TextParagraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + spans, + ) + } + + #[test] + fn decorated_ranges_follow_spans_not_paint_runs() { + let para = paragraph(vec![ + span("plain ", None, None), + span("under", Some(TextDecoration::UNDERLINE), None), + span(" plain ", None, None), + span("struck", Some(TextDecoration::LINE_THROUGH), None), + ]); + + assert_eq!( + decorated_span_ranges(¶), + vec![ + (6, 11, TextDecoration::UNDERLINE), + (18, 24, TextDecoration::LINE_THROUGH), + ] + ); + } + + #[test] + fn decorated_ranges_are_utf16_offsets_of_the_transformed_text() { + let para = paragraph(vec![ + span("🎉", None, None), + span( + "straße", + Some(TextDecoration::UNDERLINE), + Some(TextTransform::Uppercase), + ), + span("x", Some(TextDecoration::UNDERLINE), None), + ]); + + // The emoji takes two UTF-16 units and `ß` uppercases to `SS`. + assert_eq!( + decorated_span_ranges(¶), + vec![ + (2, 9, TextDecoration::UNDERLINE), + (9, 10, TextDecoration::UNDERLINE), + ] + ); + } + + #[test] + fn undecorated_paragraphs_have_no_ranges() { + let para = paragraph(vec![ + span("plain", None, None), + span("none", Some(TextDecoration::NO_DECORATION), None), + ]); + + assert!(decorated_span_ranges(¶).is_empty()); + } +} diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index b19117b9d4..f12f134489 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -2,7 +2,8 @@ use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ - merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type, + circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path, + BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -11,7 +12,7 @@ use super::shape_renderer::ShapeRenderer; use super::text; use super::RenderResources; use super::RenderState; -use super::{get_dest_rect, get_source_rect}; +use super::{get_dest_rect, get_image_dest_rect, get_source_rect}; // --------------------------------------------------------------------------- // VectorRenderer — implements ShapeRenderer for canvas-based vector export @@ -22,9 +23,10 @@ pub(super) struct VectorRenderer<'a> { canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32, - /// When `true`, multiple fills are composited into a single shader (PDF). - /// When `false`, each fill is drawn separately so SkSVGDevice can emit - /// `fill` attributes (SVG export). + /// When `true`, use PDF/GPU-friendly compositing (`merge_fills`, + /// `save_layer` for outer strokes). When `false` (SVG export), avoid + /// techniques that `SkSVGDevice` drops: draw fills individually and emit + /// solid Inner/Outer strokes as filled outlines. compose_fills: bool, } @@ -42,6 +44,18 @@ impl<'a> VectorRenderer<'a> { compose_fills, } } + + /// Layer-blur paint filter for this backend. + /// + /// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops + /// paint image-filters (the shape would vanish). Layer blur is re-emitted as + /// a native SVG `<filter>` wrapper instead. + fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> { + if !self.compose_fills { + return None; + } + shape.image_filter(1.) + } } impl ShapeRenderer for VectorRenderer<'_> { @@ -50,17 +64,24 @@ impl ShapeRenderer for VectorRenderer<'_> { return Ok(()); } + let blur_filter = self.layer_blur_filter(shape); let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); if !self.compose_fills || has_image_fills { // fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG). for fill in fills.iter().rev() { match fill { Fill::Image(image_fill) => { - draw_image_fill(self.shared, self.canvas, shape, image_fill)?; + draw_image_fill( + self.shared, + self.canvas, + shape, + image_fill, + blur_filter.as_ref(), + )?; } _ => { let mut paint = fill.to_paint(&shape.selrect, true); - if let Some(filter) = shape.image_filter(1.) { + if let Some(filter) = blur_filter.clone() { paint.set_image_filter(filter); } draw_shape_geometry(self.canvas, shape, &paint); @@ -73,7 +94,7 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut paint = merge_fills(fills, shape.selrect); paint.set_anti_alias(true); - if let Some(filter) = shape.image_filter(1.) { + if let Some(filter) = blur_filter { paint.set_image_filter(filter); } @@ -82,8 +103,16 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> { + let svg_export = !self.compose_fills; for stroke in strokes.iter().rev() { - draw_single_stroke(self.canvas, self.shared, self.scale, shape, stroke)?; + draw_single_stroke( + self.canvas, + self.shared, + self.scale, + shape, + stroke, + svg_export, + )?; } Ok(()) } @@ -114,7 +143,7 @@ impl ShapeRenderer for VectorRenderer<'_> { } let layer_bounds = shape.layer_bounds(); for shadow in shape.inner_shadows_visible() { - let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); + let paint = shadow.get_inner_shadow_paint(true, self.layer_blur_filter(shape).as_ref()); self.canvas.save_layer( &skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) @@ -153,7 +182,7 @@ impl ShapeRenderer for VectorRenderer<'_> { let text_content = text_content.new_bounds(shape.selrect()); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let blur_filter = shape.image_filter(1.); + let blur_filter = self.layer_blur_filter(shape); // Text drop shadows: one filter layer per shadow over fill + stroke // silhouettes (mirrors GPU `render_text_shadows`). @@ -343,6 +372,9 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn apply_blur_layer(&mut self, shape: &Shape) -> bool { + if !self.compose_fills { + return false; + } let blur = match shape.blur { Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b, _ => return false, @@ -840,9 +872,9 @@ fn render_frame( canvas.save_layer(&layer_rec); } - // Clip to frame bounds in the frame's own space, then undo the transform so - // children draw at their absolute coords while staying clipped (mirrors the - // GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam. + // Clip fills + children only. Strokes render outside the content clip so + // outer/center strokes are not trimmed (same as GPU render_shape_exit). + canvas.save(); if element.clip_content { canvas.concat(&matrix); clip_to_frame_content(canvas, element, scale); @@ -866,8 +898,9 @@ fn render_frame( for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; } + canvas.restore(); // content clip - // Strokes over children (clipped frames), in the frame's space. + // Strokes over children, outside the frame content clip. let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { canvas.save(); @@ -1022,6 +1055,7 @@ fn draw_image_fill( canvas: &Canvas, shape: &Shape, image_fill: &crate::shapes::ImageFill, + blur_filter: Option<&skia::ImageFilter>, ) -> Result<()> { // Use a CPU-backed image copy — GPU-backed images can't be drawn // on the PDF canvas which has no GPU context. @@ -1032,8 +1066,8 @@ fn draw_image_fill( let size = image.dimensions(); let container = &shape.selrect; - let src_rect = get_source_rect(size, container, image_fill); - let dest_rect = container; + let dest_rect = get_image_dest_rect(container, image_fill); + let src_rect = get_source_rect(size, &dest_rect, image_fill); canvas.save(); @@ -1042,8 +1076,8 @@ fn draw_image_fill( let mut paint = Paint::default(); paint.set_anti_alias(true); - if let Some(filter) = shape.image_filter(1.) { - paint.set_image_filter(filter); + if let Some(filter) = blur_filter { + paint.set_image_filter(filter.clone()); } canvas.draw_image_rect_with_sampling_options( @@ -1064,16 +1098,186 @@ fn draw_single_stroke( scale: f32, shape: &Shape, stroke: &Stroke, + svg_export: bool, ) -> Result<()> { // Image-fill strokes: the stroke masks the visible area of the image. if let Fill::Image(image_fill) = &stroke.fill { return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill); } + // Techniques SkSVGDevice cannot keep (save_layer+Clear/clip for Outer, + // PathEffect stamps for dots/dashes): expand to a filled outline instead. + // Solid Center stays on the shared stroke path. + if svg_export && draw_svg_stroke_as_fill(canvas, shape, stroke) { + return Ok(()); + } + draw_stroke_geometry(canvas, scale, shape, stroke, false); Ok(()) } +/// Shape path in local coords for SVG stroke outline expansion. +fn svg_stroke_shape_path(shape: &Shape) -> Option<Path> { + match &shape.shape_type { + Type::Rect(r) => Some(Path::new(rect_segments_local(shape, r.corners))), + Type::Frame(f) => Some(Path::new(rect_segments_local(shape, f.corners))), + Type::Circle => Some(Path::new(circle_segments_local(shape))), + Type::Path(_) | Type::Bool(_) => { + let path = shape.shape_type.path()?; + let mut local = path.clone(); + if let Some(t) = shape.to_path_transform() { + local.transform(&t); + } + Some(local) + } + Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => None, + } +} + +fn svg_stroke_solid_outline(stroke: &Stroke, is_open: bool) -> Option<bool> { + let kind = stroke.render_kind(is_open); + match stroke.style { + StrokeStyle::Solid => match kind { + // Solid Center already serializes as a native SVG stroke. + StrokeKind::Center => None, + StrokeKind::Inner | StrokeKind::Outer => { + if is_open { + None + } else { + Some(true) + } + } + }, + // PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them. + StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => Some(false), + } +} + +/// Draws a stroke as a filled path outline for SVG export. +/// +/// Handles solid Inner/Outer and all dotted/dashed/mixed alignments (including +/// Center and open paths, which force Center). Returns `true` when handled. +fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> bool { + let is_open = shape.is_open(); + + // Per-side rect/frame strokes already expand to an evenodd band in + // `draw_stroke_on_rect`. `stroke_to_path` only knows a uniform width. + if stroke.per_side_widths().is_some() + && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) + { + return false; + } + + let Some(solid_outline) = svg_stroke_solid_outline(stroke, is_open) else { + return false; + }; + + let Some(shape_path) = svg_stroke_shape_path(shape) else { + return false; + }; + + let Some(outline) = stroke_to_path( + stroke, + &shape_path, + None, + &shape.selrect, + shape.svg_attrs.as_ref(), + solid_outline, + ) else { + return false; + }; + + let mut paint = stroke.fill.to_paint(&shape.selrect, true); + paint.set_style(skia::PaintStyle::Fill); + paint.set_anti_alias(true); + canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); + + // Expanded dotted/dashed strokes skip `draw_stroke_geometry`, which is + // where open-path caps are drawn. Overlay them here in local path space + // (same as fills / the outline above under the leaf CTM). + if is_open { + paint_svg_stroke_caps(canvas, shape, stroke, false); + } + + true +} + +/// Opaque stroke region for SVG clipPath silhouettes. +/// +/// Expands every alignment (including solid Center) to a filled outline so we +/// do not rely on save_layer + SrcIn. Returns false when there is nothing to draw. +pub(super) fn paint_svg_stroke_silhouette( + canvas: &Canvas, + shape: &Shape, + stroke: &Stroke, + scale: f32, +) -> bool { + let is_open = shape.is_open(); + + if stroke.per_side_widths().is_some() + && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) + { + let corners = shape.shape_type.corners(); + let mut paint = stroke.to_paint(&shape.selrect, shape.svg_attrs.as_ref(), true); + paint.set_shader(None); + paint.set_color(skia::Color::BLACK); + super::strokes::draw_stroke_on_rect( + canvas, + stroke, + &shape.selrect, + &corners, + &paint, + scale, + None, + None, + true, + ); + return true; + } + + let Some(shape_path) = svg_stroke_shape_path(shape) else { + return false; + }; + + // Expand Center too: a native stroke attribute cannot clip an image. + let solid_outline = matches!(stroke.style, StrokeStyle::Solid); + let Some(outline) = stroke_to_path( + stroke, + &shape_path, + None, + &shape.selrect, + shape.svg_attrs.as_ref(), + solid_outline, + ) else { + return false; + }; + + let mut paint = Paint::default(); + paint.set_style(skia::PaintStyle::Fill); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); + + if is_open { + paint_svg_stroke_caps(canvas, shape, stroke, true); + } + + true +} + +fn paint_svg_stroke_caps(canvas: &Canvas, shape: &Shape, stroke: &Stroke, opaque: bool) { + let Some(cap_path) = transformed_skia_path(shape) else { + return; + }; + let mut cap_paint = + stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true); + if opaque { + cap_paint.set_shader(None); + cap_paint.set_color(skia::Color::BLACK); + } + super::strokes::handle_stroke_caps(&cap_path, stroke, canvas, true, &cap_paint, None, true); +} + /// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle /// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width /// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette. diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 2db654e799..bb3c74f7f0 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -652,6 +652,13 @@ impl Shape { self.background_blur.filter(|blur| !blur.hidden) } + /// Visible layer blur (`!hidden`, `LayerBlur`, `value > 0`). + pub fn visible_layer_blur(&self) -> Option<Blur> { + self.blur.filter(|blur| { + !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 + }) + } + #[cfg(test)] pub fn add_child(&mut self, id: Uuid) { self.children.push(id); @@ -2059,6 +2066,51 @@ mod tests { assert_eq!(shape.visible_background_blur(), None); } + /// Cases mirrored from Penpot MCP board `layer-blur-cases` (file "blur"): + /// leaf-visible-blur, leaf-hidden-blur, leaf-zero-blur, leaf-background-blur, + /// group-with-blur. + #[test] + fn visible_layer_blur_requires_non_hidden_positive_layer_blur() { + let mut shape = any_shape(); + + // leaf-visible-blur / group-with-blur + let visible = Blur::new(BlurType::LayerBlur, false, 10.0); + shape.set_blur(Some(visible)); + assert_eq!(shape.visible_layer_blur(), Some(visible)); + + let group_blur = Blur::new(BlurType::LayerBlur, false, 6.0); + shape.set_blur(Some(group_blur)); + assert_eq!(shape.visible_layer_blur(), Some(group_blur)); + + // leaf-hidden-blur + shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0))); + assert_eq!(shape.visible_layer_blur(), None); + + // leaf-zero-blur + shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 0.0))); + assert_eq!(shape.visible_layer_blur(), None); + + // no blur + shape.set_blur(None); + assert_eq!(shape.visible_layer_blur(), None); + } + + #[test] + fn visible_layer_blur_ignores_background_blur() { + let mut shape = any_shape(); + // leaf-background-blur: Plugin API uses `backgroundBlur`, not `blur`. + shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0))); + assert_eq!(shape.visible_layer_blur(), None); + assert_eq!( + shape.visible_background_blur(), + Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)) + ); + + let layer = Blur::new(BlurType::LayerBlur, false, 4.0); + shape.set_blur(Some(layer)); + assert_eq!(shape.visible_layer_blur(), Some(layer)); + } + #[test] fn test_set_corners() { let mut shape = any_shape(); diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 15d5dc09f7..1faeb54185 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -118,6 +118,14 @@ impl Gradient { } } +#[derive(Debug, Clone, PartialEq, Copy)] +pub struct ImageFillTransform { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + #[derive(Debug, Clone, PartialEq)] pub struct ImageFill { id: Uuid, @@ -125,6 +133,7 @@ pub struct ImageFill { width: i32, height: i32, keep_aspect_ratio: bool, + transform: Option<ImageFillTransform>, } impl ImageFill { @@ -135,6 +144,25 @@ impl ImageFill { width, height, keep_aspect_ratio, + transform: None, + } + } + + pub fn new_with_transform( + id: Uuid, + opacity: u8, + width: i32, + height: i32, + keep_aspect_ratio: bool, + transform: Option<ImageFillTransform>, + ) -> Self { + Self { + id, + opacity, + width, + height, + keep_aspect_ratio, + transform, } } @@ -157,6 +185,10 @@ impl ImageFill { pub fn height(&self) -> i32 { self.height } + + pub fn transform(&self) -> Option<&ImageFillTransform> { + self.transform.as_ref() + } } #[derive(Debug, Clone, PartialEq, Copy)] diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index da6289b289..1898ed7276 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -12,8 +12,8 @@ use common::GetBounds; use crate::error::Result; use crate::shapes; use crate::shapes::{ - ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry, - TransformEntrySource, Type, + ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape, + TransformEntry, TransformEntrySource, Type, }; use crate::state::{ShapesPoolRef, State}; use crate::uuid::Uuid; @@ -139,36 +139,84 @@ fn calculate_bool_bounds( Some(result) } -fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { - let tr = bounds.transform_matrix().unwrap_or_default(); - let tr_inv = tr.invert().unwrap_or_default(); +/// Which parts of the geometry a pixel-grid correction rounds: only the ones +/// the transform changes, so a move keeps its dimensions and a resize keeps +/// its anchored corner. +#[derive(PartialEq, Debug, Clone, Copy)] +struct SnapGeometry { + x: bool, + y: bool, + width: bool, + height: bool, +} - let x = bounds.min_x().round(); - let y = bounds.min_y().round(); +impl SnapGeometry { + /// Flags the properties that differ between the two bounds. The axis mask + /// in `precision` applies to the position only. + fn new(before: &Bounds, after: &Bounds, precision: PixelPrecision) -> Self { + SnapGeometry { + x: precision.rounds_x() && !is_close_to(before.min_x(), after.min_x()), + y: precision.rounds_y() && !is_close_to(before.min_y(), after.min_y()), + width: !is_close_to(before.width(), after.width()), + height: !is_close_to(before.height(), after.height()), + } + } - let width = bounds.width(); - let height = bounds.height(); + fn resized(&self) -> bool { + self.width || self.height + } - let target_width = bounds.width().round(); - let target_height = bounds.height().round(); + fn any(&self) -> bool { + self.x || self.y || self.resized() + } +} - let scale_width = if width > 0.1 { - f32::max(0.01, target_width / width) +/// Rounds a transform so the parts of the shape the gesture changed land on +/// the pixel grid, leaving everything else exactly where it is. +fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds, snap: SnapGeometry) { + // Target corner, taken before the size correction: that correction scales + // about the bounds center, and the translation below undoes the corner + // displacement it causes. An unsnapped axis targets its own value. + let x = if snap.x { + bounds.min_x().round() } else { - 1.0 + bounds.min_x() }; - let scale_height = if height > 0.1 { - f32::max(0.01, target_height / height) + let y = if snap.y { + bounds.min_y().round() } else { - 1.0 + bounds.min_y() }; - if f32::is_finite(scale_width) && f32::is_finite(scale_height) { - let mut round_transform = Matrix::scale((scale_width, scale_height)); - round_transform.post_concat(&tr); - round_transform.pre_concat(&tr_inv); - transform.post_concat(&round_transform); - bounds.transform_mut(&round_transform); + if snap.resized() { + let tr = bounds.transform_matrix().unwrap_or_default(); + let tr_inv = tr.invert().unwrap_or_default(); + + let width = bounds.width(); + let height = bounds.height(); + + // A rounded dimension is never smaller than one pixel. + let target_width = f32::max(1.0, width.round()); + let target_height = f32::max(1.0, height.round()); + + let scale_width = if snap.width && width > 0.1 { + f32::max(0.01, target_width / width) + } else { + 1.0 + }; + let scale_height = if snap.height && height > 0.1 { + f32::max(0.01, target_height / height) + } else { + 1.0 + }; + + if f32::is_finite(scale_width) && f32::is_finite(scale_height) { + let mut round_transform = Matrix::scale((scale_width, scale_height)); + round_transform.post_concat(&tr); + round_transform.pre_concat(&tr_inv); + transform.post_concat(&round_transform); + bounds.transform_mut(&round_transform); + } } let dx = x - bounds.min_x(); @@ -184,7 +232,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { #[allow(clippy::too_many_arguments)] fn propagate_transform( entry: TransformEntry, - pixel_precision: bool, + pixel_precision: PixelPrecision, state: &State, entries: &mut VecDeque<Modifier>, bounds: &mut HashMap<Uuid, Bounds>, @@ -286,8 +334,11 @@ fn propagate_transform( } } - if pixel_precision { - set_pixel_precision(&mut transform, &mut shape_bounds_after); + if pixel_precision.enabled() { + let snap = SnapGeometry::new(&shape_bounds_before, &shape_bounds_after, pixel_precision); + if snap.any() { + set_pixel_precision(&mut transform, &mut shape_bounds_after, snap); + } } if entry.propagate { @@ -417,10 +468,15 @@ fn reflow_shape( Ok(()) } +/// Propagates a set of transforms through the shape tree, returning one +/// transform per affected shape. +/// +/// The transforms are relative to the committed geometry, so callers clear +/// any transform modifier of their own before propagating. pub fn propagate_modifiers( state: &State, modifiers: &[TransformEntry], - pixel_precision: bool, + pixel_precision: PixelPrecision, ) -> Result<Vec<TransformEntry>> { let mut entries: VecDeque<_> = modifiers .iter() @@ -567,6 +623,249 @@ mod tests { assert_eq!(result.len(), 1); } + #[test] + fn test_pixel_precision_move_keeps_size() { + let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3)); + let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 3.7))); + let mut transform = Matrix::translate((5.2, 3.7)); + + let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both); + set_pixel_precision(&mut transform, &mut bounds_after, snap); + + assert!(is_close_to(bounds_after.width(), 100.5)); + assert!(is_close_to(bounds_after.height(), 50.3)); + assert!(is_close_to(bounds_after.min_x(), 16.0)); + assert!(is_close_to(bounds_after.min_y(), 24.0)); + assert!(math::is_move_only_matrix(&transform)); + } + + #[test] + fn test_pixel_precision_resize_rounds_size() { + let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3)); + let mut bounds_after = bounds.transform(&Matrix::scale((1.1, 1.1))); + let mut transform = Matrix::scale((1.1, 1.1)); + + let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both); + set_pixel_precision(&mut transform, &mut bounds_after, snap); + + assert!(is_close_to( + bounds_after.width(), + bounds_after.width().round() + )); + assert!(is_close_to( + bounds_after.height(), + bounds_after.height().round() + )); + assert!(is_close_to( + bounds_after.min_x(), + bounds_after.min_x().round() + )); + assert!(is_close_to( + bounds_after.min_y(), + bounds_after.min_y().round() + )); + } + + #[test] + fn test_propagate_pixel_precision_move_only_rounds_position() { + let shape_id = Uuid::new_v4(); + let mut state = State::new(); + state.shapes.initialize(10); + { + let shape = state.shapes.add_shape(shape_id); + shape.set_selrect(10.4, 20.6, 110.9, 70.9); + } + + let entry = TransformEntry::from_input(shape_id, Matrix::translate((5.2, 3.7))); + let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap(); + + let transform = result + .iter() + .find(|entry| entry.id == shape_id) + .map(|entry| entry.transform) + .unwrap(); + + let shape = state.shapes.get(&shape_id).unwrap(); + let bounds = shape.bounds().transform(&transform); + + assert!(is_close_to(bounds.width(), 100.5)); + assert!(is_close_to(bounds.height(), 50.3)); + assert!(is_close_to(bounds.min_x(), 16.0)); + assert!(is_close_to(bounds.min_y(), 24.0)); + } + + #[test] + fn test_propagate_pixel_precision_resize_keeps_anchored_corner() { + let shape_id = Uuid::new_v4(); + let mut state = State::new(); + state.shapes.initialize(10); + { + let shape = state.shapes.add_shape(shape_id); + shape.set_selrect(10.4, 20.6, 110.4, 70.6); + } + + // Drag the bottom-right corner in small steps: the top-left corner + // stays put on every step. + for step in 1..40 { + let delta = step as f32 * 0.05; + let mut resize = Matrix::scale(((100.0 + delta) / 100.0, (50.0 + delta) / 50.0)); + resize.post_translate(Point::new(10.4, 20.6)); + resize.pre_translate(Point::new(-10.4, -20.6)); + + let entry = TransformEntry::from_input(shape_id, resize); + let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap(); + + let transform = result + .iter() + .find(|entry| entry.id == shape_id) + .map(|entry| entry.transform) + .unwrap(); + + let shape = state.shapes.get(&shape_id).unwrap(); + let bounds = shape.bounds().transform(&transform); + + assert!( + is_close_to(bounds.min_x(), 10.4) && is_close_to(bounds.min_y(), 20.6), + "corner moved to ({}, {}) at delta {}", + bounds.min_x(), + bounds.min_y(), + delta + ); + assert!(is_close_to(bounds.width(), bounds.width().round())); + assert!(is_close_to(bounds.height(), bounds.height().round())); + } + } + + #[test] + fn test_pixel_precision_only_x_leaves_y_untouched() { + let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3)); + let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 0.0))); + let mut transform = Matrix::translate((5.2, 0.0)); + + let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyX); + set_pixel_precision(&mut transform, &mut bounds_after, snap); + + assert!(is_close_to(bounds_after.min_x(), 16.0)); + assert!(is_close_to(bounds_after.min_y(), 20.6)); + } + + #[test] + fn test_pixel_precision_only_y_leaves_x_untouched() { + let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3)); + let mut bounds_after = bounds.transform(&Matrix::translate((0.0, 3.7))); + let mut transform = Matrix::translate((0.0, 3.7)); + + let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyY); + set_pixel_precision(&mut transform, &mut bounds_after, snap); + + assert!(is_close_to(bounds_after.min_x(), 10.4)); + assert!(is_close_to(bounds_after.min_y(), 24.0)); + } + + #[test] + fn test_pixel_precision_resize_never_rounds_below_one_pixel() { + let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.0, 20.0, 0.4, 0.3)); + let mut bounds_after = bounds.transform(&Matrix::scale((1.5, 1.5))); + let mut transform = Matrix::scale((1.5, 1.5)); + + let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both); + set_pixel_precision(&mut transform, &mut bounds_after, snap); + + assert!(is_close_to(bounds_after.width(), 1.0)); + assert!(is_close_to(bounds_after.height(), 1.0)); + } + + #[test] + fn test_propagate_pixel_precision_snaps_every_frame_of_a_gesture() { + let shape_id = Uuid::new_v4(); + let mut state = State::new(); + state.shapes.initialize(10); + { + let shape = state.shapes.add_shape(shape_id); + shape.set_selrect(10.4, 20.6, 110.9, 70.9); + } + + // One frame of a drag, as the entry point runs it: clear the + // modifiers, propagate the delta accumulated since the gesture + // started, then push the result back as the active modifier, which is + // what the renderer draws. + let frame = |state: &mut State, delta: f32| { + state.shapes.clear_transform_modifiers(); + + let entry = TransformEntry::from_input(shape_id, Matrix::translate((delta, delta))); + let result = propagate_modifiers(state, &[entry], PixelPrecision::Both).unwrap(); + let transform = result + .iter() + .find(|entry| entry.id == shape_id) + .map(|entry| entry.transform) + .unwrap(); + + let bounds = state + .shapes + .get_raw(&shape_id) + .unwrap() + .bounds() + .transform(&transform); + + state.set_modifiers(HashMap::from([(shape_id, transform)])); + bounds + }; + + // Every frame lands on the pixel grid and keeps the size. + for step in 1..40 { + let bounds = frame(&mut state, step as f32 * 0.35); + + assert!( + is_close_to(bounds.min_x(), bounds.min_x().round()) + && is_close_to(bounds.min_y(), bounds.min_y().round()), + "shape landed off the pixel grid at ({}, {}) on frame {}", + bounds.min_x(), + bounds.min_y(), + step + ); + assert!(is_close_to(bounds.width(), 100.5)); + assert!(is_close_to(bounds.height(), 50.3)); + } + } + + #[test] + fn test_propagate_pixel_precision_resize_only_rounds_the_changed_dimension() { + let shape_id = Uuid::new_v4(); + let mut state = State::new(); + state.shapes.initialize(10); + { + let shape = state.shapes.add_shape(shape_id); + shape.set_selrect(10.4, 20.6, 110.9, 70.9); + } + + // Drag the right edge: the width lands on the grid, the height and + // the top-left corner stay put. + let mut resize = Matrix::scale((103.3 / 100.5, 1.0)); + resize.post_translate(Point::new(10.4, 20.6)); + resize.pre_translate(Point::new(-10.4, -20.6)); + + let entry = TransformEntry::from_input(shape_id, resize); + let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap(); + + let transform = result + .iter() + .find(|entry| entry.id == shape_id) + .map(|entry| entry.transform) + .unwrap(); + + let bounds = state + .shapes + .get_raw(&shape_id) + .unwrap() + .bounds() + .transform(&transform); + + assert!(is_close_to(bounds.width(), 103.0)); + assert!(is_close_to(bounds.height(), 50.3)); + assert!(is_close_to(bounds.min_x(), 10.4)); + assert!(is_close_to(bounds.min_y(), 20.6)); + } + #[test] fn test_group_bounds() { let parent_id = Uuid::new_v4(); diff --git a/render-wasm/src/shapes/shape_to_path.rs b/render-wasm/src/shapes/shape_to_path.rs index 32e058dcb6..a797e52837 100644 --- a/render-wasm/src/shapes/shape_to_path.rs +++ b/render-wasm/src/shapes/shape_to_path.rs @@ -102,9 +102,18 @@ fn fix_radius( } pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> { + transform_segments(rect_segments_local(shape, corners), shape) +} + +/// Axis-aligned rect path in selrect space (no `shape.transform`). +/// +/// Use when the caller already applies [`Shape::centered_transform`] on the +/// canvas (e.g. SVG leaf export); [`rect_segments`] would bake the transform +/// into the path and double-rotate. +pub fn rect_segments_local(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> { let sr = shape.selrect; - let segments = if let Some([r1, r2, r3, r4]) = corners { + if let Some([r1, r2, r3, r4]) = corners { let (r1, r2, r3, r4) = fix_radius(r1, r2, r3, r4, sr.width(), sr.height()); let p1 = (sr.x(), sr.y() + r1.y); @@ -139,9 +148,7 @@ pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> { Segment::LineTo(p4), Segment::Close, ] - }; - - transform_segments(segments, shape) + } } fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) { @@ -151,6 +158,11 @@ fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) { } pub fn circle_segments(shape: &Shape) -> Vec<Segment> { + transform_segments(circle_segments_local(shape), shape) +} + +/// Circle path in selrect space (no `shape.transform`). See [`rect_segments_local`]. +pub fn circle_segments_local(shape: &Shape) -> Vec<Segment> { let sr = shape.selrect; let c = BEZIER_CIRCLE_C; let c1x = sr.x() + (sr.width() / 2.0 * (1.0 - c)); @@ -168,15 +180,13 @@ pub fn circle_segments(shape: &Shape) -> Vec<Segment> { let p3 = (mx, ey); let p4 = (sr.x(), my); - let segments = vec![ + vec![ Segment::MoveTo(p1), Segment::CurveTo(((c2x, p1.1), (p2.0, c1y), p2)), Segment::CurveTo(((p2.0, c2y), (c2x, p3.1), p3)), Segment::CurveTo(((c1x, p3.1), (p4.0, c2y), p4)), Segment::CurveTo(((p4.0, c1y), (c1x, p1.1), p1)), - ]; - - transform_segments(segments, shape) + ] } fn join_paths(path: Path, other: Path) -> Path { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f0f2fa9f1e..f8009e91be 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,4 +1,4 @@ -use crate::render::text::calculate_decoration_metrics; +use crate::render::text::decoration_segments; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, @@ -320,6 +320,18 @@ pub struct TextDecorationSegment { pub width: f32, } +impl TextDecorationSegment { + /// The bar to paint, centered on `y`. + pub fn rect(&self) -> Rect { + Rect::new( + self.left, + self.y - self.thickness / 2.0, + self.left + self.width, + self.y + self.thickness / 2.0, + ) + } +} + fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 { match valign { VerticalAlign::Center => (container_h - content_h) / 2.0, @@ -796,13 +808,13 @@ impl TextContent { &self, use_shadow: Option<bool>, ) -> Vec<ParagraphBuilderGroup> { - self.paragraph_builders(use_shadow, false, None, None) + self.paragraph_builders(use_shadow, false, None, None, None, None) } /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). /// Used as a clip mask for inner stroke rendering. pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> { - self.paragraph_builders(None, true, None, None) + self.paragraph_builders(None, true, None, None, None, None) } /// Maximum number of stacked fills across every span in this text block. @@ -821,7 +833,42 @@ impl TextContent { &self, layer_from_bottom: usize, ) -> Vec<ParagraphBuilderGroup> { - self.paragraph_builders(None, false, None, Some(layer_from_bottom)) + self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None) + } + + /// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at + /// this layer is an image in `skip_image_ids` get transparent paint (those + /// fills are re-emitted as linked SVG `<image>` elements). + pub fn paragraph_builder_group_for_fill_layer_skipping_images( + &self, + layer_from_bottom: usize, + skip_image_ids: &HashSet<Uuid>, + ) -> Vec<ParagraphBuilderGroup> { + self.paragraph_builders( + None, + false, + None, + Some(layer_from_bottom), + None, + Some(skip_image_ids), + ) + } + + /// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is + /// the given image — used as an SVG `<clipPath>` for linked image fills. + pub fn paragraph_builder_group_opaque_for_image_layer( + &self, + layer_from_bottom: usize, + image_id: Uuid, + ) -> Vec<ParagraphBuilderGroup> { + self.paragraph_builders( + None, + false, + None, + None, + Some((layer_from_bottom, image_id)), + None, + ) } fn paragraph_builders( @@ -830,6 +877,8 @@ impl TextContent { opaque: bool, align_override: Option<skia::textlayout::TextAlign>, fill_layer: Option<usize>, + opaque_image_layer: Option<(usize, Uuid)>, + skip_image_ids: Option<&HashSet<Uuid>>, ) -> Vec<ParagraphBuilderGroup> { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); @@ -843,15 +892,63 @@ impl TextContent { let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for span in paragraph.children() { - let remove_alpha = - opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); - let text_style = span.to_style_with_paint( - &self.bounds(), - fallback_fonts, - remove_alpha, - paragraph.line_height(), - fill_layer, - ); + let text_style = if let Some((layer, image_id)) = opaque_image_layer { + let mut style = span.to_style( + &self.bounds(), + fallback_fonts, + false, + paragraph.line_height(), + ); + let mut paint = paint::Paint::default(); + match span.fills_from_bottom(layer) { + Some(shapes::Fill::Image(img)) if img.id() == image_id => { + paint.set_color(skia::Color::BLACK); + paint.set_alpha(255); + } + _ => { + paint.set_color(skia::Color::TRANSPARENT); + } + } + style.set_foreground_paint(&paint); + style + } else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) { + let skip_span = matches!( + span.fills_from_bottom(layer), + Some(shapes::Fill::Image(img)) if skip.contains(&img.id()) + ); + if skip_span { + let mut style = span.to_style( + &self.bounds(), + fallback_fonts, + false, + paragraph.line_height(), + ); + let mut paint = paint::Paint::default(); + paint.set_color(skia::Color::TRANSPARENT); + style.set_foreground_paint(&paint); + style + } else { + let remove_alpha = + opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); + span.to_style_with_paint( + &self.bounds(), + fallback_fonts, + remove_alpha, + paragraph.line_height(), + fill_layer, + ) + } + } else { + let remove_alpha = + opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); + span.to_style_with_paint( + &self.bounds(), + fallback_fonts, + remove_alpha, + paragraph.line_height(), + fill_layer, + ) + }; let text: String = span.apply_text_transform(); if !text.is_empty() { has_text = true; @@ -871,8 +968,14 @@ impl TextContent { /// Performs an Auto Width text layout. fn text_layout_auto_width(&self) -> TextContentLayoutResult { // Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container. - let mut measure_builders = - self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None); + let mut measure_builders = self.paragraph_builders( + None, + false, + Some(skia::textlayout::TextAlign::Left), + None, + None, + None, + ); let normalized_line_height = calculate_normalized_line_height(&mut measure_builders, f32::MAX); @@ -1464,6 +1567,15 @@ pub struct TextSpan { } impl TextSpan { + /// Fill at `layer` counting from the bottom (`0` = last / bottommost fill). + pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> { + if layer < self.fills.len() { + Some(&self.fills[self.fills.len() - 1 - layer]) + } else { + None + } + } + #[allow(clippy::too_many_arguments)] pub fn new( text: String, @@ -1725,68 +1837,12 @@ pub fn calculate_text_layout_data( for (i, group_paragraphs) in built_groups.into_iter().enumerate() { // For each paragraph in the group (e.g., fill, stroke, etc.) for skia_paragraph in group_paragraphs.into_iter() { - // Calculate text decorations for this paragraph - let mut decorations = Vec::new(); - let line_metrics = skia_paragraph.get_line_metrics(); - for line in &line_metrics { - let style_metrics: Vec<_> = line - .get_style_metrics(line.start_index..line.end_index) - .into_iter() - .collect(); - let line_baseline = y_accum + line.baseline as f32; - let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = - calculate_decoration_metrics(&style_metrics, line_baseline); - for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { - let text_style = &style_metric.text_style; - let style_end = style_metrics - .get(i + 1) - .map(|(next_i, _)| *next_i) - .unwrap_or(line.end_index); - let seg_start = (*style_start).max(line.start_index); - let seg_end = style_end.min(line.end_index); - if seg_start >= seg_end { - continue; - } - let rects = skia_paragraph.get_rects_for_range( - seg_start..seg_end, - skia::textlayout::RectHeightStyle::Tight, - skia::textlayout::RectWidthStyle::Tight, - ); - let (segment_width, actual_x_offset) = if !rects.is_empty() { - let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); - let skia_x_offset = rects - .first() - .map(|r| r.rect.left - line.left as f32) - .unwrap_or(0.0); - (total_width, skia_x_offset) - } else { - (0.0, 0.0) - }; - let text_left = x + line.left as f32 + actual_x_offset; - let text_width = segment_width; - use skia::textlayout::TextDecoration; - if text_style.decoration().ty == TextDecoration::UNDERLINE { - decorations.push(TextDecorationSegment { - kind: TextDecoration::UNDERLINE, - text_style: (*text_style).clone(), - y: underline_y.unwrap_or(line_baseline), - thickness: max_underline_thickness, - left: text_left, - width: text_width, - }); - } - if text_style.decoration().ty == TextDecoration::LINE_THROUGH { - decorations.push(TextDecorationSegment { - kind: TextDecoration::LINE_THROUGH, - text_style: (*text_style).clone(), - y: strike_y.unwrap_or(line_baseline), - thickness: max_strike_thickness, - left: text_left, - width: text_width, - }); - } - } - } + let decorations = text_paragraphs + .get(i) + .map(|text_paragraph| { + decoration_segments(&skia_paragraph, text_paragraph, x, y_accum) + }) + .unwrap_or_default(); paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, x, diff --git a/render-wasm/src/shapes/transform.rs b/render-wasm/src/shapes/transform.rs index b0ff2a52d0..98224a0e97 100644 --- a/render-wasm/src/shapes/transform.rs +++ b/render-wasm/src/shapes/transform.rs @@ -5,18 +5,59 @@ use crate::utils::{uuid_from_u32_quartet, uuid_to_u32_quartet}; use crate::uuid::Uuid; use skia::Matrix; +/// Axes the pixel grid rounds. An axis-locked drag rounds only the axis it +/// moves along. +#[derive(PartialEq, Debug, Clone, Copy)] +pub enum PixelPrecision { + Disabled, + Both, + OnlyX, + OnlyY, +} + +impl PixelPrecision { + pub fn enabled(&self) -> bool { + *self != PixelPrecision::Disabled + } + + pub fn rounds_x(&self) -> bool { + matches!(self, PixelPrecision::Both | PixelPrecision::OnlyX) + } + + pub fn rounds_y(&self) -> bool { + matches!(self, PixelPrecision::Both | PixelPrecision::OnlyY) + } +} + +impl From<u8> for PixelPrecision { + fn from(value: u8) -> Self { + match value { + 1 => PixelPrecision::Both, + 2 => PixelPrecision::OnlyX, + 3 => PixelPrecision::OnlyY, + _ => PixelPrecision::Disabled, + } + } +} + #[derive(PartialEq, Debug, Clone)] pub enum Modifier { - Transform(TransformEntry, bool), + Transform(TransformEntry, PixelPrecision), Reflow(Uuid, bool), } impl Modifier { pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self { - Modifier::Transform(TransformEntry::from_propagate(id, transform), false) + Modifier::Transform( + TransformEntry::from_propagate(id, transform), + PixelPrecision::Disabled, + ) } pub fn parent(id: Uuid, transform: Matrix) -> Self { - Modifier::Transform(TransformEntry::parent(id, transform), false) + Modifier::Transform( + TransformEntry::parent(id, transform), + PixelPrecision::Disabled, + ) } pub fn reflow(id: Uuid, force_reflow: bool) -> Self { Modifier::Reflow(id, force_reflow) diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index d09bb109b2..f39825ebaf 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -403,6 +403,20 @@ impl ShapesPoolImpl { /// gone, but if we don't touch their tiles they keep pointing at the /// previous modified position and the tile texture cache may serve stale /// pixels. + /// Drops the transform modifiers, keeping structure and scale-content + /// entries, so the pool serves committed geometry again. Called before + /// propagating a new set of transforms, which are relative to that + /// geometry. + pub fn clear_transform_modifiers(&mut self) { + if self.modifiers.is_empty() { + return; + } + + self.clean_shape_cache(); + self.modifiers = HashMap::default(); + self.modifier_uuids.clear(); + } + pub fn clean_all(&mut self) -> Vec<Uuid> { self.clean_shape_cache(); diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index c4084ea983..5d2a6e29e2 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -187,4 +187,30 @@ mod tests { assert_eq!(bytes[0], 0x03); assert_eq!(shapes::Fill::from(RawFillData::from(bytes)), fill); } + + #[test] + fn test_image_fill_with_transform_round_trip() { + let transform = shapes::ImageFillTransform { + x: 0.1, + y: -0.2, + width: 1.5, + height: 2.0, + }; + let image_fill = shapes::ImageFill::new_with_transform( + crate::uuid::Uuid::nil(), + 0xcc, + 400, + 300, + false, + Some(transform), + ); + let fill = shapes::Fill::Image(image_fill); + let raw_fill = + RawFillData::try_from(&fill).expect("image fill with transform must be serializable"); + let bytes = <[u8; RAW_FILL_DATA_SIZE]>::from(raw_fill); + + assert_eq!(bytes[0], 0x03); + let deserialized = shapes::Fill::from(RawFillData::from(bytes)); + assert_eq!(deserialized, fill); + } } diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index ce14511220..e6b2e634c5 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -30,6 +30,7 @@ fn touch_shapes_with_image(state: &mut State, image_id: Uuid) { } const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0; +const FLAG_HAS_TRANSFORM: u8 = 1 << 1; const IMAGE_IDS_SIZE: usize = 32; const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag @@ -43,20 +44,30 @@ pub struct RawImageFillData { d: u32, opacity: u8, flags: u8, - // 16-bit padding here, reserved for future use + _pad: u16, width: i32, height: i32, + transform_x: f32, + transform_y: f32, + transform_w: f32, + transform_h: f32, } impl From<&ImageFill> for RawImageFillData { fn from(image_fill: &ImageFill) -> Self { let id = image_fill.id(); let (a, b, c, d) = crate::utils::uuid_to_u32_quartet(&id); - let flags = if image_fill.keep_aspect_ratio() { + let mut flags = if image_fill.keep_aspect_ratio() { FLAG_KEEP_ASPECT_RATIO } else { 0 }; + let (tx, ty, tw, th) = if let Some(tf) = image_fill.transform() { + flags |= FLAG_HAS_TRANSFORM; + (tf.x, tf.y, tf.width, tf.height) + } else { + (0.0, 0.0, 1.0, 1.0) + }; Self { a, @@ -65,8 +76,13 @@ impl From<&ImageFill> for RawImageFillData { d, opacity: image_fill.opacity(), flags, + _pad: 0, width: image_fill.width(), height: image_fill.height(), + transform_x: tx, + transform_y: ty, + transform_w: tw, + transform_h: th, } } } @@ -75,13 +91,24 @@ impl From<RawImageFillData> for ImageFill { fn from(value: RawImageFillData) -> Self { let id = uuid_from_u32_quartet(value.a, value.b, value.c, value.d); let keep_aspect_ratio = value.flags & FLAG_KEEP_ASPECT_RATIO != 0; + let transform = if value.flags & FLAG_HAS_TRANSFORM != 0 { + Some(crate::shapes::ImageFillTransform { + x: value.transform_x, + y: value.transform_y, + width: value.transform_w, + height: value.transform_h, + }) + } else { + None + }; - Self::new( + Self::new_with_transform( id, value.opacity, value.width, value.height, keep_aspect_ratio, + transform, ) } } @@ -140,6 +167,22 @@ pub extern "C" fn store_image() -> Result<()> { Ok(()) } +/// Registers the public URL an image was loaded from for SVG export. +/// +/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as +/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`). +#[no_mangle] +#[wasm_error] +pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> { + let id = uuid_from_u32_quartet(a, b, c, d); + let url_bytes = mem::bytes(); + let url = String::from_utf8(url_bytes) + .map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?; + mem::free_bytes()?; + get_resources().images.set_source_url(id, url); + Ok(()) +} + /// Stores an image from an existing WebGL texture, avoiding re-decoding /// Expected memory layout: /// - bytes 0-15: shape UUID diff --git a/render-wasm/src/wasm/transforms.rs b/render-wasm/src/wasm/transforms.rs index 87989c545b..e8c92da247 100644 --- a/render-wasm/src/wasm/transforms.rs +++ b/render-wasm/src/wasm/transforms.rs @@ -76,7 +76,7 @@ impl From<RawTransformEntry> for TransformEntry { #[no_mangle] #[wasm_error] -pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> { +pub extern "C" fn propagate_modifiers(pixel_precision: u8) -> Result<*mut u8> { let bytes = mem::bytes(); let entries: Vec<TransformEntry> = bytes @@ -85,7 +85,8 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> .collect::<Result<Vec<_>>>()?; with_state!(state, { - let result = shapes::propagate_modifiers(state, &entries, pixel_precision)?; + state.shapes.clear_transform_modifiers(); + let result = shapes::propagate_modifiers(state, &entries, pixel_precision.into())?; Ok(mem::write_vec(result)) }) } diff --git a/scripts/clean-node-modules b/scripts/clean-node-modules new file mode 100755 index 0000000000..8d17b5f213 --- /dev/null +++ b/scripts/clean-node-modules @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Remove node_modules directories across the monorepo's pnpm workspaces: +# the repo root, all module workspaces, and all workspace member packages. +# +# Use it when node_modules are stale or corrupted and a clean reinstall is +# cheaper than debugging. Reinstall afterwards with `pnpm install` in each +# workspace root (root, backend, common, docs, exporter, frontend, library, +# mcp, media-processor, plugins, render-wasm). +# +# external/ (vendored dependency trees) and .opencode/ are always ignored. +# The pnpm content-addressable store lives at <repo>/.pnpm-store, outside +# node_modules, and survives a default clean. `--store` removes it too; +# the next install re-downloads what it held. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/clean-node-modules [options] + +Remove every node_modules directory in the pnpm workspaces: the repo root, +all module workspaces, and all workspace member packages. + +Options: + -n, --dry-run Print what would be removed without deleting anything. + --store Also delete the shared pnpm store at <repo>/.pnpm-store. + The next install re-downloads everything it held. + -h, --help Show this help. + +external/ (vendored dependency trees) and .opencode/ are always ignored. +EOF +} + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DRY_RUN=0 +WITH_STORE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -n | --dry-run) DRY_RUN=1 ;; + --store) WITH_STORE=1 ;; + -h | --help) usage; exit 0 ;; + *) { + echo "error: unknown option: $1" >&2 + usage >&2 + exit 64 + } ;; + esac + shift +done + +cd "$ROOT" + +if [[ $WITH_STORE -eq 1 && -d .pnpm-store ]]; then + size="$(du -sh .pnpm-store | cut -f1)" + if [[ $DRY_RUN -eq 1 ]]; then + echo "would remove: ./.pnpm-store (pnpm store, $size)" + else + echo "removing pnpm store: $ROOT/.pnpm-store ($size); the next install re-downloads it" + rm -rf .pnpm-store + fi +fi + +# -prune stops the descent, so only the top-most node_modules of each tree +# matches; nested dependency copies inside it die with their parent. +mapfile -t dirs < <( + find . \( -name .git -o -name external -o -name .opencode \) -type d -prune \ + -o \( -name node_modules -type d -prune -print \) +) + +if [[ ${#dirs[@]} -eq 0 ]]; then + echo "no node_modules directories found" + exit 0 +fi + +cleaned=0 +for dir in "${dirs[@]}"; do + if [[ $DRY_RUN -eq 1 ]]; then + echo "would remove: $dir" + else + rm -rf "$dir" + echo "removed: $dir" + fi + cleaned=$((cleaned + 1)) +done + +if [[ $DRY_RUN -eq 1 ]]; then + echo "$cleaned node_modules directories found" +else + echo "$cleaned node_modules directories cleaned" +fi diff --git a/scripts/replace-copyright.sh b/scripts/replace-copyright.sh index f910d0a20d..93fc8476ec 100755 --- a/scripts/replace-copyright.sh +++ b/scripts/replace-copyright.sh @@ -173,9 +173,11 @@ main() { fi done - # Get tracked files matching our extensions - local files - files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" || true) + # Get tracked files matching our extensions, excluding this script itself + # (it mentions the search string in its own usage docs). + local files script_name + script_name=$(basename "$0") + files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" | grep -v -F "$script_name" || true) if [[ -z "$files" ]]; then log_warn "No tracked files found matching extensions: ${EXTENSIONS}"