mirror of
https://github.com/penpot/penpot.git
synced 2026-09-12 05:39:59 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947954933c | ||
|
|
87e5ffdf94 | ||
|
|
3adf9ade14 | ||
|
|
f3da8af7b6 | ||
|
|
7ff76a9ebc | ||
|
|
fc8c5a98de | ||
|
|
bda8459d89 | ||
|
|
06239844b1 | ||
|
|
09736aa4c9 | ||
|
|
ffad71bdc5 |
No files matched your search
@@ -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 1–3. 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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
|
||||
@@ -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])))))
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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>
|
||||
+12
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
+8
@@ -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>
|
||||
+8
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
+10
@@ -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>
|
||||
+10
@@ -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>
|
||||
+18
@@ -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>
|
||||
+14
@@ -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>
|
||||
+8
@@ -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>
|
||||
+8
@@ -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>
|
||||
+13
@@ -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>
|
||||
+12
@@ -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>
|
||||
+8
@@ -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>
|
||||
+8
@@ -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>
|
||||
+8
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user