Compare commits

...
Author SHA1 Message Date
Alonso Torres 947954933c 🐛 Fix problem with node splitting in paths (#11553)
* 🐛 Fix problem with node splitting in paths

* 🐛 Fix merge splitting node in paths
2026-09-11 14:20:09 +02:00
Alejandro Alonso 87e5ffdf94 🐛 Restart progressive render on canvas background change (#11643)
Cancel Partial/ViewportReady before invalidating tiles so a mid-flight
pass does not leave background-only holes. Closes #11642.
2026-09-11 14:12:57 +02:00
Alejandro Alonso 3adf9ade14 Export drop and inner shadows to WASM SVG (#11593)
SkSVGDevice drops save_layer image-filters, so re-emit visible drop and
inner shadows (plus optional layer blur) as one native SVG filter chain
matching classic order: flood, drops, SourceGraphic, inners, blur.

Closes #11379
2026-09-11 13:52:57 +02:00
Eva Marco f3da8af7b6 🎉 Avoid interacting with clipped content (#11613)
* 🐛 Fix nested board drop target ignoring ancestor clip bounds

Frame hit-testing (get-frame-by-position, get-frames-by-position and
top-nested-frame) only checked a candidate board's own rectangle,
without accounting for an ancestor board with clip content enabled.
A nested board wider/taller than its clipping ancestor could still be
picked as the drop target in its invisible, clipped-away area, so a
dragged shape would get reparented there and disappear from view.

Add clipped-by-ancestor? to reject a point when it falls outside the
bounds of any ancestor board that has clip content enabled, so the
lookup now stops at the correct visible ancestor instead of
descending into the hidden region.

* 🐛 Fix Ctrl+click deep-select reaching into clipped board area

The clip-aware quadtree query (query-index) filters candidate shapes
by whether they overlap every clip-parent ancestor, but the whole
filter was skipped whenever clip-children? was false. That flag is
turned off while a modifier key (Ctrl/Cmd) is held for deep/penetrate
selection, which was meant to let it reach past boolean/mask clip
boundaries, but it also disabled enforcement for board "Clip content"
ancestors, letting a modifier-held click select a shape sitting in a
board's invisible, clipped-away region.

overlaps-parent? now only relaxes the check for non-frame clip-parents
(bool shapes / mask children) when clip-children? is false; board clip
ancestors are always enforced regardless of the modifier key.
2026-09-11 13:42:13 +02:00
Eva Marco 7ff76a9ebc 🐛 Fix project name width in dashboard header (#11564)
The project title's max-width was capped via an inline style computed
from the number of thumbnail columns fitting in the grid below it (an
unrelated value, reused only because it happened to be in scope). This
produced an oversized gap between a short/medium title and the file
count, timestamp, and action buttons, and gave long titles an
arbitrary, columns-based truncation point unrelated to the row's
actual available width.

Replace it with a standard flexbox truncate-to-fit: the title sizes to
its own content and sits right next to the info/actions, only
shrinking (and ellipsizing) once the row runs out of room, while the
info/actions never shrink.
2026-09-11 13:40:58 +02:00
Alonso Torres fc8c5a98de 🐛 Fix handler change to equal (#11612) 2026-09-11 13:23:45 +02:00
Andrey Antukh bda8459d89 Merge remote-tracking branch 'origin/staging' into develop 2026-09-11 12:57:51 +02:00
Andrey Antukh 06239844b1 🐛 Fix chunked upload storage amplification and cap chunk size (#11635)
* 🐛 Reject duplicate chunk index in chunked uploads

Repeat uploads of the same chunk index each stored a new
object because upload-chunk only checked index bounds. Run the
handler in a transaction, lock the session row and reject an
already-stored index with :duplicate-chunk-index.

Also harden assemble-chunks to require exactly indices 0..n-1
 so gaps or duplicates fail instead of assembling a corrupt
file. Covers media, fonts and binfile through the shared
helper.

Closes #11634

AI-assisted-by: muse-spark-1.3-contributor

*  Cap upload chunk size at 30 MiB by default

Chunks were only bounded by the 350 MiB HTTP body limit while the
30 MiB caps applied to the assembled file. Add :upload-max-chunk-size
(default 30 MiB, tunable via env) and reject oversize chunks in
upload-chunk with :validation/:chunk-too-large before anything is
stored. App clients slice at 25/10 MiB, so no frontend change needed.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix tx-run! call and storage resolve in upload-chunk

Pass cfg as first arg to db/tx-run!, which expects [system f & params; without it every chunk upload raised invalid system/cfg provided and no chunk was stored, breaking assemble with missing-chunks. Also resolve storage without reuse-conn: put-object! writes to the backend outside any transaction, so reusing the tx connection gives no atomicity. Media, font and storage suites green, lint and format clean. AI-assisted-by: muse-spark-1.3-contributor
2026-09-11 12:10:57 +02:00
Andrey Antukh 09736aa4c9 Enforce commit body line wrapping
Add a body line-length validator to scripts/check-commit. It
fails when a body line exceeds 76 characters, exempting
trailers, URLs, and unbreakable tokens. The 76 limit leaves
room for git log's four-space indent in an 80-column
terminal.

Align the subject limit with the documented 70 characters;
the checker allowed 90 before.

Document the rule as a hard, verifiable requirement in
AGENTS.md, CONTRIBUTING.md, the create-commit skill, and
the workflow memory, and point at scripts/check-commit.

Add tests for the validator and the subject length rule.

AI-assisted-by: deepseek-flash
2026-09-11 08:10:49 +00:00
Alejandro Alonso ffad71bdc5 🐛 Hard-clip tile atlas blit to avoid seam hairlines (#11639)
AA clip on the Current→atlas blit softens shared tile edges so
the canvas background shows through as 1px lines at the 512px
grid when tiles are composed with SrcOver.

Closes #11638
2026-09-11 09:22:36 +02:00
55 changed files with 3123 additions and 266 deletions

No files matched your search

+20 -2
View File
@@ -20,6 +20,17 @@ Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
is the authoritative source for the commit message format, the emoji menu,
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
## Iron Rules (non-negotiable)
1. **Wrap every body line at 76 characters or fewer.** Count characters, do
not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and
lines carrying a URL. This is the rule agents skip most often.
2. **Subject ≤70 chars**, imperative, capitalized, no trailing period.
3. **Blank line between subject and body.**
4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically
checks rules 13. A non-zero exit is a hard blocker: fix the message and
re-commit. Never report the commit as done with a failing checker.
## Workflow
1. **Stage the files** specified by the calling context. Do not ask for
@@ -29,12 +40,18 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
that does not match the stated intent, **STOP** and tell the user before
committing.
3. Draft the message following the format in the memory doc, wrapping the body
at 72 characters per line, and run:
at 76 characters per line, and run:
```bash
git commit -m "<subject>" -m "<body>"
```
(or `git commit -F -` if the body has unusual characters).
4. The `AI-assisted-by` trailer value is provided by the calling context — use
4. **Verify the message with the checker**:
```bash
./scripts/check-commit
```
If it fails, amend the message (`git commit --amend`) until it passes. Do
not finish with a failing checker.
5. The `AI-assisted-by` trailer value is provided by the calling context — use
it verbatim.
## Constraints
@@ -45,3 +62,4 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
- Do not amend a commit you did not create in this session, unless explicitly asked.
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
- Do not add untracked files that were not created in this session.
- Do not skip the `scripts/check-commit` verification step (Iron Rule 4).
+1 -1
View File
@@ -11,7 +11,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
- Before `git commit``mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `git commit``mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `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
+22 -2
View File
@@ -14,12 +14,32 @@ automatically pull the identity from the local git config `user.name` and `user.
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why.
Wrap lines at 72 characters — git log and tooling
render long lines poorly. Keep each line concise.
Wrap lines at 76 characters — git log adds a
four-space indent, so 76 + 4 fits an 80-column
terminal. Keep each line concise.
AI-assisted-by: model-name
```
## HARD RULES (inexcusable)
These rules are not advisory. Do not commit until every one holds. A commit
that breaks them is wrong, even if the code is right.
- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do
not eyeball it. This is the rule most often skipped. Rationale: `git log`
indents the body four spaces, so 76 + 4 fits an 80-column terminal.
- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period.
- **MUST be a blank line** between subject and body.
- **MUST run `scripts/check-commit` and get exit code 0 before finishing.**
It mechanically validates the rules above; a failing run is a blocker.
- It checks `HEAD` by default: `./scripts/check-commit`
- For another commit: `./scripts/check-commit -c <ref>`
- **NEVER** hand-wave the body as "one long line". If a line exceeds 76,
break it at a space.
- Exceptions inside the body (do not wrap these): `Signed-off-by:`,
`Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL.
**AI-assisted-by trailer rules:**
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
- Do NOT add prefixes like `opencode-go/` — use the bare model name
+3
View File
@@ -17,6 +17,9 @@
- **`.claude/skills` is a symlink to `.agents/skills`.**
Edit skills only in their canonical location (`.agents/skills`); never edit
through `.claude/skills`.
- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and
the commit MUST pass `./scripts/check-commit` with exit code 0 before you
consider it done. This is mechanically checked — do not eyeball it.
- **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)
+3
View File
@@ -188,8 +188,11 @@ Commit messages must follow this format:
- Add clear and concise description on the body
- Do not end the subject with a period
- Keep the subject to **70 characters** or fewer
- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted)
- Separate the subject from the body with a **blank line**
You can check a commit against these rules with `./scripts/check-commit`.
### Examples
```
+2
View File
@@ -92,6 +92,7 @@
:quotes-upload-sessions-per-profile 5
:quotes-upload-chunks-per-session 20
:upload-max-chunk-size (* 1024 1024 30) ; 30MiB
;; SSRF protection
:ssrf-allowed-hosts #{}
@@ -203,6 +204,7 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:upload-max-chunk-size {:optional true} ::sm/int]
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
+63 -26
View File
@@ -339,6 +339,9 @@
;; --- Chunked Upload: Upload a single chunk
(declare ^:private get-upload-chunk)
(declare ^:private check-upload-chunk-slot)
(def ^:private schema:upload-chunk
[:map {:title "upload-chunk"}
[:session-id ::sm/uuid]
@@ -354,9 +357,31 @@
{::doc/added "2.17"
::sm/params schema:upload-chunk
::sm/result schema:upload-chunk-result}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id index content] :as _params}]
(let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})]
[cfg {:keys [::rpc/profile-id session-id index content]}]
(let [session (db/tx-run! cfg check-upload-chunk-slot session-id profile-id index content)]
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content))
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touch true
:content-type (:mtype content)
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))
{:session-id session-id
:index index}))
(defn- check-upload-chunk-slot
[{:keys [::db/conn]} session-id profile-id index content]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id} {::db/for-update true})]
(when (or (neg? index) (>= index (:total-chunks session)))
(ex/raise :type :validation
:code :invalid-chunk-index
@@ -365,26 +390,23 @@
:total-chunks (:total-chunks session)
:index index))
(when (> (:size content) (cf/get :upload-max-chunk-size))
(ex/raise :type :validation
:code :chunk-too-large
:hint "chunk size exceeds the maximum allowed"
:session-id session-id
:index index
:size (:size content)
:max-size (cf/get :upload-max-chunk-size)))
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content)))
(when (get-upload-chunk conn session-id index)
(ex/raise :type :validation
:code :duplicate-chunk-index
:hint "chunk index already uploaded for this session"
:session-id session-id
:index index))
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touched-at (ct/in-future {:hours 1})
:content-type (:mtype content)
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))
{:session-id session-id
:index index})
session))
;; --- Chunked Upload: shared helpers
@@ -400,6 +422,18 @@
[conn session-id]
(db/exec! conn [sql:get-upload-chunks (str session-id)]))
(def ^:private sql:get-upload-chunk
"SELECT id
FROM storage_object
WHERE (metadata->>'~:upload-id') = ?::text
AND (metadata->>'~:chunk-index')::integer = ?
AND deleted_at IS NULL
LIMIT 1")
(defn- get-upload-chunk
[conn session-id index]
(db/exec-one! conn [sql:get-upload-chunk (str session-id) index]))
(defn- concat-chunks
"Reads all chunk storage objects in order and writes them to a single
temporary file on the local filesystem. Returns a path to that file."
@@ -418,18 +452,21 @@
conforming to `media.v/schema:upload` with `:filename`, `:path` and
`:size`.
Raises a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
Raises a :validation/:missing-chunks error when the stored chunk
indices do not form exactly the `0..total-chunks` range recorded in
the session row (wrong count, gaps or duplicates).
Raises :not-found when the session does not belong to `profile-id`.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} profile-id session-id]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
chunks (get-upload-chunks conn session-id)]
chunks (get-upload-chunks conn session-id)
indices (sort (map :chunk-index chunks))]
(when (not= (count chunks) (:total-chunks session))
(when (or (not= (count chunks) (:total-chunks session))
(not= indices (range (:total-chunks session))))
(ex/raise :type :validation
:code :missing-chunks
:hint "number of stored chunks does not match expected total"
:hint "stored chunks do not match expected total"
:session-id session-id
:expected (:total-chunks session)
:found (count chunks)))
@@ -681,6 +681,131 @@
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-assemble-rejects-duplicate-indices
;; assemble-chunks must validate the index SET, not just the count: a
;; session declaring 2 chunks but storing [0,0] must fail instead of
;; assembling a corrupt file. Chunks are written at the storage level
;; because upload-chunk itself now rejects the second index.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 2)
storage (:app.storage/storage th/*system*)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
put-chunk! (fn [idx]
(let [mfile (make-chunk-mfile (first chunks) "image/jpeg")]
(sto/put-object! storage
{::sto/content (sto/content (:path mfile))
::sto/deduplicate? false
::sto/touch true
:content-type "image/jpeg"
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index idx})))]
(put-chunk! 0)
(put-chunk! 0)
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "dupe-indices"
:mtype "image/jpeg"})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-duplicate-then-assemble
;; A rejected duplicate must leave the first chunk intact: upload 0,
;; re-upload 0 (rejected), then assemble succeeds with the original size.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"
size (alength (first chunks))]
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :duplicate-chunk-index (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "after-dupe"
:mtype mtype})]
(t/is (nil? (:error out)))
(let [storage (:app.storage/storage th/*system*)
mobj (sto/get-object storage (:media-id (:result out)))]
(t/is (= size (:size mobj)))))))
(t/deftest chunked-upload-rejected-duplicate-keeps-session-usable
;; Rejecting a duplicate must not poison the session: the remaining
;; distinct indices still accumulate and assemble normally.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 2)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 110000)
mtype "image/jpeg"]
(t/is (= 3 (count chunks)))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (some? (:error out)))
(t/is (= :duplicate-chunk-index (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 1
:content (make-chunk-mfile (nth chunks 1) mtype)})]
(t/is (nil? (:error out))))
;; The live store holds exactly the two distinct indices: the
;; rejected duplicate stored nothing.
(let [rows (th/db-exec! ["SELECT (metadata->>'~:chunk-index')::integer AS idx FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND deleted_at IS NULL ORDER BY idx"
(str session-id)])]
(t/is (= [0 1] (mapv :idx rows))))))
(t/deftest chunked-upload-session-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
@@ -767,6 +892,77 @@
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
(t/deftest chunked-upload-duplicate-index-rejected
;; Uploading the same chunk index twice into one session must fail:
;; the second call raises :validation / :duplicate-chunk-index and
;; stores nothing, so one session+index keeps at most one object.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"
mfile1 (make-chunk-mfile (first chunks) mtype)
mfile2 (make-chunk-mfile (first chunks) mtype)]
;; First upload succeeds
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile1})]
(t/is (nil? (:error out))))
;; Second upload of the same index must be rejected
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile2})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :duplicate-chunk-index (-> out :error ex-data :code))))
;; Exactly one live object stored for that session/index
(let [rows (th/db-exec! ["SELECT id FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND (metadata->>'~:chunk-index') = '0' AND deleted_at IS NULL"
(str session-id)])]
(t/is (= 1 (count rows))))))
(t/deftest chunked-upload-chunk-too-large
;; Chunks larger than the configured cap must be rejected with
;; :validation / :chunk-too-large before anything is stored, while a
;; chunk exactly at the cap still uploads fine.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:upload-max-chunk-size 1024})}]
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"]
;; 312043 bytes exceeds the mocked 1024-byte cap: rejected
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-too-large (-> out :error ex-data :code))))
;; Nothing stored for the rejected chunk
(let [rows (th/db-exec! ["SELECT id FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND deleted_at IS NULL"
(str session-id)])]
(t/is (= 0 (count rows))))
;; A chunk exactly at the cap still uploads fine
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
(t/is (nil? (:error out)))))))
(t/deftest chunked-upload-sessions-per-profile-quota
;; With the session limit set to 2, creating a third session for the
;; same profile must fail with :restriction / :max-quote-reached.
+13
View File
@@ -460,6 +460,19 @@
(let [content (impl/path-data content)]
(segment/merge-nodes content points)))
(defn merge-coincident-nodes
"Collapses the nodes sharing a position into one node.
Without `points` every position held by more than one command is merged."
([content]
(let [content (impl/path-data content)]
(-> (segment/merge-coincident-nodes content)
(impl/from-plain))))
([content points]
(let [content (impl/path-data content)]
(-> (segment/merge-coincident-nodes content points)
(impl/from-plain)))))
(defn join-nodes
"Creates new segments between points that weren't previously connected."
[content points]
+160 -9
View File
@@ -940,18 +940,21 @@
(not= :close-path (:command c))))]
(loop [i 0
k 0
start nil
result (transient [])]
(if (>= i n)
(persistent! result)
(let [cmd (nth content i)
nxt (nth content (inc i) nil)
move? (= :move-to (:command cmd))
start (if move? (helpers/segment->point cmd) start)
at-p? (and (not= :close-path (:command cmd))
(gpt/close? point (helpers/segment->point cmd)))]
(cond
;; Offset a subpath start.
(and at-p? (= :move-to (:command cmd)))
(and at-p? move?)
(let [off (gpt/point (* k ox) (* k oy))]
(recur (inc i) (inc k)
(recur (inc i) (inc k) start
(conj! result (-> cmd
(update-in [:params :x] + (:x off))
(update-in [:params :y] + (:y off))))))
@@ -972,7 +975,7 @@
(= :curve-to (:command nxt))
(-> (update-in [:params :c1x] + (:x off2))
(update-in [:params :c1y] + (:y off2))))]
(recur (+ i 2) (inc k2)
(recur (+ i 2) (inc k2) start
(-> result (conj! cmd') (conj! mv) (conj! nxt'))))
;; Open and offset a closed seam.
@@ -985,12 +988,20 @@
(-> (update-in [:params :c2x] + (:x off))
(update-in [:params :c2y] + (:y off))))]
;; Drop the close command so the seam stays open.
(recur (+ i 2) (inc k) (conj! result cmd')))
(recur (+ i 2) (inc k) start (conj! result cmd')))
;; Open the seam of a subpath that closes back onto the node.
(and (= :close-path (:command cmd))
(some? start)
(gpt/close? point start))
(let [off (gpt/point (* k ox) (* k oy))]
(recur (inc i) (inc k) start
(conj! result (helpers/make-line-to (gpt/add point off)))))
;; Offset the end of an open subpath.
(and at-p? (seg? cmd) (not= :close-path (:command nxt)))
(let [off (gpt/point (* k ox) (* k oy))]
(recur (inc i) (inc k)
(recur (inc i) (inc k) start
(conj! result (cond-> (-> cmd
(update-in [:params :x] + (:x off))
(update-in [:params :y] + (:y off)))
@@ -999,7 +1010,7 @@
(update-in [:params :c2y] + (:y off)))))))
:else
(recur (inc i) k (conj! result cmd))))))))
(recur (inc i) k start (conj! result cmd))))))))
(defn separate-nodes
"Removes segments between points or splits one node into offset open ends."
@@ -1072,7 +1083,7 @@
result (cond-> result
(and (nil? set-a) (nil? set-b))
(conj #{point-a point-b})
(conj (hash-set point-a point-b))
(and (some? set-a) (nil? set-b))
(add-to-set set-a point-b)
@@ -1108,6 +1119,144 @@
(->> content
(mapv replace-command))))
(defn- remove-empty-segments
"Drops segments with no length whose ends are accepted by `at-point?`."
[content at-point?]
(loop [result (transient [])
prev nil
segments? false
pending (seq content)]
(if-let [{:keys [command] :as segment} (first pending)]
(let [close? (= :close-path command)
move? (= :move-to command)
point (when-not close? (helpers/segment->point segment))
;; A close command on a subpath without segments draws nothing.
empty? (if close?
(not segments?)
(and (not move?)
(some? prev)
(gpt/close? prev point)
(at-point? point)))]
(if empty?
(recur result prev segments? (next pending))
(recur (conj! result segment)
(if close? nil point)
(not (or move? close?))
(next pending))))
(persistent! result))))
(defn- point-key
"Rounded coordinates of a point, usable as a map key."
[point]
[(mth/round (:x point) 0.1) (mth/round (:y point) 0.1)])
(defn- curve-key
"Key for the curve a segment draws, equal in either direction."
[from segment to]
(let [c1 (or (get-handler segment :c1) from)
c2 (or (get-handler segment :c2) to)
fwd [(point-key from) (point-key c1) (point-key c2) (point-key to)]
bwd [(point-key to) (point-key c2) (point-key c1) (point-key from)]]
(if (neg? (compare fwd bwd)) fwd bwd)))
(defn- node-point-groups
"Node positions of the content grouped by their rounded coordinates."
[content]
(group-by point-key
(into []
(comp (remove #(= :close-path (:command %)))
(map helpers/segment->point))
content)))
(defn- coincident-points
"Positions of the content that more than one command holds."
[content]
(into #{}
(comp (filter (fn [[_ points]] (> (count points) 1)))
(map (fn [[_ points]] (first points))))
(node-point-groups content)))
(defn- repeated-nodes
"Rounded positions accepted by `at-point?` that more than one command holds."
[content at-point?]
(into #{}
(comp (filter (fn [[_ points]]
(and (> (count points) 1)
(at-point? (first points)))))
(map key))
(node-point-groups content)))
(defn- resume-segment
"Commands that reopen the subpath at `from` and draw `segment` from there."
[from segment start]
(if (= :close-path (:command segment))
(when-not (subpath/pt= from start)
[(helpers/make-move-to from) (helpers/make-line-to start)])
[(helpers/make-move-to from) segment]))
(defn- remove-retraced-segments
"Drops the segments that draw a curve already drawn through a node.
A node held by several commands is a junction, but two segments meeting
there and drawing the same curve are one line traced twice."
[content at-point?]
(let [repeated (repeated-nodes content at-point?)
retraced? (fn [from to]
(or (contains? repeated (point-key from))
(contains? repeated (point-key to))))]
(if (empty? repeated)
content
(loop [result (transient [])
pending (seq content)
drawn #{}
from nil
start nil
lifted? false]
(if-let [{:keys [command] :as segment} (first pending)]
(if (= :move-to command)
(let [point (helpers/segment->point segment)]
(recur (conj! result segment) (next pending) drawn point point false))
(let [to (if (= :close-path command)
start
(helpers/segment->point segment))
key (curve-key from segment to)]
(if (and (contains? drawn key)
(retraced? from to))
(recur result (next pending) drawn to start true)
(recur (reduce conj! result (if lifted?
(resume-segment from segment start)
[segment]))
(next pending) (conj drawn key) to start false))))
(persistent! result))))))
(defn merge-coincident-nodes
"Collapses the commands sharing a position at `points` into a single node.
Drops the empty segments and the ones retracing another through such a
point, and stitches the subpath ends meeting there, closing the resulting
loops. A point where more than two distinct segments meet is left alone: the
format needs one command per segment there. Without `points` every position
held by more than one command is merged."
([content]
(merge-coincident-nodes content (coincident-points content)))
([content points]
(let [at-point? (fn [point] (some #(gpt/close? point %) points))
stitch (fn [content]
(-> content
(subpath/close-subpaths at-point?)
;; A subpath whose ends meet carries an explicit close command.
(subpath/close-loops)))
content (-> (vec content)
(remove-empty-segments at-point?)
(stitch))
retraced (remove-retraced-segments content at-point?)]
(if (= retraced content)
content
(stitch retraced)))))
(defn merge-nodes
"Joins and merges `points` into one point."
[content points]
@@ -1116,10 +1265,12 @@
(if (seq segments)
(let [point->merge-point (-> segments
(group-segments)
(calculate-merge-points points))]
(calculate-merge-points points))
merge-points (set (vals point->merge-point))]
(-> content
(separate-nodes points)
(replace-points point->merge-point)))
(replace-points point->merge-point)
(merge-coincident-nodes merge-points)))
content)))
(defn transform-content
+39 -32
View File
@@ -99,25 +99,30 @@
(defn- merge-paths
"Tries to merge into candidate the subpaths. Will return the candidate with the subpaths merged
and removed from subpaths the subpaths merged"
[candidate subpaths]
(let [merge-with-candidate
and removed from subpaths the subpaths merged. Only meeting points accepted
by `meet?` are joined"
[candidate subpaths meet?]
(let [joins?
(fn [point other]
(and (pt= point other) (meet? point)))
merge-with-candidate
(fn [[candidate result] current]
(cond
(pt= (:to current) (:from current))
;; Subpath is already a closed path
[candidate (conj result current)]
(pt= (:to candidate) (:from current))
(joins? (:to candidate) (:from current))
[(subpaths-join candidate current) result]
(pt= (:from candidate) (:to current))
(joins? (:from candidate) (:to current))
[(subpaths-join current candidate) result]
(pt= (:to candidate) (:to current))
(joins? (:to candidate) (:to current))
[(subpaths-join candidate (reverse-subpath current)) result]
(pt= (:from candidate) (:from current))
(joins? (:from candidate) (:from current))
[(subpaths-join (reverse-subpath current) candidate) result]
:else
@@ -163,35 +168,37 @@
(into [] xf-mapcat-data merged)))
(defn close-subpaths
"Searches a path for possible subpaths that can create closed loops and merge them"
[content]
(let [subpaths (get-subpaths content)
closed-subpaths
(loop [result []
current (first subpaths)
subpaths (rest subpaths)]
"Searches a path for possible subpaths that can create closed loops and merge them.
When `meet?` is given only subpaths that touch at an accepted point are merged"
([content]
(close-subpaths content (constantly true)))
([content meet?]
(let [subpaths (get-subpaths content)
closed-subpaths
(loop [result []
current (first subpaths)
subpaths (rest subpaths)]
(if (some? current)
(let [[new-current new-subpaths]
(if (is-closed? current)
[current subpaths]
(merge-paths current subpaths))]
(if (some? current)
(let [[new-current new-subpaths]
(if (is-closed? current)
[current subpaths]
(merge-paths current subpaths meet?))]
(if (= current new-current)
;; If equal we haven't found any matching subpaths we advance
(recur (conj result new-current)
(first new-subpaths)
(rest new-subpaths))
(if (= current new-current)
;; If equal we haven't found any matching subpaths we advance
(recur (conj result new-current)
(first new-subpaths)
(rest new-subpaths))
;; If different we need to pass again the merge to check for additional
;; subpaths to join
(recur result
new-current
new-subpaths)))
result))]
;; If different we need to pass again the merge to check for additional
;; subpaths to join
(recur result
new-current
new-subpaths)))
result))]
(into [] xf-mapcat-data closed-subpaths)))
(into [] xf-mapcat-data closed-subpaths))))
(defn- close-loop
"Adds an explicit close command when a subpath's endpoints meet."
+17 -1
View File
@@ -272,6 +272,20 @@
-1))))
items))))
(defn- clipped-by-ancestor?
"Checks whether position falls outside the visible (clipped) bounds of
some ancestor frame with clip content enabled. Used so that a nested
frame that extends beyond a clipping ancestor's own bounds is never
considered hit/reachable in the invisible, clipped-away region."
[objects shape position]
(->> (cfh/get-parent-ids objects (dm/get-prop shape :id))
(keep (d/getf objects))
(some (fn [ancestor]
(and (not= (dm/get-prop ancestor :id) uuid/zero)
^boolean (cfh/frame-shape? ancestor)
(not (:show-content ancestor))
(not ^boolean (gsh/has-point? ancestor position)))))))
(defn get-frame-by-position
([objects position]
(get-frame-by-position objects position nil))
@@ -287,6 +301,7 @@
validator (or (get options :validator) #(-> true))]
(or (d/seek #(and ^boolean (some? position)
^boolean (gsh/has-point? % position)
^boolean (not (clipped-by-ancestor? objects % position))
^boolean (validator %))
frames)
(get objects uuid/zero)))))
@@ -302,7 +317,8 @@
([objects position options]
(->> (get-frames objects options)
(filter #(and ^boolean (some? position)
^boolean (gsh/has-point? % position)))
^boolean (gsh/has-point? % position)
^boolean (not (clipped-by-ancestor? objects % position))))
(sort-z-index-objects objects))))
(defn top-nested-frame
@@ -1373,6 +1373,20 @@
(t/is (= {:c2x 4.0 :c2y 4.0}
(select-keys (:params (peek result)) [:c2x :c2y])))))
(t/deftest segment-separate-single-node-closed-subpath-start
;; The seam of a closed subpath opens even when it is the subpath start.
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :close-path :params {}}])
result (vec (path/separate-nodes content #{(gpt/point 0.0 0.0)}))]
;; the close command becomes the second, offset, open end
(t/is (= [:move-to :line-to :line-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 0.0}
{:x 10.0 :y 10.0} {:x 8.0 :y 8.0}]
(mapv #(select-keys (:params %) [:x :y]) result)))))
(t/deftest segment-separate-single-node-endpoint-noop
;; an endpoint node has no following segment, so nothing is split
(let [content (path/content
@@ -2092,7 +2106,7 @@
(t/is (some? result)))))
(t/deftest path-merge-disconnected-nodes
;; Merging separate subpaths joins them at the shared midpoint.
;; Merging separate subpaths stitches them into one at the shared midpoint.
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
@@ -2100,10 +2114,184 @@
{:command :line-to :params {:x 10.0 :y 10.0}}])
pts #{(gpt/point 10.0 0.0) (gpt/point 0.0 10.0)}
result (vec (path/merge-nodes content pts))]
(t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0}
{:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
(mapv :params result)))))
(t/deftest path-merge-nodes-leaves-a-single-node
;; The merged node exists once, so separating it yields a fresh split.
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :move-to :params {:x 20.0 :y 0.0}}
{:command :line-to :params {:x 12.0 :y 12.0}}])
merged (path/merge-nodes content #{(gpt/point 10.0 10.0)
(gpt/point 12.0 12.0)})
node (gpt/point 11.0 11.0)]
(t/is (= 1 (count (path/point-indices merged node))))
;; separating splits the node in two ends, none of them the merged nodes
(let [result (vec (path/separate-nodes merged #{node} (gpt/point 8.0 8.0)))]
(t/is (= [{:x 0.0 :y 0.0} {:x 11.0 :y 11.0}
{:x 19.0 :y 19.0} {:x 20.0 :y 0.0}]
(mapv #(select-keys (:params %) [:x :y]) result))))))
(t/deftest path-merge-nodes-on-empty-segment
;; Merging across an empty segment returns a content instead of throwing
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 20.0 :y 0.0}}])]
(t/is (some? (path/merge-nodes content #{(gpt/point 0.0 0.0)
(gpt/point 20.0 0.0)})))))
(t/deftest path-merge-coincident-nodes-stitches-dragged-ends
;; Two open ends left at the same position become one node
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :move-to :params {:x 20.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}])
result (vec (path/merge-coincident-nodes content #{(gpt/point 10.0 10.0)}))]
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 10.0} {:x 20.0 :y 0.0}]
(mapv :params result)))))
(t/deftest path-merge-coincident-nodes-drops-empty-segment
;; A node dragged onto its neighbour leaves no segment behind
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 20.0 :y 0.0}}])
result (vec (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)}))]
(t/is (= [:move-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 0.0} {:x 20.0 :y 0.0}] (mapv :params result)))))
(t/deftest path-merge-coincident-nodes-closes-the-loop
;; Dragging both ends of a subpath together closes it
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}])
result (vec (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)}))]
(t/is (= [:move-to :line-to :close-path] (mapv :command result)))))
(t/deftest path-merge-coincident-nodes-only-at-given-points
;; Subpaths touching somewhere else are left alone
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :move-to :params {:x 20.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}])
result (path/merge-coincident-nodes content #{(gpt/point 20.0 0.0)})]
(t/is (= (vec content) (vec result)))))
(t/deftest path-merge-coincident-nodes-keeps-closed-subpaths
;; Closed subpaths keep their close command, wherever the merge happens
(let [rect (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :line-to :params {:x 0.0 :y 10.0}}
{:command :close-path :params {}}])
curve (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 8.0
:x 10.0 :y 10.0}}
{:command :curve-to :params {:c1x 8.0 :c1y -8.0 :c2x 2.0 :c2y -2.0
:x 0.0 :y 0.0}}
{:command :close-path :params {}}])]
(t/is (= (vec rect) (vec (path/merge-coincident-nodes rect #{(gpt/point 10.0 0.0)}))))
(t/is (= (vec rect) (vec (path/merge-coincident-nodes rect #{(gpt/point 0.0 0.0)}))))
(t/is (= (vec curve) (vec (path/merge-coincident-nodes curve #{(gpt/point 0.0 0.0)}))))))
(t/deftest path-merge-coincident-nodes-keeps-junctions
;; Four distinct segments meeting at a point need one command each
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 5.0 :y 5.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :move-to :params {:x 0.0 :y 10.0}}
{:command :line-to :params {:x 5.0 :y 5.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}])
result (path/merge-coincident-nodes content #{(gpt/point 5.0 5.0)})]
(t/is (= (vec content) (vec result)))))
(t/deftest path-merge-coincident-nodes-drops-a-retraced-segment
;; The rest of the loop draws the same two lines backwards; dropping them
;; leaves a single node where they meet.
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 5.0}}
{:command :line-to :params {:x 20.0 :y 10.0}}
{:command :curve-to :params {:c1x 20.0 :c1y 10.0
:c2x 10.0 :c2y 5.0
:x 10.0 :y 5.0}}
{:command :close-path :params {}}])
result (vec (path/merge-coincident-nodes content #{(gpt/point 10.0 5.0)}))]
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 5.0} {:x 20.0 :y 10.0}]
(mapv :params result)))
(t/is (= 1 (count (path/point-indices result (gpt/point 10.0 5.0)))))))
(t/deftest path-merge-coincident-nodes-stitches-a-retraced-junction
;; The same two lines, drawn out and back from the subpath start
(let [content (path/content
[{:command :move-to :params {:x 5.0 :y 5.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 5.0 :y 5.0}}
{:command :line-to :params {:x 0.0 :y 10.0}}
{:command :close-path :params {}}])
result (vec (path/merge-coincident-nodes content #{(gpt/point 5.0 5.0)}))]
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 10.0} {:x 5.0 :y 5.0} {:x 10.0 :y 0.0}]
(mapv :params result)))))
(t/deftest path-merge-coincident-nodes-collapses-every-repeated-node
;; Without points every position held by more than one command is merged
(let [content (path/content
[{:command :move-to :params {:x 119.0 :y 231.0}}
{:command :line-to :params {:x 447.0 :y 253.0}}
{:command :curve-to :params {:c1x 447.0 :c1y 253.0
:c2x 774.0 :c2y 384.0
:x 774.0 :y 384.0}}
{:command :curve-to :params {:c1x 774.0 :c1y 384.0
:c2x 447.0 :c2y 253.0
:x 447.0 :y 253.0}}
{:command :close-path :params {}}])
result (vec (path/merge-coincident-nodes content))]
(t/is (= [:move-to :line-to :curve-to] (mapv :command result)))
(t/is (= 1 (count (path/point-indices result (gpt/point 447.0 253.0)))))))
(t/deftest path-merge-coincident-nodes-keeps-distinct-curves
;; Two different curves between the same two points are not a retrace
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 8.0
:x 10.0 :y 10.0}}
{:command :curve-to :params {:c1x 8.0 :c1y -8.0 :c2x 2.0 :c2y -2.0
:x 0.0 :y 0.0}}
{:command :line-to :params {:x 0.0 :y 20.0}}])
result (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)})]
(t/is (= (vec content) (vec result)))))
(t/deftest path-separate-nodes-after-merge-yields-one-end-per-line
;; A node with two visible lines separates into two ends.
(let [content (path/content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 5.0}}
{:command :line-to :params {:x 20.0 :y 10.0}}
{:command :curve-to :params {:c1x 20.0 :c1y 10.0
:c2x 10.0 :c2y 5.0
:x 10.0 :y 5.0}}
{:command :close-path :params {}}])
node (gpt/point 10.0 5.0)
merged (path/merge-coincident-nodes content #{node})
result (vec (path/separate-nodes merged #{node} (gpt/point 4.0 4.0)))]
(t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result)))
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 5.0}
{:x 14.0 :y 9.0} {:x 20.0 :y 10.0}]
(mapv #(select-keys (:params %) [:x :y]) result)))))
(t/deftest path-duplicate-node-content
;; Duplicating a node copies its incident segments as subpaths.
(let [content (path/content
@@ -0,0 +1,58 @@
;; 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 common-tests.types-shape-tree-test
(:require
[app.common.geom.point :as gpt]
[app.common.types.shape-tree :as ctt]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(defn- make-frame
[id parent-id shapes x y width height show-content]
{:id id
:type :frame
:parent-id parent-id
:frame-id parent-id
:shapes (vec shapes)
:x x
:y y
:width width
:height height
:rotation nil
:hidden false
:blocked false
:show-content show-content})
(t/deftest top-nested-frame-clip-content-test
(t/testing "board A (clip) contains a wider board B; point inside both resolves to B"
(let [a-id (uuid/next)
b-id (uuid/next)
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
position (gpt/point 150 150)
result (ctt/top-nested-frame objects position)]
(t/is (= b-id result))))
(t/testing "point inside B but outside A's clipped bounds is not reachable at all"
(let [a-id (uuid/next)
b-id (uuid/next)
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
position (gpt/point 300 300)
result (ctt/top-nested-frame objects position)]
;; Outside A (the clip ancestor) and B's visible/clipped region there is
;; not visible either, so no frame should be resolved at that point.
(t/is (= uuid/zero result))))
(t/testing "with show-content true on A, the same point can resolve into B"
(let [a-id (uuid/next)
b-id (uuid/next)
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 true)
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
position (gpt/point 300 300)
result (ctt/top-nested-frame objects position)]
(t/is (= b-id result)))))
@@ -15,7 +15,7 @@
(defn clean-edit-state
[state]
(dissoc state :last-point :prev-handler :drag-handler :preview))
(dissoc state :last-point :prev-handler :drag-handler :preview :pending-start))
(defn- drop-trailing-move-to
"Drops a trailing subpath start without segments."
@@ -113,7 +113,9 @@
(update [_ state]
(let [id (st/get-path-id state)
fix-angle? shift?
{:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id])
{:keys [last-point prev-handler pending-start]}
(get-in state [:workspace-local :edit-path id])
position (cond-> (gpt/point x y)
fix-angle? (path.helpers/position-fixed-angle last-point))]
(if-not (= last-point position)
@@ -121,6 +123,9 @@
(assoc-in [:workspace-local :edit-path id :last-point] position)
(update-in [:workspace-local :edit-path id] dissoc :prev-handler)
(update-in [:workspace-local :edit-path id] dissoc :preview)
(update-in [:workspace-local :edit-path id] dissoc :pending-start)
(cond-> (some? pending-start)
(update-in (st/get-path-location state) helpers/start-subpath pending-start))
(update-in (st/get-path-location state) helpers/append-node position last-point prev-handler))
state)))))
@@ -398,16 +403,19 @@
(cond-> (some? drop-index)
(with-meta {:index drop-index})))))))))
(defn- close-drawn-loops
"Adds explicit close commands to completed loops."
(defn- clean-drawn-content
"Collapses the nodes drawn on top of each other and closes completed loops.
Clicking a node already in the path draws its segments again backwards, and
only one copy of each line is kept."
[]
(ptk/reify ::close-drawn-loops
(ptk/reify ::clean-drawn-content
ptk/UpdateEvent
(update [_ state]
(d/update-in-when state [:workspace-drawing :object]
(fn [object]
(-> object
(update :content path/close-loops)
(update :content path/merge-coincident-nodes)
(path/update-geometry)))))))
(defn- handle-drawing-end
@@ -427,13 +435,13 @@
(cond
(and (> (count content) 1) restart?)
(rx/of (common/finish-path)
(close-drawn-loops)
(clean-drawn-content)
(setup-frame)
(dwdc/handle-finish-drawing)
(start-created-path-edition shape-id))
(> (count content) 1)
(rx/of (close-drawn-loops)
(rx/of (clean-drawn-content)
(setup-frame)
(dwdc/handle-finish-drawing)
(dwe/clear-edition-mode))
@@ -529,16 +537,11 @@
pos (helpers/node-position content index)
last-idx (dec (count content))
tip? (and (= index last-idx)
(not= :close-path (:command (nth content index nil))))
state (assoc-in state [:workspace-local :edit-path id :last-point] pos)]
(if tip?
state
(update-in state (st/get-path-location state)
(fn [shape]
(-> shape
(update :content path/append-segment
{:command :move-to :params (select-keys pos [:x :y])})
(path/update-geometry))))))
(not= :close-path (:command (nth content index nil))))]
(cond-> (assoc-in state [:workspace-local :edit-path id :last-point] pos)
;; A tip already ends the content; an inner node needs its own start.
(not tip?)
(assoc-in [:workspace-local :edit-path id :pending-start] pos)))
state)))
(defn change-edit-mode
@@ -163,6 +163,7 @@
(-> state
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)
(assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler)
(assoc-in [:workspace-local :edit-path id :edited-handler] primary)
(cond-> (some? new-prev-handler)
(assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler)))))))
@@ -286,7 +287,10 @@
content-modifiers))]
(-> state
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)
(cond-> (= 1 (count handler-ids))
(assoc-in [:workspace-local :edit-path id :edited-handler]
(first handler-ids))))))))
(defn- move-node-indices
[state node-indices from-point to-point]
@@ -466,6 +470,7 @@
(rx/map #(move-selected-path-point start-position %))
(rx/take-until stopper))
(rx/of (apply-content-modifiers)
(tools/merge-coincident-nodes)
(merge-dragged-on-drop)))))))
(declare drag-selected-segments)
@@ -555,6 +560,7 @@
(rx/map #(move-selected-path-segment start-position %))
(rx/take-until stopper))
(rx/of (apply-content-modifiers)
(tools/merge-coincident-nodes)
(merge-dragged-on-drop))))))))
(defn bend-segment-modifier
@@ -753,6 +759,7 @@
(rx/of (move-selected direction shift?)))
(rx/of (apply-content-modifiers)
(tools/merge-coincident-nodes)
(finish-move-selected))))
(rx/empty)))))))
@@ -946,7 +953,7 @@
(ptk/data-event :layout/update {:ids [id]})))))
(defn- split-segments
[_id {:keys [from-p to-p t]}]
[id {:keys [from-p to-p t]}]
(ptk/reify ::split-segments
ptk/UpdateEvent
(update [_ state]
@@ -955,7 +962,9 @@
(st/set-content (-> content
(path/split-segments #{from-p to-p} t)
(path/content)))
(update-in (st/get-path-location state) path/update-geometry))))))
(update-in (st/get-path-location state) path/update-geometry)
;; The inserted command shifts the indices a handler id refers to.
(update-in [:workspace-local :edit-path id] dissoc :edited-handler))))))
(defn create-node-at-position
[params]
@@ -15,6 +15,12 @@
[app.common.types.path :as path]
[app.common.types.path.helpers :as path.helpers]))
(defn start-subpath
"Adds the subpath start a pending node draws its first segment from."
[shape position]
(update shape :content path/append-segment
{:command :move-to :params (select-keys position [:x :y])}))
(defn append-node
"Creates a new node in the path. Usually used when drawing."
[shape position prev-point prev-handler]
@@ -180,6 +186,36 @@
:else nil)))
(defn node-handler-ids
"Returns a node's curve handlers, its primary handle first."
[content node-index]
(if-let [[index prefix :as primary] (node-primary-handler content node-index)]
(let [[op-idx op-prefix] (path/opposite-index content index prefix)]
(if (some? op-idx)
[primary [op-idx op-prefix]]
[primary]))
[]))
(defn handler-type-reference
"Returns the handler that keeps its geometry when a node's handler type changes.
The other handler adapts to it. Priority: the node's only selected handler,
then `edited-handler` when it is one of this node's two handlers, then its
primary handle. `edited-handler` is the last handler edited anywhere in the
path, so a node only gets this hint while it holds the latest edit."
[content selection edited-handler node-index]
(let [handler-ids (node-handler-ids content node-index)
selected (filterv (get selection :handlers #{}) handler-ids)]
(cond
(= 1 (count selected))
(first selected)
(some #{edited-handler} handler-ids)
edited-handler
:else
(first handler-ids))))
(defn handlers-equal-length?
"True when a node's two handlers are the same distance from the node."
[content index prefix]
@@ -301,17 +337,33 @@
(remove nil?))
(segment-entries content))))
(defn coincident-node-indices
"Adds to `indices` every other command sharing one of their positions.
Commands at the same position are one node: they move together, so an
action cannot depend on which of them the selection holds."
[content indices]
(let [indices (into #{} (filter #(node? content %)) indices)]
(into indices
(mapcat #(path/point-indices content %))
(node-positions content indices))))
(defn selected-node-count
"Number of nodes in the selection, counting coincident commands as one."
[content selection]
(count (node-positions content (get selection :nodes #{}))))
(defn check-enabled
"Returns path actions enabled for selected node indices."
[content selected-nodes]
(when content
(let [selected-nodes (into #{} (filter #(node? content %)) selected-nodes)
(let [selected-nodes (coincident-node-indices content selected-nodes)
selected-segments (filter (fn [{:keys [from-index to-index]}]
(and (contains? selected-nodes from-index)
(contains? selected-nodes to-index)))
(segment-entries content))
num-segments (count selected-segments)
num-nodes (count selected-nodes)
num-nodes (count (node-positions content selected-nodes))
nodes-selected? (seq selected-nodes)
segments-selected? (seq selected-segments)
max-segments (/ (* num-nodes (dec num-nodes)) 2)
@@ -523,6 +575,12 @@
(let [selection (or selection empty-selection)]
(if (= (count old-content) (count new-content))
(-> selection
;; Drop indices that stopped being nodes.
(update :nodes
(fn [nodes]
(into #{}
(filter #(node? new-content %))
nodes)))
(update :handlers
(fn [handlers]
(into #{}
@@ -49,7 +49,8 @@
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content))))
#(helpers/remap-handler-types % old-content new-content))
(update-in [:workspace-local :edit-path id] dissoc :edited-handler)))
state)))
ptk/WatchEvent
@@ -80,9 +81,11 @@
(reduce path/make-curve-point content))))))
(defn- apply-handler-type-modifiers
"Returns modifiers that reshape a node's handlers to `type`."
[content node-index type]
(if-let [[idx prefix] (helpers/node-primary-handler content node-index)]
"Returns modifiers that reshape a node's handlers to `type`.
`reference` keeps its geometry; the opposite handler adapts to it."
[content reference type]
(if-let [[idx prefix] reference]
(case type
:mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0)
:aligned (helpers/align-handler-modifiers content idx prefix 0 0)
@@ -98,10 +101,13 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selection (st/get-selection state id)
edited (dm/get-in state [:workspace-local :edit-path id :edited-handler])
nodes (helpers/handler-target-nodes content selection)]
(if (and (some? content) (seq nodes))
(let [modifiers (reduce (fn [acc node-index]
(d/deep-merge acc (apply-handler-type-modifiers content node-index type)))
(let [reference (helpers/handler-type-reference
content selection edited node-index)]
(d/deep-merge acc (apply-handler-type-modifiers content reference type))))
{} nodes)
new-content (path/apply-content-modifiers content modifiers)]
(-> (st/set-content state new-content)
@@ -136,17 +142,22 @@
(make-curve point)))))))))
(defn- update-path-content
"Updates path content, geometry, selection, and handler types."
[state new-content]
(let [id (st/get-path-id state)
old-content (st/get-path state :content)]
(-> (cond-> (st/set-content state new-content)
(seq new-content)
(update-in (st/get-path-location state) path/update-geometry))
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content)))))
"Updates path content, geometry, selection, and handler types.
The selection is remapped by position, so a tool that moves nodes before
changing the content structure passes the moved content as `old-content`."
([state new-content]
(update-path-content state (st/get-path state :content) new-content))
([state old-content new-content]
(let [id (st/get-path-id state)]
(-> (cond-> (st/set-content state new-content)
(seq new-content)
(update-in (st/get-path-location state) path/update-geometry))
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content))
(update-in [:workspace-local :edit-path id] dissoc :edited-handler)))))
(defn remove-segments
"Removes segments and opens the path at their endpoints."
@@ -227,6 +238,30 @@
(defn merge-nodes []
(process-path-tool path/merge-nodes))
(defn- merge-coincident
"Collapses the nodes of `indices` sharing a position with another node."
[content indices]
(path/merge-coincident-nodes content (helpers/node-positions content indices)))
(defn merge-coincident-nodes
"Merges the selected nodes sharing a position with another node.
Runs after a move, which leaves the nodes it brings together as one
command each."
[]
(ptk/reify ::merge-coincident-nodes
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)
content (st/get-path state :content)
indices (helpers/selected-node-indices content (st/get-selection state id))
new-content (when (and (some? content) (seq indices))
(merge-coincident content indices))]
(if (and (some? new-content)
(not= (vec new-content) (vec content)))
(update-path-content state new-content)
state)))))
(defn join-nodes []
(process-path-tool path/join-nodes))
@@ -281,9 +316,8 @@
indices (if (seq selected)
selected
(helpers/node-indices content))
content (path/flip-content content indices axis)]
(-> (st/set-content state content)
(update-in (st/get-path-location state) path/update-geometry))))))
flipped (path/flip-content content indices axis)]
(update-path-content state flipped (merge-coincident flipped indices))))))
(defn align-nodes
"Aligns selected nodes and their handles within their bounds."
@@ -294,9 +328,8 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selected (get (st/get-selection state id) :nodes #{})
content (path/align-content content selected axis)]
(-> (st/set-content state content)
(update-in (st/get-path-location state) path/update-geometry))))))
aligned (path/align-content content selected axis)]
(update-path-content state aligned (merge-coincident aligned selected))))))
(defn distribute-nodes
"Distributes selected nodes evenly along `axis`."
@@ -307,9 +340,8 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selected (get (st/get-selection state id) :nodes #{})
content (path/distribute-content content selected axis)]
(-> (st/set-content state content)
(update-in (st/get-path-location state) path/update-geometry))))))
spread (path/distribute-content content selected axis)]
(update-path-content state spread (merge-coincident spread selected))))))
(defn- axis-point
"Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`."
@@ -379,8 +411,8 @@
(cond-> content
(seq node-idx) (path/set-nodes-coordinate node-idx axis value)
(seq pts) (path/set-handler-points pts))))]
(-> (st/set-content state new-content)
(update-in (st/get-path-location state) path/update-geometry))))))
(update-path-content state new-content
(merge-coincident new-content node-idx))))))
(defn toggle-snap []
(ptk/reify ::toggle-snap
@@ -216,8 +216,7 @@
(fn [event]
(when (kbd/enter? event)
(dom/stop-propagation event)
(on-menu-click event))))
title-width (/ 100 limit)]
(on-menu-click event))))]
[:article {:class (stl/css-case :dashboard-project-row true :first is-first)}
[:header {:class (stl/css :project)}
@@ -227,7 +226,6 @@
:on-end on-edit
:max-length 250}]
[:h2 {:on-click on-nav
:style {:max-width (str title-width "%")}
:class (stl/css :project-name)
:title (if (:is-default project)
(tr "labels.drafts")
@@ -92,11 +92,14 @@
block-size: $sz-16;
line-height: 0.8;
margin-inline-end: var(--sp-m);
flex: 0 1 auto;
min-inline-size: 0;
}
.info-wrapper {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: var(--sp-s);
}
@@ -467,6 +467,7 @@
drag-handler
prev-handler
preview
last-point
content-modifiers
selection
moving-nodes
@@ -534,9 +535,6 @@
(mf/with-memo [content selected-segments]
(dwp.helpers/segment-node-indices content selected-segments))
last-p
(->> content last path.helpers/segment->point)
handlers
(mf/with-memo [content]
(path/get-handlers content))
@@ -589,7 +587,7 @@
(and is-hover (some? hover-point))))}]))
(when (and preview (not drag-handler))
[:> path-preview* {:segment preview
:from last-p
:from last-point
:zoom zoom}])
;; Let insertion preview clicks reach the segment.
@@ -600,9 +598,9 @@
:is-new true
:zoom zoom}]])
(when (and drag-handler last-p)
(when (and drag-handler last-point)
[:g.drag-handler {:pointer-events "none"}
[:> path-handler* {:point last-p
[:> path-handler* {:point last-point
:handler drag-handler
:edit-mode edit-mode
:zoom zoom}]])
@@ -624,10 +622,10 @@
:drag-cursor drag-cursor
:any-node-selected any-node-selected?}])
(when (and prev-handler last-p)
(when (and prev-handler last-point)
[:g.prev-handler
[:> path-handler*
{:point last-p
{:point last-point
:edit-mode edit-mode
:handler prev-handler
:zoom zoom
@@ -15,6 +15,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as udw]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.path.helpers :as path.helpers]
[app.main.data.workspace.path.state :as path.state]
[app.main.features :as features]
[app.main.refs :as refs]
@@ -115,8 +116,16 @@
path-editing?
(path.state/editing? edit-path edition)
path-content
(dm/get-in drawing [:object :content])
selected-nodes
(:nodes (:selection edit-path-state))
;; Coincident commands are one node, so count positions.
path-node-count
(count (dm/get-in edit-path-state [:selection :nodes]))
(mf/with-memo [path-content selected-nodes]
(path.helpers/selected-node-count path-content {:nodes selected-nodes}))
files
(mf/deref refs/files)
+4
View File
@@ -2211,6 +2211,10 @@
[background]
(when (initialized?)
(let [rgba (sr-clr/hex->u32argb background 1)]
;; Background is baked into every tile. Cancel Partial/ViewportReady so we
;; do not continue a progressive pass whose tile cache was just cleared —
;; that drops already-finished tiles from the queue and leaves bg-only holes.
(stop-progressive-render!)
(h/call wasm/internal-module "_set_canvas_background" rgba)
(request-render "set-canvas-background"))))
+8 -4
View File
@@ -245,16 +245,20 @@
overlaps-parent?
(fn [clip-parents]
(->> clip-parents (some (comp not overlaps?)) not))]
(->> clip-parents
;; When clip-children? is false (e.g. deep/penetrate selection with
;; a modifier key held) we still must not reach into the clipped-away,
;; invisible area of an ancestor board with clip content enabled.
;; Only the bool/mask clip-parents (non-frame) are relaxed in that case.
(remove #(and (not clip-children?) (not ^boolean (cfh/frame-shape? %))))
(every? overlaps?)))]
;; Shapes after filters of overlapping and criteria
(into (d/ordered-set)
(comp (map #(unchecked-get % "data"))
(filter match-criteria?)
(filter overlaps?)
(filter (if clip-children?
(comp overlaps-parent? :clip-parents)
(constantly true)))
(filter (comp overlaps-parent? :clip-parents))
(keep :id))
result)))
@@ -7,6 +7,7 @@
(ns frontend-tests.logic.path-actions-test
(:require
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.types.path :as path]
[app.main.data.workspace.path.helpers :as path.helpers]
[app.main.ui.workspace.viewport.path-actions :as path.actions]
@@ -22,7 +23,8 @@
(t/is (true? (:make-corner enabled)))
(t/is (true? (:make-curve enabled)))))
(t/deftest action-eligibility-keeps-coincident-node-identities
(t/deftest action-eligibility-treats-coincident-commands-as-one-node
;; Both commands sit at (0,0): one node, with a corner and a curve on it.
(let [content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
@@ -32,8 +34,42 @@
enabled (path.helpers/check-enabled content #{0 2})]
(t/is (true? (:make-corner enabled)))
(t/is (true? (:make-curve enabled)))
(t/is (true? (:merge-nodes enabled)))
(t/is (true? (:join-nodes enabled)))))
;; there is a single node, so there is nothing to merge it with
(t/is (false? (:merge-nodes enabled)))
(t/is (false? (:join-nodes enabled)))
(t/is (true? (:separate-nodes enabled)))))
(t/deftest a-junction-node-offers-the-same-actions-however-it-is-selected
;; Three lines meeting at (50,50); a rubber band catches every command
;; there while a click catches one of them.
(let [content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 50 :y 50}}
{:command :move-to :params {:x 100 :y 0}}
{:command :line-to :params {:x 50 :y 50}}
{:command :move-to :params {:x 50 :y 100}}
{:command :line-to :params {:x 50 :y 50}}])
in-rect (path.helpers/nodes-in-rect content (grc/make-rect 45 45 10 10))
clicked (path.helpers/check-enabled content #{1})
dragged (path.helpers/check-enabled content in-rect)]
(t/is (= #{1 3 5} in-rect))
(t/is (= clicked dragged))
;; and they are the actions of a single node
(t/is (false? (:merge-nodes dragged)))
(t/is (false? (:join-nodes dragged)))
(t/is (true? (:separate-nodes dragged)))
;; two distinct nodes offer the multiple-node actions
(t/is (true? (:merge-nodes (path.helpers/check-enabled content #{1 4}))))))
(t/deftest selected-node-count-counts-a-junction-once
(let [content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 50 :y 50}}
{:command :move-to :params {:x 100 :y 0}}
{:command :line-to :params {:x 50 :y 50}}])]
(t/is (= 1 (path.helpers/selected-node-count content {:nodes #{1 3}})))
(t/is (= 2 (path.helpers/selected-node-count content {:nodes #{0 1}})))
(t/is (= 0 (path.helpers/selected-node-count content {})))))
(t/deftest toolbar-separators-only-render-between-visible-tool-groups
(t/are [structural? shape? handler? expected]
@@ -161,7 +161,7 @@
(run-handle-drawing-end
false
(fn [emissions]
(t/is (= [::path.drawing/close-drawn-loops
(t/is (= [::path.drawing/clean-drawn-content
::path.drawing/setup-frame
::dwdc/handle-finish-drawing
::dwe/clear-edition-mode]
@@ -175,13 +175,37 @@
true
(fn [emissions]
(t/is (= [::path.common/finish-path
::path.drawing/close-drawn-loops
::path.drawing/clean-drawn-content
::path.drawing/setup-frame
::dwdc/handle-finish-drawing
::path.drawing/start-created-path-edition]
(mapv ptk/type emissions)))
(done)))))
(t/deftest ending-a-draw-collapses-the-nodes-drawn-on-top-of-each-other
(t/async
done
(run-handle-drawing-end
false
(fn [emissions]
;; A path drawn back onto one of its own nodes and then closed.
(let [clean (first (filter #(= ::path.drawing/clean-drawn-content (ptk/type %))
emissions))
state {:workspace-drawing
{:object {:id (random-uuid)
:type :path
:content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 5}}
{:command :line-to :params {:x 20 :y 10}}
{:command :line-to :params {:x 10 :y 5}}
{:command :close-path :params {}}])}}}
content (-> (ptk/update clean state)
(get-in [:workspace-drawing :object :content]))]
(t/is (= [:move-to :line-to :line-to] (mapv :command (vec content))))
(t/is (= 1 (count (path/point-indices content (gpt/point 10.0 5.0)))))
(done))))))
(t/deftest escape-with-pending-segment-cancels-it-and-keeps-drawing
(let [id (random-uuid)
state {:workspace-local
@@ -306,8 +306,11 @@
state' (ptk/update (path.tools/set-selection-coordinate :x 5) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
;; both ends land on (5,0), where they merge and close the subpath
(t/is (= [:move-to :line-to :close-path] (mapv :command (vec content'))))
;; the merged node stays selected
(t/is (= #{0} (get-in state' [:workspace-local :edit-path id :selection :nodes])))))
;; a coincident closed-seam node moves as one logical node
(let [id (random-uuid)
content (path/content
@@ -319,7 +322,8 @@
state' (ptk/update (path.tools/set-selection-coordinate :y 7) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 2))))
;; the seam is one node, so the subpath closes on it
(t/is (= [:move-to :line-to :close-path] (mapv :command (vec content')))))
;; a selected handler on an independent node moves only its own control point
(let [id (random-uuid)
content (path/content
@@ -359,17 +363,17 @@
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}])]
;; a middle node: opens a new subpath (move-to) at the node and makes it the
;; pending origin, so the next click draws a line from it
;; a middle node: becomes the pending origin of a new subpath, which stays
;; out of the content until the next click draws a line from it
(let [state (pth/selectable-path-state id content
{:nodes #{1} :segments #{} :handlers #{}})
state' (ptk/update (path.drawing/change-edit-mode :draw) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 10 0)
(get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (= 4 (count content')))
(t/is (= :move-to (:command (nth content' 3))))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3))))
(t/is (= (gpt/point 10 0)
(get-in state' [:workspace-local :edit-path id :pending-start])))
(t/is (= (vec content) (vec content'))))
;; the drawing tip: just becomes the pending origin (extends), no new subpath
(let [state (pth/selectable-path-state id content
{:nodes #{2} :segments #{} :handlers #{}})
@@ -377,6 +381,7 @@
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 20 0)
(get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :pending-start])))
(t/is (= 3 (count content'))))
;; nothing selected: no pending line
(let [state (pth/selectable-path-state id content
@@ -415,7 +420,8 @@
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 20 10) (path.helpers/node-position content' 3)))
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 4)))))
;; the seam commands merge into the subpath close
(t/is (= :close-path (:command (nth (vec content') 4))))))
(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection
;; Selected segments and nodes translate as one group.
@@ -428,12 +434,12 @@
;; The combined bounds start at x=0.
state (pth/selectable-path-state id content
{:nodes #{0} :segments #{3} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :x 10) state)
state' (ptk/update (path.tools/set-selection-coordinate :x 5) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3)))))
(t/is (= (gpt/point 25 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 35 0) (path.helpers/node-position content' 3)))))
(t/deftest set-selection-coordinate-translates-mixed-segment-and-handler-selection
;; Standalone selected handlers translate with the group.
@@ -801,4 +807,208 @@
(t/testing "a node dropped with no neighbour in range does not merge"
(t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}})))))))
(t/deftest nodes-dropped-on-the-same-position-are-merged
;; An exact drop leaves both commands at one position, with no node near it.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 10}}
{:command :move-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 10 :y 10}}])
state (pth/selectable-path-state
id content {:nodes #{3} :segments #{} :handlers #{}})
result (-> (ptk/update (path.tools/merge-coincident-nodes) state)
(path.state/get-path :content))]
(t/is (= [:move-to :line-to :line-to] (mapv :command (vec result))))
;; the two ends are one node, so separating them cannot restore them
(t/is (= 1 (count (path/point-indices result (gpt/point 10.0 10.0)))))))
(t/deftest a-node-dropped-inside-a-closed-subpath-leaves-one-node
;; The rest of the loop retraces the two visible lines backwards, so only
;; those lines survive and the node they meet at exists once.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 5}}
{:command :line-to :params {:x 20 :y 10}}
{:command :curve-to :params {:c1x 20 :c1y 10
:c2x 10 :c2y 5
:x 10 :y 5}}
{:command :close-path :params {}}])
state (pth/selectable-path-state
id content {:nodes #{3} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/merge-coincident-nodes) state)
result (path.state/get-path state' :content)]
(t/is (= [:move-to :line-to :line-to] (mapv :command (vec result))))
(t/is (= 1 (count (path/point-indices result (gpt/point 10.0 5.0)))))
;; the surviving node stays selected
(t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes])))))
(defn- three-node-line []
(path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 5}}
{:command :line-to :params {:x 20 :y 10}}]))
(t/deftest splitting-a-node-in-draw-mode-yields-one-end-per-line
;; Two lines meet at the node, so it separates into two ends.
(let [id (random-uuid)
state (->> (pth/selectable-path-state
id (three-node-line)
{:nodes #{1} :segments #{} :handlers #{}})
(ptk/update (path.drawing/change-edit-mode :draw)))
state' (ptk/update (path.tools/separate-nodes) state)
result (vec (path.state/get-path state' :content))]
(t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result)))
;; one end stays on the node and the other is offset away from it
(t/is (= (gpt/point 10 5) (path.helpers/node-position result 1)))
(t/is (not= (gpt/point 10 5) (path.helpers/node-position result 2)))))
(t/deftest adding-a-node-after-a-pending-start-opens-the-subpath
;; The start reaches the content together with the segment it draws.
(let [id (random-uuid)
state (->> (pth/selectable-path-state
id (three-node-line)
{:nodes #{1} :segments #{} :handlers #{}})
(ptk/update (path.drawing/change-edit-mode :draw)))
state' (ptk/update (path.drawing/add-node {:x 30 :y 30}) state)
result (vec (path.state/get-path state' :content))]
(t/is (= [:move-to :line-to :line-to :move-to :line-to] (mapv :command result)))
(t/is (= [{:x 0 :y 0} {:x 10 :y 5} {:x 20 :y 10} {:x 10 :y 5} {:x 30 :y 30}]
(mapv #(select-keys (:params %) [:x :y]) result)))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :pending-start])))))
(t/deftest aligning-nodes-onto-each-other-merges-them
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :move-to :params {:x 0 :y 10}}
{:command :line-to :params {:x 20 :y 10}}])
state (pth/selectable-path-state
id content {:nodes #{1 3} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/align-nodes :vcenter) state)
result (path.state/get-path state' :content)]
;; both ends meet at (20,5) and become a single node
(t/is (= [:move-to :line-to :line-to] (mapv :command (vec result))))
(t/is (= 1 (count (path/point-indices result (gpt/point 20.0 5.0)))))
;; and that node stays selected
(t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes])))
(t/is (= (gpt/point 20.0 5.0)
(path.helpers/node-position result 1)))))
;; Path-local undo and redo events use a seeded local stack.
;; --- Handler type changes pick which handler keeps its geometry
(defn- aligned-uneven-handlers-content
"Returns content whose node (10,0) has aligned handlers at (8,0) and (16,0)."
[]
(path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]))
(t/deftest making-handlers-equal-keeps-the-selected-handler-length
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
;; The node is aligned with handlers of unequal length.
(t/is (= :aligned (path.helpers/derive-handler-type content 1)))
;; The selected handler keeps its length and the opposite one adapts.
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/deftest making-handlers-equal-falls-back-to-the-last-edited-handler
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (-> (pth/selectable-path-state
id content {:nodes #{1} :segments #{} :handlers #{}})
(assoc-in [:workspace-local :edit-path id :edited-handler]
[2 :c1]))
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/deftest making-handlers-equal-uses-the-incoming-handler-with-no-hint
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{1} :segments #{} :handlers #{}})
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
(t/is (= (gpt/point 8 0) (path/get-handler-point result 1 :c2)))
(t/is (= (gpt/point 12 0) (path/get-handler-point result 2 :c1)))))
(t/deftest aligning-handlers-takes-the-angle-of-the-selected-handler
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 0 :c2x 10 :c2y -3 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
result (-> (ptk/update (path.tools/set-handler-type :aligned) state)
(path.state/get-path :content))]
;; The selected handler stays put; the opposite one rotates onto its axis
;; keeping its own length.
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 7 0) (path/get-handler-point result 1 :c2)))))
(t/deftest dragging-a-handler-records-it-as-the-last-edited-one
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
state' (ptk/update
(path.edition/modify-selected-handlers id [2 :c1] {} 3 0 :independent false)
state)]
(t/is (= [2 :c1]
(get-in state' [:workspace-local :edit-path id :edited-handler])))))
(t/deftest making-handlers-equal-ignores-an-ambiguous-handler-selection
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
mk (fn [] (pth/selectable-path-state
id content
{:nodes #{} :segments #{} :handlers #{[1 :c2] [2 :c1]}}))
equal (fn [state]
(-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content)))]
(t/testing "with both handlers selected the last edited one wins"
(let [result (equal (-> (mk)
(assoc-in [:workspace-local :edit-path id :edited-handler]
[2 :c1])))]
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/testing "with both handlers selected and no hint the incoming one wins"
(let [result (equal (mk))]
(t/is (= (gpt/point 8 0) (path/get-handler-point result 1 :c2)))
(t/is (= (gpt/point 12 0) (path/get-handler-point result 2 :c1)))))))
(t/deftest inserting-a-node-forgets-the-last-edited-handler
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (-> (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{}})
(assoc-in [:workspace-local :edit-path id :edited-handler] [2 :c1]))
events (let [out (atom [])]
(->> (ptk/watch (path.edition/create-node-at-position
{:from-p (gpt/point 0 0)
:to-p (gpt/point 10 0)
:t 0.5})
state (rx/subject))
(rx/subs! #(swap! out conj %)))
@out)
state' (ptk/update (first events) state)]
;; The new command shifts every later index, so [2 :c1] is another node now.
(t/is (= 4 (count (path.state/get-path state' :content))))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :edited-handler])))))
+3 -1
View File
@@ -40,7 +40,9 @@ fn draw_surface_src_rect_to_dst(
return;
}
to_canvas.save();
to_canvas.clip_rect(dst, None, true);
// Hard clip: AA softens shared tile edges so the backbuffer background
// shows through as 1px seams when tiles are composed with SrcOver.
to_canvas.clip_rect(dst, None, false);
let sx = dst.width() / src.width();
let sy = dst.height() / src.height();
to_canvas.translate((dst.left, dst.top));
+405 -27
View File
@@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint};
use crate::shapes::{radius_to_sigma, Shape, Type};
use crate::shapes::{radius_to_sigma, Shadow, Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
@@ -25,6 +25,17 @@ pub(crate) struct SvgLayerCanvas {
pending: Option<skia::svg::Canvas>,
next_id: usize,
frag_no: usize,
/// When true, skip SVG `<filter>` effects so a parent drop-shadow pass can
/// sample silhouettes without nested child shadows (shadow-of-shadow).
pub(super) suppress_filters: bool,
/// Design-space outset applied while drawing a container drop-shadow
/// silhouette. Matches GPU geometric spread (avoids `feMorphology` fattening
/// stroke rings on both edges).
pub(super) silhouette_spread: f32,
/// Design-space drop offset applied in local shape space while drawing a
/// container silhouette (GPU `pre_translate` before rotation). The SVG
/// filter itself uses a zero offset so rotated shadows stay correct.
pub(super) silhouette_offset: (f32, f32),
}
impl SvgLayerCanvas {
@@ -39,9 +50,23 @@ impl SvgLayerCanvas {
pending: None,
next_id: 0,
frag_no: 0,
suppress_filters: false,
silhouette_spread: 0.0,
silhouette_offset: (0.0, 0.0),
}
}
/// CTM for silhouette geometry: original centered transform, then local
/// drop offset (spread is applied by outsetting selrect separately).
pub(super) fn silhouette_draw_matrix(&self, element: &Shape) -> skia::Matrix {
let mut matrix = element.centered_transform();
let (dx, dy) = self.silhouette_offset;
if dx != 0.0 || dy != 0.0 {
matrix.pre_translate((dx, dy));
}
matrix
}
pub(super) fn unique(&mut self, prefix: &str) -> String {
let id = format!("{prefix}{}", self.next_id);
self.next_id += 1;
@@ -103,11 +128,11 @@ impl SvgLayerCanvas {
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 {
/// CTM for leaf content placed in page space: Scale * Translate * `draw_matrix`.
pub(super) fn page_draw_matrix_attr(&self, draw_matrix: &skia::Matrix) -> 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();
ctm = ctm * *draw_matrix;
format!(
"matrix({} {} {} {} {} {})",
ctm.scale_x(),
@@ -123,6 +148,9 @@ impl SvgLayerCanvas {
///
/// A mask can be a group too. Since a group has no geometry of its own, we
/// recurse into its descendants and accumulate their geometry.
///
/// Uses [`Self::silhouette_offset`] so clipped container drop silhouettes
/// move their clip with the offset content (GPU parity).
pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) {
let canvas = self.new_fragment();
{
@@ -130,7 +158,7 @@ impl SvgLayerCanvas {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(skia::Color::BLACK);
draw_clip_geometry(cv, shape, tree, &paint);
draw_clip_geometry(cv, shape, tree, &paint, self.silhouette_offset);
}
self.finish_clip_path_fragment(id, canvas);
}
@@ -152,49 +180,242 @@ impl SvgLayerCanvas {
));
}
/// Registers a layer-blur `<filter>` and returns its id.
/// Registers a composite effects `<filter>` (drop/inner shadows + optional
/// layer blur) matching classic SVG filter order, 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");
/// Order: transparent flood → drop shadows → SourceGraphic → inner shadows
/// → layer blur. Shadow blur uses canvas sigma (`radius_to_sigma`); offsets
/// and spread are scaled by the export scale.
///
/// When `blend_source_graphic` is false, the filter ends after the drop
/// chain (used for container silhouette passes that must not re-emit
/// content — content is drawn in a separate unfiltered group).
pub(super) fn push_effects_filter(
&mut self,
drops: &[&Shadow],
inners: &[&Shadow],
layer_blur_sigma: Option<f32>,
scale: f32,
blend_source_graphic: bool,
) -> String {
let id = self.unique("fx");
let mut body = String::new();
body.push_str(r#"<feFlood flood-opacity="0" result="bg"/>"#);
let mut prev = "bg".to_string();
for (i, shadow) in drops.iter().enumerate() {
let result = format!("drop{i}");
append_drop_shadow_primitives(
&mut body, shadow, scale, &prev, &result, /* morph_spread */ true,
);
prev = result;
}
if blend_source_graphic {
body.push_str(&format!(
r#"<feBlend mode="normal" in="SourceGraphic" in2="{prev}" result="shape"/>"#
));
prev = "shape".to_string();
for (i, shadow) in inners.iter().enumerate() {
let result = format!("inner{i}");
append_inner_shadow_primitives(&mut body, shadow, scale, &prev, &result);
prev = result;
}
if let Some(sigma) = layer_blur_sigma {
body.push_str(&format!(
r#"<feGaussianBlur in="{prev}" stdDeviation="{sigma}"/>"#
));
}
}
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>"
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"{body}</filter>"
),
id = id,
sigma = sigma
region = filter_region_attrs(self),
body = body
));
id
}
}
fn color_matrix_values(color: skia::Color) -> String {
let r = f32::from(color.r()) / 255.0;
let g = f32::from(color.g()) / 255.0;
let b = f32::from(color.b()) / 255.0;
let a = f32::from(color.a()) / 255.0;
format!("0 0 0 0 {r} 0 0 0 0 {g} 0 0 0 0 {b} 0 0 0 {a} 0")
}
/// Filter subregion covering the export page in user space.
///
/// Default SVG `objectBoundingBox` + `x/y=-50% width/height=200%` is a percent
/// of the *shape* bbox. Shadow reach (offset + blur sigma + spread) is absolute
/// pixels, so small/thin shapes crop the halo. Page bounds already include
/// shadow/blur via `extrect`; sizing the filter to the page matches that.
fn filter_region_attrs(builder: &SvgLayerCanvas) -> String {
let w = builder.page_rect.width();
let h = builder.page_rect.height();
format!(r#"filterUnits="userSpaceOnUse" x="0" y="0" width="{w}" height="{h}""#)
}
fn append_drop_shadow_primitives(
body: &mut String,
shadow: &Shadow,
scale: f32,
in2: &str,
result: &str,
morph_spread: bool,
) {
let sigma = radius_to_sigma(shadow.blur * scale);
// `shadow.offset` must already be in the SVG filter user space (parent of
// the transformed leaf). Callers map local design offsets through
// `centered_transform().map_vector` for rotated/flipped leaves.
let dx = shadow.offset.0 * scale;
let dy = shadow.offset.1 * scale;
let spread = shadow.spread * scale;
let color = color_matrix_values(shadow.color);
body.push_str(
r#"<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/>"#,
);
// Container silhouettes apply spread geometrically (GPU). Morphology on a
// stroke ring expands both edges and makes border shadows look too thick.
let after_morph = if morph_spread && spread > 0.0 {
body.push_str(&format!(
r#"<feMorphology in="alpha" operator="dilate" radius="{spread}" result="spread"/>"#
));
"spread"
} else if morph_spread && spread < 0.0 {
body.push_str(&format!(
r#"<feMorphology in="alpha" operator="erode" radius="{}" result="spread"/>"#,
-spread
));
"spread"
} else {
"alpha"
};
body.push_str(&format!(
r#"<feOffset in="{after_morph}" dx="{dx}" dy="{dy}" result="off"/>"#
));
body.push_str(&format!(
r#"<feGaussianBlur in="off" stdDeviation="{sigma}" result="blurred"/>"#
));
body.push_str(&format!(
r#"<feColorMatrix in="blurred" type="matrix" values="{color}" result="colored"/>"#
));
body.push_str(&format!(
r#"<feBlend mode="normal" in="colored" in2="{in2}" result="{result}"/>"#
));
}
fn append_inner_shadow_primitives(
body: &mut String,
shadow: &Shadow,
scale: f32,
in2: &str,
result: &str,
) {
let sigma = radius_to_sigma(shadow.blur * scale);
let dx = shadow.offset.0 * scale;
let dy = shadow.offset.1 * scale;
let spread = shadow.spread * scale;
let color = color_matrix_values(shadow.color);
// Classic inner-shadow graph: hard alpha, optional erode for +spread,
// offset+blur, subtract from hard alpha, tint, blend over prior result.
body.push_str(
r#"<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>"#,
);
let morph_in = if spread > 0.0 {
body.push_str(&format!(
r#"<feMorphology in="hardAlpha" operator="erode" radius="{spread}" result="spread"/>"#
));
"spread"
} else {
"hardAlpha"
};
body.push_str(&format!(
r#"<feOffset in="{morph_in}" dx="{dx}" dy="{dy}" result="off"/>"#
));
body.push_str(&format!(
r#"<feGaussianBlur in="off" stdDeviation="{sigma}" result="blurred"/>"#
));
body.push_str(
r#"<feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/>"#,
);
body.push_str(&format!(
r#"<feColorMatrix in="shadow" type="matrix" values="{color}" result="colored"/>"#
));
body.push_str(&format!(
r#"<feBlend mode="normal" in="colored" in2="{in2}" result="{result}"/>"#
));
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
///
/// `silhouette_offset` is applied in local space (same as
/// [`SvgLayerCanvas::silhouette_draw_matrix`]) so board `clip content` during a
/// container drop pass tracks the shifted silhouette.
fn draw_clip_geometry(
cv: &skia::Canvas,
shape: &Shape,
tree: ShapesPoolRef,
paint: &Paint,
silhouette_offset: (f32, f32),
) {
if let Type::Group(_) = &shape.shape_type {
for child_id in shape.children_ids_iter_forward(true) {
if let Some(child) = tree.get(child_id) {
draw_clip_geometry(cv, child, tree, paint);
draw_clip_geometry(cv, child, tree, paint, silhouette_offset);
}
}
return;
}
cv.save();
cv.concat(&shape.centered_transform());
let mut matrix = shape.centered_transform();
let (dx, dy) = silhouette_offset;
if dx != 0.0 || dy != 0.0 {
matrix.pre_translate((dx, dy));
}
cv.concat(&matrix);
draw_shape_geometry(cv, shape, paint);
cv.restore();
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
/// Returns a shape whose `selrect` is expanded/shrunk by `outset` (design space).
/// Used for GPU-matching geometric drop-shadow spread.
pub(super) fn shape_with_selrect_outset(shape: &Shape, outset: f32) -> Shape {
let mut out = shape.clone();
if outset > 0.0 {
out.selrect.outset((outset, outset));
} else if outset < 0.0 {
out.selrect.inset((-outset, -outset));
}
out
}
/// Builds the `<g>` attribute string for a leaf shape's composite effects
/// (opacity, blend mode, drop/inner shadows, layer blur). Returns `None` when
/// the shape needs no wrapper.
///
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
/// Shadows still need dedicated re-emission in a later PR.
/// Shadows and layer blur are native SVG `<filter>`s — `SkSVGDevice` drops the
/// GPU `save_layer` image-filter path. When `suppress_filters` is set (parent
/// drop-shadow silhouette pass), only opacity/blend are emitted.
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
wrapper_attrs(builder, element, EffectFilterMode::LeafComposite)
}
/// Opacity / blend for a container, wrapping both drop silhouettes and content.
///
/// Matches GPU `render_shape_enter` (opacity save_layer before shadow composite)
/// so container drop shadows inherit the board's opacity.
pub(super) fn opacity_blend_attrs(element: &Shape) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
@@ -206,11 +427,168 @@ pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Opt
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 {
Some(parts.join(" "))
}
}
/// Inner shadows / layer blur for a container's real content group.
///
/// Opacity and blend are applied by [`opacity_blend_attrs`] around silhouette +
/// content. Drop shadows are a separate silhouette pass.
pub(super) fn content_effect_attrs(
builder: &mut SvgLayerCanvas,
element: &Shape,
) -> Option<String> {
wrapper_attrs(builder, element, EffectFilterMode::ContentWithoutDrops)
}
/// Drop-shadow-only filter for one container silhouette pass.
///
/// Offset and spread are applied geometrically while drawing (local space,
/// matching GPU). The filter only hardens alpha, blurs, and tints.
pub(super) fn push_container_drop_filter(builder: &mut SvgLayerCanvas, shadow: &Shadow) -> String {
let scale = builder.scale;
let id = builder.unique("fx");
let mut body = String::from(r#"<feFlood flood-opacity="0" result="bg"/>"#);
// Zero offset: geometric `pre_translate` already moved the silhouette.
let mut filter_shadow = *shadow;
filter_shadow.offset = (0.0, 0.0);
append_drop_shadow_primitives(
&mut body,
&filter_shadow,
scale,
"bg",
"drop0",
/* morph_spread */ false,
);
builder.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"{body}</filter>"
),
id = id,
region = filter_region_attrs(builder),
body = body
));
id
}
/// Dilates/erodes glyph alpha for text drawn inside a container drop silhouette.
///
/// Container drop filters omit `feMorphology` so stroke-ring silhouettes stay
/// thin (morph fattens both edges). Text children still need spread: GPU paints
/// them with `Shadow::get_drop_shadow_filter`, which wraps `drop_shadow_only`
/// in `dilate(spread)` (thicken the already blurred shadow).
///
/// Approximation: we nest `feMorphology` on the glyph alpha *before* the
/// parent container blur. That is morph-then-blur, not dilate-after-drop like
/// Skia. Close enough for export parity; halo softness can differ slightly.
pub(super) fn push_text_silhouette_spread_filter(
builder: &mut SvgLayerCanvas,
spread: f32,
) -> Option<String> {
let radius = spread * builder.scale;
if radius == 0.0 {
return None;
}
let id = builder.unique("txmorph");
let (op, r) = if radius > 0.0 {
("dilate", radius)
} else {
("erode", -radius)
};
builder.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"<feMorphology in=\"SourceAlpha\" operator=\"{op}\" radius=\"{r}\" result=\"m\"/>",
"<feFlood flood-color=\"#000000\" flood-opacity=\"1\" result=\"f\"/>",
"<feComposite in=\"f\" in2=\"m\" operator=\"in\"/>",
"</filter>"
),
id = id,
region = filter_region_attrs(builder),
op = op,
r = r
));
Some(id)
}
#[derive(Clone, Copy)]
enum EffectFilterMode {
/// Flood → drops → SourceGraphic → inners → blur (leaves).
LeafComposite,
/// Flood → SourceGraphic → inners → blur (container content; drops separate).
ContentWithoutDrops,
}
/// Maps a design-space shadow offset into SVG filter user space.
///
/// Leaf geometry is drawn with `centered_transform` (rotation / flip). GPU
/// `drop_shadow_only` offsets in that local space, then the CTM maps it. SVG
/// `feOffset` runs after the leaf is painted into the parent group, so the
/// offset must be `map_vector` of the local offset or rotated shadows drift.
fn shadow_with_user_space_offset(shape: &Shape, shadow: &Shadow) -> Shadow {
let mut out = *shadow;
let mapped = shape.centered_transform().map_vector(shadow.offset);
out.offset = (mapped.x, mapped.y);
out
}
fn wrapper_attrs(
builder: &mut SvgLayerCanvas,
element: &Shape,
mode: EffectFilterMode,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
// Leaves keep opacity/blend on the same wrapper as their filter. Containers
// use [`opacity_blend_attrs`] outside silhouette + content instead.
if matches!(mode, EffectFilterMode::LeafComposite) {
let opacity = element.opacity();
if opacity < 1.0 {
parts.push(format!("opacity=\"{opacity}\""));
}
if let Some(css) = blend_css(element.blend_mode().0) {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
}
if !builder.suppress_filters {
let scale = builder.scale;
let mapped_inners: Vec<Shadow> = element
.inner_shadows_visible()
.map(|s| shadow_with_user_space_offset(element, s))
.collect();
let inners: Vec<&Shadow> = mapped_inners.iter().collect();
let layer_blur_sigma = element
.visible_layer_blur()
.map(|blur| radius_to_sigma(blur.value * scale));
match mode {
EffectFilterMode::LeafComposite => {
let mapped_drops: Vec<Shadow> = element
.drop_shadows_visible()
.map(|s| shadow_with_user_space_offset(element, s))
.collect();
let drops: Vec<&Shadow> = mapped_drops.iter().collect();
if !drops.is_empty() || !inners.is_empty() || layer_blur_sigma.is_some() {
let id =
builder.push_effects_filter(&drops, &inners, layer_blur_sigma, scale, true);
parts.push(format!("filter=\"url(#{id})\""));
}
}
EffectFilterMode::ContentWithoutDrops => {
if !inners.is_empty() || layer_blur_sigma.is_some() {
let id =
builder.push_effects_filter(&[], &inners, layer_blur_sigma, scale, true);
parts.push(format!("filter=\"url(#{id})\""));
}
}
}
}
if parts.is_empty() {
+80 -16
View File
@@ -1,8 +1,11 @@
use crate::error::Result;
use crate::shapes::{Shape, Stroke};
use crate::shapes::{Shadow, Shape};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::document::{
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter,
shape_with_selrect_outset, SvgLayerCanvas,
};
use super::images::{emit_fills, emit_strokes};
use super::render_tree;
use crate::render::RenderResources;
@@ -14,11 +17,61 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(builder, element);
// Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer
// before the shadow composite).
let composite = opacity_blend_attrs(element);
if let Some(attrs) = &composite {
builder.open_group(attrs);
}
// One silhouette pass per drop: geometric offset+spread in local space
// (GPU), filter only blurs/tints — keeps stroke-ring width and rotation.
if !builder.suppress_filters {
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
for shadow in &drops {
let id = push_container_drop_filter(builder, shadow);
builder.open_group(&format!("filter=\"url(#{id})\""));
let prev_suppress = builder.suppress_filters;
let prev_spread = builder.silhouette_spread;
let prev_offset = builder.silhouette_offset;
builder.suppress_filters = true;
builder.silhouette_spread = shadow.spread;
builder.silhouette_offset = shadow.offset;
render_frame_body(builder, shared, element, tree, scale)?;
builder.silhouette_offset = prev_offset;
builder.silhouette_spread = prev_spread;
builder.suppress_filters = prev_suppress;
builder.close_group();
}
}
let effects = content_effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
// Content pass uses builder.silhouette_spread (0 after this frame's own
// drop; still set when nested inside a parent silhouette — matches
// render_leaf so nested board fills outset with inherited spread).
render_frame_body(builder, shared, element, tree, scale)?;
if effects.is_some() {
builder.close_group();
}
if composite.is_some() {
builder.close_group();
}
Ok(())
}
fn render_frame_body(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let spread = builder.silhouette_spread;
let clipped = element.clip_content;
if clipped {
let clip_id = builder.unique("clip");
@@ -26,32 +79,43 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space), with linked `<image>` for image fills.
if !element.fills.is_empty() {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
// Fills: GPU `fills::render` outsets the rect for drop-shadow spread.
let mask = shape_with_selrect_outset(element, spread);
let matrix = builder.silhouette_draw_matrix(element);
emit_fills(
builder,
shared,
&mask,
&mask.fills,
tree,
scale,
Some(matrix),
)?;
}
// Children (absolute coords).
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
// 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();
// Strokes: GPU ignores Rect/Frame stroke outset for drop spread. Use
// emit_strokes (image + solid) under the silhouette/content CTM.
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
}
if effects.is_some() {
builder.close_group();
let matrix = builder.silhouette_draw_matrix(element);
emit_strokes(
builder,
shared,
element,
&visible_strokes,
scale,
Some(matrix),
)?;
}
Ok(())
}
+49 -8
View File
@@ -1,8 +1,10 @@
use crate::error::Result;
use crate::shapes::Shape;
use crate::shapes::{Shadow, Shape};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::document::{
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas,
};
use super::render_tree;
use crate::render::RenderResources;
@@ -13,7 +15,33 @@ pub(super) fn render_group(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(builder, element);
// Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer
// before the shadow composite).
let composite = opacity_blend_attrs(element);
if let Some(attrs) = &composite {
builder.open_group(attrs);
}
if !builder.suppress_filters {
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
for shadow in &drops {
let id = push_container_drop_filter(builder, shadow);
builder.open_group(&format!("filter=\"url(#{id})\""));
let prev_suppress = builder.suppress_filters;
let prev_spread = builder.silhouette_spread;
let prev_offset = builder.silhouette_offset;
builder.suppress_filters = true;
builder.silhouette_spread = shadow.spread;
builder.silhouette_offset = shadow.offset;
render_group_children(builder, shared, element, tree, scale)?;
builder.silhouette_offset = prev_offset;
builder.silhouette_spread = prev_spread;
builder.suppress_filters = prev_suppress;
builder.close_group();
}
}
let effects = content_effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
@@ -22,14 +50,27 @@ pub(super) fn render_group(
// will land in a later PR. For now we still emit the full child list
// (including the mask shape as normal content) so basic group opacity
// keeps working.
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
render_group_children(builder, shared, element, tree, scale)?;
if effects.is_some() {
builder.close_group();
}
if composite.is_some() {
builder.close_group();
}
Ok(())
}
fn render_group_children(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
Ok(())
}
+41 -9
View File
@@ -15,6 +15,9 @@ use crate::render::RenderResources;
/// 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.
///
/// `draw_matrix` is the leaf CTM (container drop silhouettes pass a local-offset
/// matrix so linked images move with solid fills).
pub(super) fn emit_fills(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
@@ -22,19 +25,20 @@ pub(super) fn emit_fills(
fills: &[Fill],
tree: ShapesPoolRef,
scale: f32,
draw_matrix: Option<skia_safe::Matrix>,
) -> Result<()> {
if fills.is_empty() {
return Ok(());
}
let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform());
// 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)?;
emit_image_fill(builder, shared, shape, image_fill, tree, matrix)?;
}
fill => {
let matrix = shape.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
@@ -58,16 +62,27 @@ fn emit_image_fill(
shape: &Shape,
image_fill: &ImageFill,
tree: ShapesPoolRef,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("imgclip");
// Clip uses builder.silhouette_offset (same space as draw_matrix during a
// container drop silhouette pass).
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);
emit_linked_image_element(
builder,
shape,
image_fill,
dest_rect,
&href,
&clip_id,
draw_matrix,
);
Ok(())
}
@@ -76,23 +91,27 @@ fn emit_image_fill(
/// 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`].
///
/// `draw_matrix` overrides the leaf CTM (container drop silhouettes pass a
/// local-offset matrix; `None` uses `centered_transform`).
pub(super) fn emit_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
shape: &Shape,
strokes: &[&Stroke],
scale: f32,
draw_matrix: Option<skia_safe::Matrix>,
) -> Result<()> {
if strokes.is_empty() {
return Ok(());
}
let matrix = shape.centered_transform();
let matrix = draw_matrix.unwrap_or_else(|| 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)?;
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale, matrix)?;
}
_ => {
let canvas = builder.canvas();
@@ -115,6 +134,7 @@ fn emit_image_stroke(
stroke: &Stroke,
image_fill: &ImageFill,
scale: f32,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
@@ -125,7 +145,7 @@ fn emit_image_stroke(
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
cv.concat(&draw_matrix);
if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
cv.restore();
return Ok(());
@@ -136,7 +156,15 @@ fn emit_image_stroke(
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);
emit_linked_image_element(
builder,
shape,
image_fill,
dest,
&href,
&clip_id,
draw_matrix,
);
Ok(())
}
@@ -160,13 +188,17 @@ fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
}
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
///
/// `draw_matrix` is the shape-local CTM (must include container drop silhouette
/// offset when drawing under a parent shadow filter).
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
_shape: &Shape,
image_fill: &ImageFill,
dest_rect: MathRect,
href: &str,
clip_id: &str,
draw_matrix: skia_safe::Matrix,
) {
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
@@ -174,7 +206,7 @@ pub(super) fn emit_linked_image_element(
} else {
"none"
};
let transform = builder.page_shape_matrix_attr(shape);
let transform = builder.page_draw_matrix_attr(&draw_matrix);
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
String::new()
+43 -25
View File
@@ -60,8 +60,8 @@ 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>`.
///
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// wrapper. 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(
@@ -135,7 +135,7 @@ use frames::render_frame;
use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
use document::{effect_attrs, push_text_silhouette_spread_filter, shape_with_selrect_outset};
use images::{emit_fills, emit_strokes};
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
@@ -193,40 +193,58 @@ fn render_leaf(
}
{
let spread = builder.silhouette_spread;
// Spread outsets fills only (GPU). Rect/Frame strokes ignore outset.
// Text keeps its selrect: GPU dilates shadow alpha, not layout bounds.
let fill_shape = shape_with_selrect_outset(element, spread);
// Always from the original element (not outset selrect) so the pivot
// matches content; offset comes from the parent silhouette pass.
let draw_matrix = builder.silhouette_draw_matrix(element);
if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, shared, element)?;
// See `push_text_silhouette_spread_filter`: morph-before-blur approx
// of GPU dilate(drop_shadow) for inherited container spread.
let morph_id = push_text_silhouette_spread_filter(builder, spread);
if let Some(id) = &morph_id {
builder.open_group(&format!("filter=\"url(#{id})\""));
}
render_text_fill(builder, shared, element, draw_matrix)?;
if morph_id.is_some() {
builder.close_group();
}
} else if matches!(element.shape_type, Type::SVGRaw(_)) {
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
canvas.concat(&draw_matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_svg(element)?;
canvas.restore();
} else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
emit_fills(
builder,
shared,
&fill_shape,
&fill_shape.fills,
tree,
scale,
Some(draw_matrix),
)?;
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();
// Drop/inner shadows are native SVG filters on the effects `<g>` —
// do not draw them via Skia image-filters (SkSVGDevice drops them).
// Stroke geometry stays on the original selrect (GPU Rect/Frame
// drop-shadow outset is a no-op for single strokes). Image strokes
// go through emit_strokes (linked <image> + stroke clip).
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();
}
emit_strokes(
builder,
shared,
element,
&visible_strokes,
scale,
Some(draw_matrix),
)?;
}
}
}
@@ -0,0 +1,16 @@
---
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="144" viewBox="0 0 200 144"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="144" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.54901963 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><clipPath id="clip1" clipPathUnits="userSpaceOnUse">
<rect transform="translate(0 24)" width="200" height="120"/>
</clipPath><clipPath id="clip2" clipPathUnits="userSpaceOnUse">
<rect width="200" height="120"/>
</clipPath></defs><g filter="url(#fx0)"><g clip-path="url(#clip1)">
<rect fill="#F0F0F0" transform="translate(0 24)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(0 24)" x="20" y="20" width="160" height="80"/>
</g></g><g clip-path="url(#clip2)">
<rect fill="#F0F0F0" width="200" height="120"/>
<rect fill="#00C800" x="20" y="20" width="160" height="80"/>
</g></svg>
@@ -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="237.64102" height="157.64102" viewBox="0 0 237.64102 157.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="237.64102" height="157.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="6.2735023" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.39215687 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#F0F0F0" transform="translate(18.8205 18.8205)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(18.8205 18.8205)" x="20" y="20" width="80" height="60"/>
</g>
<rect fill="#F0F0F0" transform="translate(18.8205 10.8205)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(18.8205 10.8205)" x="20" y="20" width="80" height="60"/>
</svg>
@@ -3,7 +3,7 @@ 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)">
<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="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="123.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="3.9641016"/></filter></defs><g filter="url(#fx0)">
<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>
@@ -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="130.71281" height="110.712814" viewBox="0 0 130.71281 110.712814"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="130.71281" height="110.712814" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="4" dy="6" result="off"/><feGaussianBlur in="off" stdDeviation="5.118802" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<rect fill="red" transform="translate(11.3564 9.35641)" width="100" height="80"/>
</g></svg>
@@ -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="123.78461" height="103.78461" viewBox="0 0 123.78461 103.78461"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="123.78461" height="103.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset in="hardAlpha" dx="2" dy="3" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/><feColorMatrix in="shadow" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.7058824 0" result="colored"/><feBlend mode="normal" in="colored" in2="shape" result="inner0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#0080FF" transform="translate(9.8923 8.8923)" width="100" height="80"/>
</g></svg>
@@ -3,6 +3,6 @@ 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)">
<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="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="137.64102" height="117.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="6.2735023"/></filter></defs><g filter="url(#fx0)">
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
</g></svg>
@@ -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="232.4282" height="152.4282" viewBox="0 0 232.4282 152.4282"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="232.4282" height="152.4282" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<path transform="translate(20 20)" d="M200 0L0 0L0 120L200 120L200 0ZM5 5L5 115L195 115L195 5L5 5Z" fill-rule="evenodd"/>
</g>
<path d="M200 0L0 0L0 120L200 120L200 0ZM5 5L5 115L195 115L195 5L5 5Z" fill-rule="evenodd"/>
</svg>
@@ -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="200" height="148" viewBox="0 0 200 148"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="148" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g opacity="0.5"><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(0 28)" width="200" height="120"/>
</g>
<rect fill="#3D7BFF" width="200" height="120"/>
</g></svg>
@@ -0,0 +1,18 @@
---
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="332.4282" height="212.4282" viewBox="0 0 332.4282 212.4282"><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><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="txmorph1" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feMorphology in="SourceAlpha" operator="dilate" radius="4" result="m"/><feFlood flood-color="#000000" flood-opacity="1" result="f"/><feComposite in="f" in2="m" operator="in"/></filter><filter id="fx2" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feMorphology in="alpha" operator="dilate" radius="4" result="spread"/><feOffset in="spread" dx="10" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)"><g filter="url(#txmorph1)">
<text transform="translate(20 20)" font-size="40" font-family="Source Sans Pro" x="40, 66.074219, 92.617188, 112.16797" y="76">
HOLA
</text>
</g>
<path transform="translate(20 20)" d="M300 0L0 0L0 180L300 180L300 0ZM5 5L5 175L295 175L295 5L5 5Z" fill-rule="evenodd"/>
</g><g filter="url(#fx2)">
<text font-size="40" font-family="Source Sans Pro" x="40, 66.074219, 92.617188, 112.16797" y="76">
HOLA
</text>
</g>
<path d="M300 0L0 0L0 180L300 180L300 0ZM5 5L5 175L295 175L295 5L5 5Z" fill-rule="evenodd"/>
</svg>
@@ -0,0 +1,14 @@
---
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="330" height="160" viewBox="0 0 330 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="330" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><clipPath id="imgclip1" clipPathUnits="userSpaceOnUse">
<rect transform="translate(40 40)" width="120" height="120"/>
</clipPath><clipPath id="imgclip2" clipPathUnits="userSpaceOnUse">
<rect width="120" height="120"/>
</clipPath></defs><g filter="url(#fx0)"><g clip-path="url(#imgclip1)"><image href="images/test-fill.svg" x="0" y="0" width="120" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 40 40)"/></g>
<rect fill="#3D7BFF" transform="translate(40 40)" x="170" width="120" height="120"/>
</g><g clip-path="url(#imgclip2)"><image href="images/test-fill.svg" x="0" y="0" width="120" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g>
<rect fill="#3D7BFF" x="170" width="120" height="120"/>
</svg>
@@ -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="241.56406" height="241.56406" viewBox="0 0 241.56406 241.56406"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="241.56406" height="241.56406" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="23.59401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.69803923 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(70.782 70.782)" width="100" height="100"/>
</g></svg>
@@ -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="110" viewBox="0 0 80 110"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="80" height="110" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<rect fill="red" transform="matrix(0 1 -1 0 80 0)" width="100" height="80"/>
</g></svg>
@@ -0,0 +1,13 @@
---
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="280" height="160" viewBox="0 0 280 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.9843137 0 0 0 0 0.9529412 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="10" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<rect fill="#EF5350" transform="translate(20 20)" x="40" y="30" width="80" height="40"/>
<path transform="translate(20 20)" d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
</g><g filter="url(#fx1)">
<rect fill="#EF5350" x="40" y="30" width="80" height="40"/>
</g>
<path d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
</svg>
@@ -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="388" height="268" viewBox="0 0 388 268"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="388" height="268" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(24 24)" x="-4" y="6" width="228" height="208"/>
<rect fill="#00C800" transform="translate(24 24)" x="226" y="46" width="108" height="108"/>
</g>
<rect fill="#3D7BFF" transform="translate(24 24)" x="20" y="30" width="180" height="160"/>
<rect fill="#00C800" transform="translate(24 24)" x="250" y="70" width="60" height="60"/>
</svg>
@@ -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>
@@ -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="223.7846" height="25.892303" viewBox="0 0 223.7846 25.892303"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="25.892303" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="12" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.8 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(11.8923 0)" width="200" height="2"/>
</g></svg>
@@ -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="32.856407" height="32.856407" viewBox="0 0 32.856407 32.856407"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="32.856407" height="32.856407" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="4" dy="4" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(4.4282 4.4282)" width="16" height="16"/>
</g></svg>
+869 -8
View File
@@ -1,8 +1,8 @@
use super::fixtures::*;
use crate::shapes::{
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor,
StrokeCap, StrokeKind,
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, Shadow,
ShadowStyle, SolidColor, StrokeCap, StrokeKind,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -114,8 +114,8 @@ fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
"stdDeviation must match canvas radius_to_sigma(value * scale): {svg}"
);
assert!(
svg.contains("filter=\"url(#blur"),
"shape group must reference the blur filter: {svg}"
svg.contains("filter=\"url(#fx"),
"shape group must reference the effects filter: {svg}"
);
insta::assert_snapshot!(svg);
}
@@ -138,7 +138,7 @@ fn skips_hidden_layer_blur() {
let svg = render(&pool, id);
assert!(
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#fx"),
"hidden layer blur must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
@@ -185,9 +185,7 @@ fn exports_group_layer_blur_wrapping_children() {
"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 filter_pos = svg.find("filter=\"url(#fx").expect("group filter wrapper");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
@@ -196,6 +194,843 @@ fn exports_group_layer_blur_wrapping_children() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_drop_shadow_as_svg_filter() {
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.add_shadow(Shadow::new(
skia::Color::from_argb(128, 0, 0, 0),
8.0,
0.0,
(4.0, 6.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
let expected_sigma = radius_to_sigma(8.0);
assert!(
svg.contains("feOffset") && svg.contains(r#"dx="4""#) && svg.contains(r#"dy="6""#),
"drop shadow must offset: {svg}"
);
assert!(
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
"drop blur sigma must match canvas: {svg}"
);
assert!(
svg.contains("filter=\"url(#fx") && svg.contains("SourceGraphic"),
"drop shadow filter must blend SourceGraphic: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn leaf_drop_offset_follows_rotation_in_user_space() {
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();
// 90° CCW: local (+10, 0) → user-space (0, 10).
let (c, s) = (0.0_f32, 1.0_f32);
shape.set_transform(c, s, -s, c, 0.0, 0.0);
shape.set_rotation(90.0);
shape.add_shadow(Shadow::new(
skia::Color::from_argb(128, 56, 0, 238),
0.0,
0.0,
(10.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert!(
svg.contains(r#"dx="0""#) && svg.contains(r#"dy="10""#),
"rotated leaf drop must map local offset into filter user space: {svg}"
);
assert!(
!svg.contains(r#"dx="10""#),
"must not keep unmapped local dx for rotated leaf: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn small_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 16×16 + offset(4,4) blur 4: objectBoundingBox ±50% only leaves 8px margin,
// but reach is ~|offset|+3σ ≈ 12px — corners crop unless the filter is
// sized in userSpaceOnUse to the page (already padded via extrect).
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 16.0, 16.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(153, 0, 0, 0),
4.0,
0.0,
(4.0, 4.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 100×100 blur 40: ±50% of bbox = 50px, 3σ≈71px — halo crops with
// objectBoundingBox percentages.
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(178, 0, 0, 0),
40.0,
0.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
let sigma = radius_to_sigma(40.0);
assert!(
svg.contains(&format!("stdDeviation=\"{sigma}\"")),
"large blur sigma must remain in the filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 200×2 + offset(0,12) blur 6: objectBoundingBox height is only 4px —
// the shadow disappears. userSpaceOnUse page region keeps it.
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 200.0, 2.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(204, 0, 0, 0),
6.0,
0.0,
(0.0, 12.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
assert!(
svg.contains(r#"dy="12""#),
"sliver drop must keep its offset: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_inner_shadow_as_svg_filter() {
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(0, 128, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(180, 0, 0, 0),
6.0,
0.0,
(2.0, 3.0),
ShadowStyle::Inner,
false,
));
}
let svg = render(&pool, id);
assert!(
svg.contains("feComposite") && svg.contains("hardAlpha"),
"inner shadow must use classic composite graph: {svg}"
);
assert!(
svg.contains("filter=\"url(#fx"),
"inner shadow must wrap the shape: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn skips_hidden_shadows() {
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.add_shadow(Shadow::new(
skia::Color::BLACK,
8.0,
0.0,
(4.0, 4.0),
ShadowStyle::Drop,
true,
));
}
let svg = render(&pool, id);
assert!(
!svg.contains("feOffset") && !svg.contains("filter=\"url(#fx"),
"hidden shadow must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_frame_drop_shadow_wrapping_children() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_argb(100, 0, 0, 0),
10.0,
0.0,
(0.0, 8.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 100.0, 80.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
assert!(
svg.contains("filter=\"url(#fx"),
"frame drop shadow must emit a filter: {svg}"
);
assert!(
!svg.contains("SourceGraphic"),
"container drop filter must be shadow-only (no SourceGraphic): {svg}"
);
// Silhouette under the filter, then real content without nesting the filter.
let filter_pos = svg.find("filter=\"url(#fx").expect("frame filter");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
"frame shadow silhouette must precede content: {svg}"
);
let fill_count = svg.matches("fill=\"#").count();
assert!(
fill_count >= 2,
"silhouette + content must both draw fills: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() {
// clip=ON + drop offset: silhouette fills/children move with
// silhouette_draw_matrix, so the board clipPath must move too — otherwise
// the unshifted clip truncates the shadow (F1a / show-content=false).
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_argb(140, 0, 0, 0),
0.0,
0.0,
(0.0, 24.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 180.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
assert!(
svg.contains("filter=\"url(#fx"),
"clipped frame drop shadow must emit a filter: {svg}"
);
assert!(
svg.matches("<clipPath").count() >= 2,
"silhouette and content each need a clipPath: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("frame filter");
let filter_close = svg[filter_open..]
.find("</g>")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
let clip_ref = silhouette
.find("clip-path=\"url(#")
.and_then(|i| {
let start = i + "clip-path=\"url(#".len();
let end = silhouette[start..].find(')')?;
Some(&silhouette[start..start + end])
})
.expect("silhouette must reference a clipPath");
let clip_def_start = svg
.find(&format!("<clipPath id=\"{clip_ref}\""))
.expect("silhouette clipPath def");
let clip_def_end = svg[clip_def_start..]
.find("</clipPath>")
.map(|i| clip_def_start + i)
.expect("clipPath close");
let clip_geom = &svg[clip_def_start..clip_def_end];
// Content clip (second clipPath) stays unshifted; silhouette clip must
// carry the local drop offset (0, 24) like silhouette fills.
assert!(
clip_geom.contains("translate(") && clip_geom.contains(" 24"),
"silhouette clipPath must follow drop offset (0,24): {clip_geom}\nfull: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn group_drop_silhouette_shifts_image_fill_children() {
// Container drop silhouettes must offset linked <image> fills the same way
// as solid fills (F3). Clip already follows silhouette_offset; the image
// CTM must use draw_matrix too.
let mut pool = ShapesPool::new();
let group_id = uid(1);
let image_child = uid(2);
let solid_child = uid(3);
let image_id = uid(42);
add_image_rect(
&mut pool,
image_child,
group_id,
(0.0, 0.0, 120.0, 120.0),
image_id,
true,
255,
);
add_solid_rect(
&mut pool,
solid_child,
group_id,
(170.0, 0.0, 290.0, 120.0),
skia::Color::from_rgb(61, 123, 255),
);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 290.0, 120.0),
&[image_child, solid_child],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.add_shadow(Shadow::new(
skia::Color::from_rgb(229, 16, 35),
0.0,
0.0,
(40.0, 40.0),
ShadowStyle::Drop,
false,
));
}
let svg = render_with(&pool, group_id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("filter=\"url(#fx"),
"group drop shadow must emit a filter: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("group filter");
let silhouette = &svg[filter_open..];
// Content pass repeats the image without the silhouette offset matrix.
let content_image = silhouette
.match_indices("<image")
.nth(1)
.map(|(i, _)| filter_open + i);
let silhouette = match content_image {
Some(end) => &svg[filter_open..end],
None => silhouette,
};
assert!(
silhouette.contains("<image") && silhouette.contains(TEST_IMAGE_URL),
"silhouette must include the image-fill child: {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains("fill=\"#3D7BFF\"") || silhouette.contains("fill=\"#3d7bff\""),
"silhouette must include the solid child: {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains(r#"translate(40 40)"#),
"solid silhouette child must apply group drop offset: {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains("matrix(1 0 0 1 40 40)"),
"image silhouette child must apply the same local offset via draw_matrix: {silhouette}\nfull: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn nested_frame_fill_outsets_under_parent_drop_spread() {
// Outer board: no fill, drop spread 24. Nested board fill must outset by
// the inherited silhouette_spread (F5) — same as leaf children via
// render_leaf. Hardcoding 0.0 on the nested frame content pass left the
// nested board hugging its true edge while the leaf got the red ring.
let mut pool = ShapesPool::new();
let outer = uid(1);
let nested = uid(2);
let leaf = uid(3);
add_frame(
&mut pool,
outer,
Uuid::nil(),
(0.0, 0.0, 340.0, 220.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&outer).unwrap();
frame.clear_fills();
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(229, 16, 35),
0.0,
24.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
add_frame(
&mut pool,
nested,
outer,
(20.0, 30.0, 200.0, 190.0),
skia::Color::from_rgb(61, 123, 255),
false,
);
add_solid_rect(
&mut pool,
leaf,
outer,
(250.0, 70.0, 310.0, 130.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&outer).unwrap();
frame.add_child(nested);
frame.add_child(leaf);
}
let svg = render(&pool, outer);
assert!(
svg.contains("filter=\"url(#fx"),
"outer drop shadow must emit a filter: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("outer filter");
let after = &svg[filter_open..];
// Content pass redraws the nested board at true size (180×160); silhouette
// must use the spread-outset size (180+48)×(160+48).
let silhouette_end = after
.find("width=\"180\"")
.map(|i| filter_open + i)
.expect("content nested board at true size");
let silhouette = &svg[filter_open..silhouette_end];
assert!(
silhouette.contains("width=\"228\"") && silhouette.contains("height=\"208\""),
"nested board fill must outset by parent spread 24 (180+48, 160+48): {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains("width=\"108\"") && silhouette.contains("height=\"108\""),
"leaf fill must also outset by parent spread 24 (60+48): {silhouette}\nfull: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn frame_drop_silhouette_inherits_board_opacity() {
// GPU opens the opacity save_layer before the shadow composite, so a board
// at opacity 0.5 casts a half-strength drop. The silhouette filter group
// must sit inside the opacity wrapper (F6a), not beside it.
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(61, 123, 255),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.set_opacity(0.5);
frame.add_shadow(Shadow::new(
skia::Color::BLACK,
0.0,
0.0,
(0.0, 28.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, frame_id);
let opacity_pos = svg.find(r#"opacity="0.5""#).expect("board opacity wrapper");
let filter_pos = svg
.find("filter=\"url(#fx")
.expect("drop silhouette filter");
assert!(
opacity_pos < filter_pos,
"opacity must wrap the drop silhouette (GPU order): {svg}"
);
// Silhouette group is nested inside the opacity group — closing opacity
// after the filter group means the shadow is attenuated.
let after_opacity = &svg[opacity_pos..];
assert!(
after_opacity.contains("filter=\"url(#fx"),
"drop silhouette must be inside the opacity wrapper: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn nested_child_drop_shadow_is_not_refiltered_by_frame() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 260.0, 140.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(251, 243, 0),
0.0,
0.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(40.0, 30.0, 120.0, 70.0),
skia::Color::from_rgb(239, 83, 80),
);
{
let shape = pool.get_mut(&child).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_rgb(56, 0, 238),
0.0,
0.0,
(10.0, 10.0),
ShadowStyle::Drop,
false,
));
}
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
let filter_attrs: Vec<_> = svg.match_indices("filter=\"url(#fx").collect();
assert_eq!(
filter_attrs.len(),
2,
"expect frame silhouette filter + child content filter only: {svg}"
);
// Child's filter must not sit inside the frame's filtered group.
let frame_filter_open = svg.find("<g filter=\"url(#fx").expect("frame filter group");
let frame_filter_close = svg[frame_filter_open..]
.find("</g>")
.map(|i| frame_filter_open + i)
.expect("close frame filter group");
let child_filter = svg.rfind("<g filter=\"url(#fx").expect("child filter");
assert!(
child_filter > frame_filter_close,
"child drop filter must be outside frame drop group to avoid shadow-of-shadow: {svg}"
);
assert!(
svg.contains(r#"dx="10""#),
"child leaf drop must keep filter offset: {svg}"
);
// Frame container drops apply offset geometrically (filter dx=0).
assert!(
svg.contains(r#"dx="0""#) || svg.matches(r#"dx=""#).count() >= 1,
"frame drop filter must not re-offset in user space: {svg}"
);
// Stroke-ring silhouette (border shadow), not a solid board fill.
let silhouette = &svg[frame_filter_open..=frame_filter_close];
assert!(
silhouette.contains("fill-rule=\"evenodd\"") || silhouette.contains("<path"),
"frame stroke must be in the drop-shadow silhouette: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn frame_drop_silhouette_offsets_child_text() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let text_id = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 300.0, 180.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_argb(128, 229, 16, 35),
4.0,
4.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
add_solid_text(
&mut pool,
text_id,
(40.0, 40.0, 200.0, 120.0),
"HOLA",
40.0,
skia::Color::BLACK,
);
{
let text = pool.get_mut(&text_id).unwrap();
text.set_parent(frame_id);
text.add_shadow(Shadow::new(
skia::Color::from_argb(128, 56, 0, 238),
4.0,
4.0,
(10.0, 10.0),
ShadowStyle::Drop,
false,
));
}
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(text_id);
}
let svg = render(&pool, frame_id);
let frame_filter_open = svg.find("<g filter=\"url(#fx").expect("frame filter group");
let frame_filter_close = svg[frame_filter_open..]
.find("</g>")
.map(|i| frame_filter_open + i)
.expect("close frame filter group");
let silhouette = &svg[frame_filter_open..=frame_filter_close];
assert!(
silhouette.contains("<text"),
"frame drop silhouette must include child text: {silhouette}"
);
assert!(
silhouette.contains(r#"transform="translate(20 20)""#),
"silhouette text must apply frame drop offset in local space: {silhouette}"
);
assert!(
silhouette.contains("feMorphology") || svg.contains("txmorph"),
"silhouette text must dilate for frame drop spread: {svg}"
);
assert!(
svg.contains(r#"operator="dilate""#) && svg.contains(r#"radius="4""#),
"text silhouette spread must dilate by frame shadow spread: {svg}"
);
// Content text (outside silhouette) must stay unshifted.
let content = &svg[frame_filter_close..];
let content_text_start = content.find("<text").expect("content text");
let content_text_end = content[content_text_start..]
.find("</text>")
.map(|i| content_text_start + i)
.expect("content text end");
let content_text = &content[content_text_start..=content_text_end];
assert!(
!content_text.contains("translate(20 20)"),
"content text must not carry silhouette offset: {content_text}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn fill_less_frame_drop_shadow_ignores_stroke_spread_outset() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_argb(128, 229, 16, 35),
4.0,
4.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, frame_id);
assert!(
!svg.contains("feMorphology"),
"container drop must not use feMorphology: {svg}"
);
assert!(
svg.contains(r#"dx="0""#) && svg.contains("feGaussianBlur"),
"container drop filter must blur only (offset is geometric): {svg}"
);
let filter_open = svg.find("<g filter=\"url(#fx").expect("drop group");
let filter_close = svg[filter_open..]
.find("</g>")
.map(|i| filter_open + i)
.expect("close drop group");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains("fill-rule=\"evenodd\""),
"silhouette must stay a stroke ring: {silhouette}"
);
// GPU ignores Rect/Frame stroke outset — ring must match content selrect
// (200×120), not an expanded 208×128 path.
assert!(
silhouette.contains("M200 ") || silhouette.contains("L200 "),
"stroke silhouette must not grow with spread: {silhouette}"
);
assert!(
!silhouette.contains("M204 ") && !silhouette.contains("L204 "),
"spread must not outset frame stroke geometry: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
let mut pool = ShapesPool::new();
@@ -1369,6 +2204,32 @@ fn exports_image_fill_on_frame() {
insta::assert_snapshot!(svg);
}
fn assert_filter_covers_page(svg: &str) {
let width = svg
.split_once("width=\"")
.and_then(|(_, rest)| rest.split_once('"').map(|(w, _)| w))
.expect("svg width");
let height = svg
.split_once("height=\"")
.and_then(|(_, rest)| rest.split_once('"').map(|(h, _)| h))
.expect("svg height");
assert!(
svg.contains("filterUnits=\"userSpaceOnUse\""),
"shadow/blur filters must use userSpaceOnUse (not objectBoundingBox %): {svg}"
);
assert!(
!svg.contains("x=\"-50%\"") && !svg.contains("width=\"200%\""),
"must not size filters as a percent of the shape bbox: {svg}"
);
assert!(
svg.contains(&format!("width=\"{width}\""))
&& svg.contains(&format!("height=\"{height}\""))
&& svg.contains(r#"x="0""#)
&& svg.contains(r#"y="0""#),
"filter region must cover the export page ({width}×{height}): {svg}"
);
}
fn assert_linked_image_stroke(svg: &str) {
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
+19 -6
View File
@@ -13,10 +13,16 @@ use crate::render::RenderResources;
///
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
///
/// `draw_matrix` is the leaf CTM (normally `centered_transform`). During a
/// parent drop-shadow silhouette pass it must include the geometric offset
/// (`silhouette_draw_matrix`); using only `centered_transform` leaves child
/// text unshifted while strokes/fills move.
pub(super) fn render_text_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let text_content = element.get_text_content();
let text_content = text_content.new_bounds(element.selrect());
@@ -25,14 +31,12 @@ pub(super) fn render_text_fill(
return Ok(());
}
let matrix = element.centered_transform();
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)?;
emit_text_image_fill(builder, shared, element, image_fill, layer, draw_matrix)?;
}
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
@@ -44,7 +48,7 @@ pub(super) fn render_text_fill(
};
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
canvas.concat(&draw_matrix);
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
canvas.restore();
}
@@ -96,6 +100,7 @@ fn emit_text_image_fill(
shape: &Shape,
image_fill: &ImageFill,
layer: usize,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
@@ -110,13 +115,21 @@ fn emit_text_image_fill(
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
cv.concat(&draw_matrix);
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);
emit_linked_image_element(
builder,
shape,
image_fill,
shape.selrect(),
&href,
&clip_id,
draw_matrix,
);
Ok(())
}
+55 -4
View File
@@ -5,6 +5,7 @@ Check commit messages against Penpot's commit guidelines.
Validates commit messages using the rules defined in:
- .github/workflows/commit-checker.yml (regex pattern)
- CONTRIBUTING.md (formatting rules, subject length, DCO)
- .serena/memories/workflow/creating-commits.md (body wrapped at 76 chars)
By default, checks HEAD. Use --commit to specify a different commit.
@@ -38,6 +39,20 @@ COMMIT_PATTERN = re.compile(
MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$")
# ── Body line wrapping ───────────────────────────────────────────────────────
# Commit bodies must wrap at 76 characters (see
# .serena/memories/workflow/creating-commits.md). That leaves room for the
# four-space indent git log adds, fitting an 80-column terminal. Trailers and
# URLs are exempt: they cannot be wrapped without losing meaning.
MAX_BODY_LINE = 76
TRAILER_PATTERN = re.compile(
r"^(Signed-off-by|Co-authored-by|Co-developed-by|Reviewed-by|"
r"Acked-by|Tested-by|Reported-by|Suggested-by|AI-assisted-by):"
)
URL_PATTERN = re.compile(r"https?://\S+")
# ═══════════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════════
@@ -93,11 +108,11 @@ def check_regex(message):
def check_subject_length(message):
"""Subject line must be ≤ 90 characters."""
"""Subject line must be ≤ 70 characters."""
first_line = message.split("\n")[0]
if len(first_line) > 90:
if len(first_line) > 70:
return False, (
f"Subject line exceeds 90 characters ({len(first_line)} chars):\n"
f"Subject line exceeds 70 characters ({len(first_line)} chars):\n"
f" {first_line}"
)
return True, None
@@ -148,6 +163,41 @@ def check_body_blank_line(message):
return True, None
def check_body_line_length(message):
"""Body lines must wrap at 76 characters or fewer.
The subject (first line) has its own length rule. Trailers (e.g.
Signed-off-by) and lines carrying a URL are exempt, since wrapping them
would break tooling or lose information.
"""
lines = message.split("\n")
offenders = []
for line_number, line in enumerate(lines[1:], start=2):
if len(line) <= MAX_BODY_LINE:
continue
if TRAILER_PATTERN.match(line):
continue
if URL_PATTERN.search(line):
continue
# A long token with no whitespace before the limit cannot be wrapped.
if " " not in line[:MAX_BODY_LINE]:
continue
offenders.append((line_number, line))
if not offenders:
return True, None
details = "\n".join(
f" line {line_number} ({len(line)} chars): {line!r}"
for line_number, line in offenders
)
return False, (
f"Body lines must wrap at {MAX_BODY_LINE} characters or fewer. "
"Unwrapped line(s):\n" + details
)
def check_signed_off_by(message):
"""Check for the DCO Signed-off-by line (required for code changes)."""
if "Signed-off-by:" not in message:
@@ -179,10 +229,11 @@ def main():
validators = [
("Regex pattern", check_regex),
("Subject ≤ 90 chars", check_subject_length),
("Subject ≤ 70 chars", check_subject_length),
("No trailing period in subject", check_subject_no_trailing_dot),
("Subject capitalized", check_subject_capitalized),
("Blank line after subject", check_body_blank_line),
("Body wrapped at 76 chars", check_body_line_length),
]
all_ok = True
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Tests for scripts/check-commit.
Run with:
python3 scripts/test_check_commit.py
Covers the body line-wrapping validator added to enforce the commit body
wrap rule documented in .serena/memories/workflow/creating-commits.md.
"""
import importlib.machinery
import importlib.util
import pathlib
import sys
import unittest
# Loading scripts/check-commit would otherwise emit scripts/__pycache__/.
sys.dont_write_bytecode = True
SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "check-commit"
def load_check_commit():
"""Load the extensionless scripts/check-commit as a module."""
loader = importlib.machinery.SourceFileLoader("check_commit", str(SCRIPT_PATH))
spec = importlib.util.spec_from_loader("check_commit", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
check_commit = load_check_commit()
class BodyLineLengthTests(unittest.TestCase):
def assert_ok(self, message):
ok, error = check_commit.check_body_line_length(message)
self.assertTrue(ok, error)
self.assertIsNone(error)
def assert_fail(self, message):
ok, error = check_commit.check_body_line_length(message)
self.assertFalse(ok)
self.assertIsNotNone(error)
return error
def test_wrapped_body_passes(self):
message = (
":bug: Fix crash when opening the file menu\n"
"\n"
"The menu reused a stale reference after the file was\n"
"closed, which raised an exception on reopen.\n"
)
self.assert_ok(message)
def test_line_at_limit_passes(self):
line = "x " * 38 # 76 chars, breakable
self.assertEqual(len(line), 76)
self.assert_ok(":bug: Fix crash\n\n" + line + "\n")
def test_line_one_over_limit_fails(self):
line = "x " * 38 + "x" # 77 chars, breakable
self.assertEqual(len(line), 77)
error = self.assert_fail(":bug: Fix crash\n\n" + line + "\n")
self.assertIn("76", error)
def test_long_body_line_fails(self):
long_line = "word " * 20 # 100 chars, breakable
error = self.assert_fail(":bug: Fix crash\n\n" + long_line + "\n")
self.assertIn("76", error)
self.assertIn("line 3", error)
def test_subject_is_not_checked(self):
# The subject has its own length rule; the body validator ignores it.
subject = ":bug: " + "S" * 100
self.assert_ok(subject + "\n")
def test_url_line_passes(self):
line = (
"See https://github.com/penpot/penpot/issues/1234"
"/comments/very/long/fragment"
)
self.assert_ok(":books: Update docs\n\n" + line + "\n")
def test_trailer_passes(self):
line = "Signed-off-by: Someone With A Long Name <someone@example.com>"
self.assert_ok(":bug: Fix crash\n\nBody.\n\n" + line + "\n")
def test_unbreakable_token_passes(self):
line = "a" * 100 # no whitespace to wrap at
self.assert_ok(":bug: Fix crash\n\n" + line + "\n")
def test_blank_lines_are_ignored(self):
self.assert_ok(":bug: Fix crash\n\n\n\n")
def test_multiple_offenders_reported(self):
error = self.assert_fail(
":bug: Fix crash\n\n"
+ ("word " * 20)
+ "\n"
+ ("other " * 20)
+ "\n"
)
self.assertIn("line 3", error)
self.assertIn("line 4", error)
class SubjectRulesRegressionTests(unittest.TestCase):
"""Guard the pre-existing validators against accidental breakage."""
def test_valid_subject_passes_regex(self):
ok, error = check_commit.check_regex(":bug: Fix crash on startup")
self.assertTrue(ok, error)
def test_missing_emoji_fails_regex(self):
ok, _ = check_commit.check_regex("Fix crash on startup")
self.assertFalse(ok)
def test_trailing_dot_fails(self):
ok, _ = check_commit.check_subject_no_trailing_dot(":bug: Fix crash.")
self.assertFalse(ok)
def test_subject_at_70_chars_passes(self):
# ":bug: " is 6 chars, so 64 chars of text reach exactly 70.
ok, error = check_commit.check_subject_length(":bug: " + "S" * 64)
self.assertTrue(ok, error)
def test_subject_over_70_chars_fails(self):
ok, error = check_commit.check_subject_length(":bug: " + "S" * 65)
self.assertFalse(ok)
self.assertIn("70", error)
if __name__ == "__main__":
unittest.main(verbosity=2)