Compare commits

..
3 Commits
Author SHA1 Message Date
Andrey Antukh e96b8a4798 📎 Test the serving path rejects unconfigured storage targets
The read/serve path (get-object-data) must fail with
:invalid-storage-target when an object references a target id that is
not configured, matching get-object-url and bulk delete. Add the
missing test so removing a target id cannot silently serve from the
wrong place.

Closes #11630

AI-assisted-by: deepseek-flash
2026-09-11 07:29:26 +00:00
Andrey Antukh 6eece49f69 🐛 Keep objects whose storage target is no longer configured
An object whose :storage-target id is not in the S3 routes configuration
can no longer be located. Reads and serving now fail with
:invalid-storage-target instead of silently using the default bucket,
and garbage collection refuses to delete the row: it logs an error and
parks the row (deleted_at +1 day, no deletion attempts, no give-up)
until the target is configured again. Legacy rows (no target) and the
default target keep working and deleting normally.

Also addresses review findings: per-target dedup isolation and
stale-repair coverage, legacy-row GC coverage through the real task
paths, a single declared-target schema, closing already-built S3
clients when target init fails, and loader edge pins. Docs and memory
updated.

Closes #11630

AI-assisted-by: deepseek-flash
2026-09-10 21:38:38 +00:00
Andrey Antukh 0d855b3436 Add per-bucket S3 target routing to asset storage
Introduce optional named S3 targets and route internal semantic buckets
(for example tempfile) to them, keeping the fs/s3 backend choice and the
storage_object.backend value unchanged.

Targets and routes are read from an EDN file referenced by
PENPOT_OBJECTS_STORAGE_S3_ROUTES_FILE. The chosen target id is stored in
the object metadata as :storage-target and resolved on reads, URL
signing, deduplication, individual deletes and both GC tasks. The
implicit :default target is built from PENPOT_OBJECTS_STORAGE_S3_*, so
behavior is unchanged when the file is absent. Bulk deletion now carries
the target, and one S3 client/presigner pair is shared per distinct
region/endpoint.

Closes #11630

AI-assisted-by: deepseek-flash
2026-09-10 19:27:47 +00:00
86 changed files with 1619 additions and 4801 deletions

No files matched your search

+2 -20
View File
@@ -20,17 +20,6 @@ Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
is the authoritative source for the commit message format, the emoji menu,
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
## Iron Rules (non-negotiable)
1. **Wrap every body line at 76 characters or fewer.** Count characters, do
not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and
lines carrying a URL. This is the rule agents skip most often.
2. **Subject ≤70 chars**, imperative, capitalized, no trailing period.
3. **Blank line between subject and body.**
4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically
checks rules 13. A non-zero exit is a hard blocker: fix the message and
re-commit. Never report the commit as done with a failing checker.
## Workflow
1. **Stage the files** specified by the calling context. Do not ask for
@@ -40,18 +29,12 @@ 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 76 characters per line, and run:
at 72 characters per line, and run:
```bash
git commit -m "<subject>" -m "<body>"
```
(or `git commit -F -` if the body has unusual characters).
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
4. The `AI-assisted-by` trailer value is provided by the calling context — use
it verbatim.
## Constraints
@@ -62,4 +45,3 @@ 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).
+21 -2
View File
@@ -8,13 +8,30 @@
- The backend stores the binary content.
- Supported backends are `:fs` and `:s3`.
- FS uses one root directory and a UUID-derived path.
- S3 uses one configured bucket and an optional prefix.
- S3 uses a default configured bucket and an optional prefix, plus optional named targets.
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
- Deprecated asset-storage config keys remain supported for migration.
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
## S3 Targets and Routing
- The `:s3` backend keeps `storage_object.backend = 's3'`; routing lives inside the backend.
- Routing maps a Penpot semantic bucket to a named target (own bucket, optional prefix/region/endpoint).
- Targets are declared in an EDN file referenced by `PENPOT_OBJECTS_STORAGE_S3_ROUTES_FILE` (`app.storage.config/load`).
- Schema: `{:targets {<id> {:bucket ... :prefix? ... :region? ... :endpoint? ...}} :routes {"<semantic-bucket>" <id>}}`.
- The reserved `:default` target is implicit and built from `PENPOT_OBJECTS_STORAGE_S3_*`; declared targets inherit missing region/endpoint/prefix from it.
- Without a routes file, `::sto/bucket->target` is nil and every object uses `:default` (unchanged behavior).
- The chosen target id is stored in object metadata as `:storage-target` (plain string) by `put-object!`.
- `app.storage.s3/resolve-target` reads the object metadata; `nil` (legacy) and `"default"` use the default target, an unknown non-nil id raises `:invalid-storage-target` (no fallback) on reads/serving/deletes.
- `impl/target-resolvable?` (wrapped as `sto/target-resolvable?`) reports whether a target id is configured; `:fs` is always true.
- GC-deleted and pending-gc refuse to delete rows whose target is not resolvable: they log `:err`, park the row (`deleted_at = now()+1d`, no attempts, no give-up) and never remove it until the target is configured again.
- `deleted_at` doubles as the pending-gc park marker; the pending selection skips rows whose `deleted_at` is in the future.
- One S3 client/presigner is built per distinct `[region endpoint]` and shared by targets; a failed init closes the already-built pairs.
- Target ids are stored in metadata, so they must stay stable; removing one makes its old rows unreadable and unGC-able by design.
- `:storage-target` metadata is load-bearing: `pending-gc` passes it via `with-meta` so `del-object` resolves the right target.
## Object Lifecycle
- `put-object!` creates the database row before it writes backend content.
@@ -64,7 +81,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `
## Deduplication
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
- The lookup matches hash, bucket, backend, storage target (`:storage-target`, coalesced to `default`), and `deleted_at IS NULL`.
- The lookup only considers rows with `status='valid'`; pending rows are invisible.
- A hit whose blob is missing is repaired in place: the same row/id is kept,
and `put-object!` rewrites the blob under that id. This heals all existing
@@ -93,6 +110,8 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `
- The valid bucket set lives in `app.storage/valid-buckets`.
- `file-media-object` is the default bucket for old rows without bucket metadata.
- Under `:s3`, any valid bucket may be routed to a named target; unrouted buckets use `:default`.
- GC resolves the target from `metadata.:storage-target` for deleted and pending rows.
- Do not assign a new bucket without adding its access and cleanup behavior.
- The touched-object collector raises an internal error for an unknown bucket.
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
+1 -1
View File
@@ -11,7 +11,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
- Before `git commit``mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer)
- Before `git commit``mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit``mem:workflow/creating-prs` (title format, body structure, "Note:" line)
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
+2 -22
View File
@@ -14,32 +14,12 @@ 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 76 characters — git log adds a
four-space indent, so 76 + 4 fits an 80-column
terminal. Keep each line concise.
Wrap lines at 72 characters — git log and tooling
render long lines poorly. Keep each line concise.
AI-assisted-by: model-name
```
## HARD RULES (inexcusable)
These rules are not advisory. Do not commit until every one holds. A commit
that breaks them is wrong, even if the code is right.
- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do
not eyeball it. This is the rule most often skipped. Rationale: `git log`
indents the body four spaces, so 76 + 4 fits an 80-column terminal.
- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period.
- **MUST be a blank line** between subject and body.
- **MUST run `scripts/check-commit` and get exit code 0 before finishing.**
It mechanically validates the rules above; a failing run is a blocker.
- It checks `HEAD` by default: `./scripts/check-commit`
- For another commit: `./scripts/check-commit -c <ref>`
- **NEVER** hand-wave the body as "one long line". If a line exceeds 76,
break it at a space.
- Exceptions inside the body (do not wrap these): `Signed-off-by:`,
`Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL.
**AI-assisted-by trailer rules:**
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
- Do NOT add prefixes like `opencode-go/` — use the bare model name
-3
View File
@@ -17,9 +17,6 @@
- **`.claude/skills` is a symlink to `.agents/skills`.**
Edit skills only in their canonical location (`.agents/skills`); never edit
through `.claude/skills`.
- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and
the commit MUST pass `./scripts/check-commit` with exit code 0 before you
consider it done. This is mechanically checked — do not eyeball it.
- **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)
-3
View File
@@ -188,11 +188,8 @@ 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
```
+1 -2
View File
@@ -92,7 +92,6 @@
: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 #{}
@@ -204,7 +203,6 @@
[: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]
@@ -295,6 +293,7 @@
[:objects-storage-s3-bucket {:optional true} :string]
[:objects-storage-s3-region {:optional true} :keyword]
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]
[:objects-storage-s3-routes-file {:optional true} :string]
;; SSRF protection
[:ssrf-allowed-hosts {:optional true} [::sm/set :string]]
+9 -1
View File
@@ -34,6 +34,7 @@
[app.setup :as-alias setup]
[app.srepl :as-alias srepl]
[app.storage :as-alias sto]
[app.storage.config :as sto.config]
[app.storage.fs :as-alias sto.fs]
[app.storage.gc-deleted :as-alias sto.gc-deleted]
[app.storage.gc-touched :as-alias sto.gc-touched]
@@ -148,6 +149,11 @@
::mdef/labels []
::mdef/type :histogram}})
(def ^:private storage-routing
"Optional S3 storage targets and per semantic-bucket routing. Loaded once
from the external routes file. Empty when the feature is not configured."
(sto.config/load))
(def system-config
{::db/pool
{::db/uri (cf/get :database-uri)
@@ -522,7 +528,8 @@
;; explicit migration because the database objects/rows will
;; still reference the old names).
:assets-s3 (ig/ref :app.storage.s3/backend)
:assets-fs (ig/ref :app.storage.fs/backend)}}
:assets-fs (ig/ref :app.storage.fs/backend)}
::sto/bucket->target (:routes storage-routing)}
:app.storage.s3/backend
{::sto.s3/region (or (cf/get :storage-assets-s3-region)
@@ -533,6 +540,7 @@
(cf/get :objects-storage-s3-bucket))
::sto.s3/io-threads (or (cf/get :storage-assets-s3-io-threads)
(cf/get :objects-storage-s3-io-threads))
::sto.s3/targets (:targets storage-routing)
::wrk/netty-io-executor
(ig/ref ::wrk/netty-io-executor)}
+26 -63
View File
@@ -339,9 +339,6 @@
;; --- 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]
@@ -357,31 +354,9 @@
{::doc/added "2.17"
::sm/params schema:upload-chunk
::sm/result schema:upload-chunk-result}
[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})]
[{: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})]
(when (or (neg? index) (>= index (:total-chunks session)))
(ex/raise :type :validation
:code :invalid-chunk-index
@@ -390,23 +365,26 @@
: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)))
(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))
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content)))
session))
(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})
;; --- Chunked Upload: shared helpers
@@ -422,18 +400,6 @@
[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."
@@ -452,21 +418,18 @@
conforming to `media.v/schema:upload` with `:filename`, `:path` and
`:size`.
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 a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
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)
indices (sort (map :chunk-index chunks))]
chunks (get-upload-chunks conn session-id)]
(when (or (not= (count chunks) (:total-chunks session))
(not= indices (range (:total-chunks session))))
(when (not= (count chunks) (:total-chunks session))
(ex/raise :type :validation
:code :missing-chunks
:hint "stored chunks do not match expected total"
:hint "number of stored chunks does not match expected total"
:session-id session-id
:expected (:total-chunks session)
:found (count chunks)))
+32 -5
View File
@@ -70,6 +70,7 @@
[:map {:title "storage"}
[::backends schema:backends]
[::backend [:enum :s3 :fs]]
[::bucket->target {:optional true} [:map-of :string :keyword]]
::db/pool])
(def valid-storage?
@@ -112,17 +113,18 @@
params))
(defn- get-database-object-by-hash
[connectable backend bucket hash]
[connectable backend bucket target hash]
(let [sql (str "select * from storage_object "
" where (metadata->>'~:hash') = ? "
" and (metadata->>'~:bucket') = ? "
" and coalesce(metadata->>'~:storage-target', 'default') = ? "
" and backend = ?"
" and deleted_at is null"
" and status = 'valid'"
" limit 1")]
;; NOTE: metadata is left encoded; row->storage-object is
;; responsible for decoding it.
(db/exec-one! connectable [sql hash bucket (name backend)])))
(db/exec-one! connectable [sql hash bucket target (name backend)])))
(defn- promote-object!
[storage object]
@@ -185,6 +187,13 @@
(let [ds (db/get-connectable storage)]
(get-database-object ds id)))
(defn- resolve-target-id
"Returns the storage target id for the given semantic bucket, or nil when
the routing does not apply (non-S3 backends)."
[storage bucket]
(when (= :s3 (::backend storage))
(get (::bucket->target storage) bucket :default)))
(defn put-object!
"Creates a new object with the provided content."
[{:keys [::backend ::db/pool] :as storage}
@@ -193,9 +202,16 @@
(assert (impl/content? content) "expected an instance of content")
(let [id (or (::id params) (uuid/random))
mdata (cond-> (get-metadata params)
base-mdata (get-metadata params)
bucket (:bucket base-mdata)
target (resolve-target-id storage bucket)
target-str (or (some-> target name) "default")
mdata (cond-> base-mdata
(satisfies? impl/IContentHash content)
(assoc :hash (impl/get-hash content)))
(assoc :hash (impl/get-hash content))
(some? target)
(assoc :storage-target target-str))
touched-at (if touch
(or touched-at (ct/now))
@@ -214,10 +230,11 @@
(not= tempfile-bucket (:bucket mdata)))
(get-database-object-by-hash pool backend
(:bucket mdata)
target-str
(:hash mdata)))]
;; PHASE 2: an existing reference is found: reuse or repair it.
(if (impl/exists-object? backend' hit)
(if (impl/exists-object? backend' (row->storage-object hit))
;; PHASE 2a: healthy reference. Optionally refresh touched_at
;; and reuse the object as it is.
@@ -312,6 +329,16 @@
(ct/is-after? (:expired-at object) (ct/now))))
(-> (impl/get-object-url backend object nil) file-url->path))))
(defn target-resolvable?
"Returns true when the backend referenced by `backend-id` can resolve
`target` to a real destination. GC callers must refuse to delete objects
whose target is not resolvable, so a misconfigured target never removes
the database row while orphaning the blob."
[storage backend-id target]
(assert (valid-storage? storage))
(-> (impl/resolve-backend storage backend-id)
(impl/target-resolvable? target)))
(defn del-object!
[storage object-or-id]
(assert (valid-storage? storage))
+143
View File
@@ -0,0 +1,143 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.storage.config
"Configuration of the optional S3 storage targets and their per
semantic-bucket routing.
The routing is read from an external EDN file referenced by the
`PENPOT_OBJECTS_STORAGE_S3_ROUTES_FILE` environment variable. The file
declares additional S3 targets and, optionally, a map from Penpot
semantic bucket to target id:
{:targets
{:temp {:bucket \"penpot-temp\"}
:cold {:bucket \"penpot-cold\" :region :us-east-1}}
:routes
{\"tempfile\" :temp
\"file-data\" :temp}}
The implicit `:default` target always exists and is built from the
existing `PENPOT_OBJECTS_STORAGE_S3_*` configuration, so the feature is
fully backward compatible when the file is absent."
(:refer-clojure :exclude [load])
(:require
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.uri :as u]
[app.config :as cf]
[app.storage :as sto]
[app.storage.s3 :as sto.s3]
[clojure.edn :as edn]
[clojure.java.io :as io]))
(def ^:private schema:target
"The EDN declares targets with the same shape as the S3 backend, except
the endpoint is accepted as a plain string and normalized to a URI by
`normalize-target`."
[:merge
sto.s3/schema:target
[:map
[:endpoint {:optional true} [:or :string ::sm/uri]]]])
(def ^:private schema:file
[:map {:title "storage-routes"}
[:targets [:map-of :keyword schema:target]]
[:routes {:optional true} [:map-of :string :keyword]]])
(def ^:private valid-file?
(sm/validator schema:file))
(def ^:private explain-file
(sm/explainer schema:file))
(defn- read-file
[path]
(try
(-> (io/file path) slurp (edn/read-string))
(catch Exception cause
(ex/raise :type :validation
:code :invalid-storage-routes-file
:hint "unable to read storage routes file"
:path (str path)
:cause cause))))
(defn- assert-backend-s3!
"The routing only makes sense on top of the S3 backend."
[]
(let [backend (or (sto/get-legacy-backend)
(cf/get :objects-storage-backend)
:fs)]
(when-not (= :s3 backend)
(ex/raise :type :validation
:code :invalid-storage-routes-backend
:hint "storage routes file requires the :s3 backend"
:backend (keyword backend)))))
(defn- validate!
[data path]
(when-not (valid-file? data)
(ex/raise :type :validation
:code :invalid-storage-routes
:hint "invalid storage routes file"
:path (str path)
:explain (explain-file data)))
(let [targets (:targets data)
routes (:routes data)]
(when (contains? targets :default)
(ex/raise :type :validation
:code :reserved-storage-target
:hint "`:default` is a reserved storage target id"
:path (str path)))
(doseq [[bucket target] routes]
(when-not (contains? sto/valid-buckets bucket)
(ex/raise :type :validation
:code :invalid-storage-routes-bucket
:hint "unknown semantic bucket in storage routes"
:bucket bucket
:path (str path)))
(when-not (contains? targets target)
(ex/raise :type :validation
:code :unknown-storage-target
:hint "storage route points to an undeclared target"
:bucket bucket
:target target
:path (str path))))
data))
(defn- normalize-target
[target]
(cond-> target
(string? (:endpoint target))
(update :endpoint u/uri)))
(defn- normalize-targets
[targets]
(persistent!
(reduce-kv (fn [acc id target]
(assoc! acc id (normalize-target target)))
(transient {})
targets)))
(defn load
"Reads and validates the optional S3 routing file.
Returns a map with `:targets` (id -> target definition) and `:routes`
(semantic bucket -> target id), or `{:targets nil :routes nil}` when the
configuration key is unset."
[]
(if-let [path (not-empty (cf/get :objects-storage-s3-routes-file))]
(let [data (-> (read-file path) (validate! path))]
(assert-backend-s3!)
{:targets (normalize-targets (:targets data))
:routes (:routes data)})
{:targets nil
:routes nil}))
+5 -1
View File
@@ -141,7 +141,7 @@
(Files/deleteIfExists ^Path path)))
(defmethod impl/del-objects-in-bulk :fs
[backend ids]
[backend _target ids]
(assert (valid-backend? backend) "expected a valid backend instance")
(let [base (fs/path (::directory backend))]
(reduce (fn [fail-ids id]
@@ -153,3 +153,7 @@
(conj fail-ids id)))))
#{} ids)))
(defmethod impl/target-resolvable? :fs
[_backend _target]
true)
+56 -21
View File
@@ -78,6 +78,29 @@
(let [ids (db/create-array conn "uuid" ids)]
(db/exec-one! conn [sql:delete-give-up ids max-attempts])))
(defn- log-refusal!
"Indirection over the error log so tests can capture the refusal payload."
[backend-id target ids]
(l/err :hint "storage target is not configured, deletion refused"
:backend (name backend-id)
:target target
:ids (mapv str ids)))
(def ^:private sql:defer-unresolvable
"UPDATE storage_object
SET deleted_at = NOW() + INTERVAL '1 day'
WHERE id = ANY(?::uuid[])")
(defn- park-unresolvable!
"Refuses to delete rows whose target id is not configured: logs the
misconfiguration and pushes `deleted_at` forward so the rows leave the
selection window without being deleted and without counting a deletion
attempt (they are therefore never subject to the give-up window)."
[conn backend-id target ids]
(log-refusal! backend-id target ids)
(let [ids (db/create-array conn "uuid" ids)]
(db/exec-one! conn [sql:defer-unresolvable ids])))
(defn- process-chunk
"Attempt to delete a chunk of storage objects from a specific backend.
@@ -87,11 +110,11 @@
Returns the number of successfully deleted objects, or 0 if no rows
could be locked."
[conn storage backend-id ids]
[conn storage backend-id target ids]
(if-let [locked-ids (lock-ids conn ids)]
(let [fail-ids (try
(-> (impl/resolve-backend storage backend-id)
(impl/del-objects-in-bulk locked-ids))
(impl/del-objects-in-bulk target locked-ids))
(catch Throwable cause
(l/err :hint "error on physical deletion, will retry"
:ids locked-ids
@@ -118,12 +141,15 @@
(count ok-ids))
0))
(defn- group-by-backend
(defn- group-by-route
[items]
(d/group-by (comp keyword :backend) :id #{} items))
(d/group-by (fn [item]
[(keyword (:backend item)) (:target item)])
:id #{} items))
(def ^:private sql:get-deleted-chunk
"SELECT id, backend
"SELECT id, backend,
coalesce(metadata->>'~:storage-target', 'default') as target
FROM storage_object
WHERE deleted_at IS NOT NULL
AND deleted_at <= ?
@@ -139,19 +165,27 @@
(defn- clean-deleted!
[cfg]
(loop [total 0]
(let [deleted (db/tx-run! cfg
(fn [{:keys [::db/conn ::sto/storage]}]
(let [chunk (get-deleted-chunk conn chunk-size)]
(when (seq chunk)
(let [by-backend (group-by-backend chunk)]
(reduce-kv (fn [acc backend-id ids]
(+ acc (process-chunk conn storage backend-id ids)))
0
by-backend))))))]
(if deleted
(recur (+ total deleted))
total))))
(loop [deleted 0
parked 0]
(let [result (db/tx-run! cfg
(fn [{:keys [::db/conn ::sto/storage]}]
(let [chunk (get-deleted-chunk conn chunk-size)]
(when (seq chunk)
(let [by-route (group-by-route chunk)]
(reduce-kv
(fn [acc [backend-id target] ids]
(if (sto/target-resolvable? storage backend-id target)
(update acc :deleted + (process-chunk conn storage backend-id target ids))
(do
(park-unresolvable! conn backend-id target ids)
(update acc :parked + (count ids)))))
{:deleted 0 :parked 0}
by-route))))))]
(if result
(recur (+ deleted (:deleted result))
(+ parked (:parked result)))
{:deleted deleted
:parked parked}))))
(defmethod ig/assert-key ::handler
[_ params]
@@ -161,6 +195,7 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(let [total (clean-deleted! cfg)]
(l/inf :hint "task finished" :total total)
{:deleted total})))
(let [{:keys [deleted parked]} (clean-deleted! cfg)]
(l/inf :hint "task finished" :total deleted :parked parked)
{:deleted deleted
:parked parked})))
+17 -4
View File
@@ -72,12 +72,14 @@
:context cfg))
(defmulti del-objects-in-bulk
"Delete multiple objects in bulk. Returns #{fail-ids} — the set of ids
whose blob deletion failed. Empty set = all succeeded."
(fn [cfg _] (::sto/type cfg)))
"Delete multiple objects in bulk. `target` is an optional backend-specific
destination id (used by the S3 storage targets); backends that do not route
ignore it. Returns #{fail-ids} — the set of ids whose blob deletion failed.
Empty set = all succeeded."
(fn [cfg _ _] (::sto/type cfg)))
(defmethod del-objects-in-bulk :default
[cfg _]
[cfg _ _]
(ex/raise :type :internal
:code :invalid-storage-backend
:context cfg))
@@ -90,6 +92,17 @@
:code :invalid-storage-backend
:context cfg))
(defmulti target-resolvable?
"Returns true when the backend can resolve `target` to a real destination.
GC callers must refuse deletion (and keep the row) when this is false."
(fn [cfg _] (::sto/type cfg)))
(defmethod target-resolvable? :default
[cfg _]
(ex/raise :type :internal
:code :invalid-storage-backend
:context cfg))
;; --- HELPERS
(defn uuid->hex
+71 -18
View File
@@ -20,10 +20,12 @@
[integrant.core :as ig]))
(def ^:private sql:get-pending-sobjects
"SELECT id, backend
"SELECT id, backend,
coalesce(metadata->>'~:storage-target', 'default') as target
FROM storage_object
WHERE status = 'pending'
AND created_at <= now() - interval '24 hours'
AND (deleted_at IS NULL OR deleted_at <= now())
ORDER BY created_at ASC
LIMIT ?
FOR UPDATE
@@ -36,30 +38,76 @@
(def ^:private sql:delete-pending-sobject
"DELETE FROM storage_object WHERE id = ? AND status = 'pending'")
(defn- log-refusal!
"Indirection over the error log so tests can capture the refusal payload."
[backend-id target ids]
(l/err :hint "storage target is not configured, deletion refused"
:backend (name backend-id)
:target target
:ids (mapv str ids)))
(def ^:private sql:park-unresolvable
"UPDATE storage_object
SET deleted_at = NOW() + INTERVAL '1 day'
WHERE id = ANY(?::uuid[])
AND status = 'pending'")
(defn- park-unresolvable!
"Refuses to delete rows whose target id is not configured: logs the
misconfiguration and pushes `deleted_at` forward so the rows are excluded
from the next selection without being deleted."
[conn backend-id target ids]
(log-refusal! backend-id target ids)
(let [ids (db/create-array conn "uuid" ids)]
(db/exec-one! conn [sql:park-unresolvable ids])))
(def ^:private chunk-size
100)
(defn- group-by-route
[rows]
(group-by (fn [{:keys [backend target]}]
[(keyword backend) target])
rows))
(defn- delete-pending-rows!
"Select, lock and delete a chunk of pending rows in a single transaction.
Returns the deleted rows or nil when there is nothing left to reclaim."
Rows whose target id is not configured are never deleted: they are parked
(error logged, `deleted_at` pushed forward) so the loop terminates and the
rows survive.
Returns a map `{:deleted rows :parked count}`, or nil when there is nothing
left to reclaim."
[cfg]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(fn [{:keys [::db/conn ::sto/storage]}]
;; NOTE: db/exec! returns an empty vector when there are no
;; rows left; use not-empty to detect it.
(when-let [chunk (not-empty (get-pending-chunk conn chunk-size))]
(doseq [{:keys [id]} chunk]
(db/exec-one! conn [sql:delete-pending-sobject id]))
chunk))))
(reduce-kv
(fn [acc [backend-id target] rows]
(if (sto/target-resolvable? storage backend-id target)
(do
(doseq [{:keys [id]} rows]
(db/exec-one! conn [sql:delete-pending-sobject id]))
(update acc :deleted into rows))
(do
(park-unresolvable! conn backend-id target (mapv :id rows))
(update acc :parked + (count rows)))))
{:deleted [] :parked 0}
(group-by-route chunk))))))
(defn- delete-blobs!
"Best-effort removal of the orphaned blobs. Runs after the pending rows
have been committed so a failure here never blocks their reclamation."
have been committed so a failure here never blocks their reclamation.
The `:storage-target` metadata is load-bearing: the S3 backend reads it
(`s3/target-id`) to resolve the bucket/prefix/client for `del-object`."
[storage rows]
(doseq [{:keys [id backend]} rows]
(doseq [{:keys [id backend target]} rows]
(try
(-> (impl/resolve-backend storage (keyword backend))
(impl/del-object {:id id}))
(impl/del-object (with-meta {:id id} {:storage-target target})))
(catch Throwable cause
(l/err :hint "error deleting orphaned pending blob"
:id (str id)
@@ -68,12 +116,16 @@
(defn- process!
[{::sto/keys [storage] :as cfg}]
(loop [total 0]
(if-let [rows (delete-pending-rows! cfg)]
(do
(delete-blobs! storage rows)
(recur (long (+ total (count rows)))))
total)))
(loop [total-deleted 0
total-parked 0]
(if-let [result (delete-pending-rows! cfg)]
(let [removed (:deleted result)
parked (:parked result)]
(delete-blobs! storage removed)
(recur (long (+ total-deleted (count removed)))
(long (+ total-parked parked))))
{:processed total-deleted
:parked total-parked})))
(defmethod ig/assert-key ::handler
[_ params]
@@ -83,6 +135,7 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(let [total (process! cfg)]
(l/inf :hint "task finished" :total total)
{:processed total})))
(let [{:keys [processed parked]} (process! cfg)]
(l/inf :hint "task finished" :total processed :parked parked)
{:processed processed
:parked parked})))
+137 -32
View File
@@ -88,13 +88,21 @@
;; --- BACKEND INIT
(def schema:target
[:map {:title "s3-target"}
[:bucket ::sm/text]
[:region {:optional true} :keyword]
[:endpoint {:optional true} ::sm/uri]
[:prefix {:optional true} ::sm/text]])
(def ^:private schema:config
[:map {:title "s3-backend-config"}
::wrk/netty-io-executor
[::region {:optional true} :keyword]
[::bucket {:optional true} ::sm/text]
[::prefix {:optional true} ::sm/text]
[::endpoint {:optional true} ::sm/uri]])
[::endpoint {:optional true} ::sm/uri]
[::targets {:optional true} [:map-of :keyword schema:target]]])
(defmethod ig/expand-key ::backend
[k v]
@@ -104,42 +112,132 @@
[_ params]
(assert (sm/check schema:config params)))
(defn- build-client-pair
[{:keys [::wrk/netty-io-executor]} region endpoint]
(let [params {::region region
::endpoint endpoint
::wrk/netty-io-executor netty-io-executor}
client (build-s3-client params)
presigner (build-s3-presigner params)]
{:client @client
:presigner presigner
:close-fn #(.close ^java.lang.AutoCloseable client)}))
(defn- build-client-pair-or-cleanup
"Builds a client pair, closing the pairs already built when the build
fails so a failed backend init does not leak clients."
[acc params region endpoint]
(try
(build-client-pair params region endpoint)
(catch Throwable cause
(doseq [f (:close-fns acc)]
(ex/ignoring (f)))
(throw cause))))
(defn- build-targets
"Resolves the implicit `:default` target plus the declared targets, sharing
one S3 client/presigner pair per distinct `[region endpoint]`."
[{:keys [::region ::endpoint ::bucket ::prefix ::targets] :as params}]
(let [defs (merge {:default {:region region :endpoint endpoint
:bucket bucket :prefix prefix}}
(into {}
(map (fn [[id target]]
[id {:region (or (:region target) region)
:endpoint (or (:endpoint target) endpoint)
:bucket (:bucket target)
:prefix (or (:prefix target) prefix)}]))
targets))
result (reduce-kv
(fn [acc id {:keys [region endpoint bucket prefix]}]
(let [k [region endpoint]
pair (or (get-in acc [:pairs k])
(build-client-pair-or-cleanup acc params region endpoint))
acc (cond-> acc
(nil? (get-in acc [:pairs k]))
(-> (assoc-in [:pairs k] pair)
(update :close-fns conj (:close-fn pair))))]
(assoc-in acc [:targets id]
{::client (:client pair)
::presigner (:presigner pair)
::bucket bucket
::prefix prefix})))
{:pairs {} :targets {} :close-fns []}
defs)]
(select-keys result [:targets :close-fns])))
(defmethod ig/init-key ::backend
[_ params]
(when (and (contains? params ::region)
(contains? params ::bucket))
(let [client (build-s3-client params)
presigner (build-s3-presigner params)]
(let [{:keys [targets close-fns]} (build-targets params)]
(assoc params
::sto/type :s3
::counter (AtomicLong. 0)
::client @client
::presigner presigner
::close-fn #(.close ^java.lang.AutoCloseable client)))))
::default-target :default
::targets targets
::close-fns (vec close-fns)))))
(defmethod ig/resolve-key ::backend
[_ params]
(dissoc params ::close-fn))
(dissoc params ::close-fns))
(defmethod ig/halt-key! ::backend
[_ {:keys [::close-fn]}]
(when (fn? close-fn)
(close-fn)))
[_ {:keys [::close-fns]}]
(doseq [f close-fns]
(when (fn? f)
(f))))
(def ^:private schema:backend
[:map {:title "s3-backend"}
;; [::region :keyword]
;; [::bucket ::sm/text]
[::client [:fn #(instance? S3AsyncClient %)]]
[::presigner [:fn #(instance? S3Presigner %)]]
[::prefix {:optional true} ::sm/text]
#_[::sto/type [:= :s3]]])
[::default-target :keyword]
[::targets
[:map-of :keyword
[:map
[::client [:fn #(instance? S3AsyncClient %)]]
[::presigner [:fn #(instance? S3Presigner %)]]
[::bucket ::sm/text]
[::prefix {:optional true} ::sm/text]]]]])
(sm/register! ::backend schema:backend)
(def ^:private valid-backend?
(sm/validator schema:backend))
;; --- TARGET RESOLUTION
(defn- target-id
"Returns the configured target id stored on the object, or the default
target name when the object predates the routing feature."
[backend object]
(or (:storage-target object)
(some-> (meta object) :storage-target)
(name (::default-target backend))))
(defn- resolve-target-by-id
[backend target-id]
(let [tid (cond
(nil? target-id) (::default-target backend)
(keyword? target-id) target-id
:else (keyword target-id))]
(or (get (::targets backend) tid)
(ex/raise :type :internal
:code :invalid-storage-target
:hint "storage target is not configured"
:target target-id
:available (vec (keys (::targets backend)))))))
(defn- resolve-target
[backend object]
(resolve-target-by-id backend (target-id backend object)))
(defmethod impl/target-resolvable? :s3
[backend target-id]
(let [tid (cond
(nil? target-id) (::default-target backend)
(keyword? target-id) target-id
:else (keyword target-id))]
(contains? (::targets backend) tid)))
;; --- API IMPL
(defmethod impl/put-object :s3
@@ -210,13 +308,14 @@
true)))
(defmethod impl/del-objects-in-bulk :s3
[backend ids]
[backend target ids]
(assert (valid-backend? backend) "expected a valid backend instance")
(let [key->id (into {} (map (fn [id]
[(str (::prefix backend) (impl/id->path id)) id]))
(let [target (resolve-target-by-id backend target)
key->id (into {} (map (fn [id]
[(str (::prefix target) (impl/id->path id)) id]))
ids)
result (try
(p/await! (del-object-in-bulk backend ids))
(p/await! (del-object-in-bulk target ids))
(catch Throwable cause
(l/err :hint "error on s3 bulk deletion"
:ids ids
@@ -320,8 +419,9 @@
^Subscriber subscriber))))))
(defn- put-object
[{:keys [::client ::bucket ::prefix ::counter]} {:keys [id] :as object} content]
(let [path (dm/str prefix (impl/id->path id))
[{:keys [::counter] :as backend} {:keys [id] :as object} content]
(let [{:keys [::client ::bucket ::prefix]} (resolve-target backend object)
path (dm/str prefix (impl/id->path id))
mdata (meta object)
mtype (:content-type mdata "application/octet-stream")
rbody (make-request-body counter content)
@@ -344,8 +444,9 @@
(proxy-super close))))
(defn- get-object-data
[{:keys [::client ::bucket ::prefix]} {:keys [id size]}]
(let [gor (.. (GetObjectRequest/builder)
[backend {:keys [id size] :as object}]
(let [{:keys [::client ::bucket ::prefix]} (resolve-target backend object)
gor (.. (GetObjectRequest/builder)
(bucket bucket)
(key (str prefix (impl/id->path id)))
(build))]
@@ -369,16 +470,18 @@
(p/fmap #(.asInputStream ^ResponseBytes %)))))))
(defn- head-object
[{:keys [::client ::bucket ::prefix]} {:keys [id]}]
(let [hor (.. (HeadObjectRequest/builder)
[backend {:keys [id] :as object}]
(let [{:keys [::client ::bucket ::prefix]} (resolve-target backend object)
hor (.. (HeadObjectRequest/builder)
(bucket bucket)
(key (str prefix (impl/id->path id)))
(build))]
(.headObject ^S3AsyncClient client ^HeadObjectRequest hor)))
(defn- get-object-bytes
[{:keys [::client ::bucket ::prefix]} {:keys [id]}]
(let [gor (.. (GetObjectRequest/builder)
[backend {:keys [id] :as object}]
(let [{:keys [::client ::bucket ::prefix]} (resolve-target backend object)
gor (.. (GetObjectRequest/builder)
(bucket bucket)
(key (str prefix (impl/id->path id)))
(build))
@@ -392,7 +495,7 @@
(ct/duration {:minutes 10}))
(defn- get-object-url
[{:keys [::presigner ::bucket ::prefix]} {:keys [id]}
[backend {:keys [id] :as object}
{:keys [max-age content-disposition] :or {max-age default-max-age}}]
(assert (ct/duration? max-age) "expected valid duration instance")
@@ -400,7 +503,8 @@
;; object store sets that header on the response the client fetches after
;; following the redirect. It is only set when asked for, so urls for
;; objects served inline stay byte identical to before.
(let [gorb (.. (GetObjectRequest/builder)
(let [{:keys [::presigner ::bucket ::prefix]} (resolve-target backend object)
gorb (.. (GetObjectRequest/builder)
(bucket bucket)
(key (dm/str prefix (impl/id->path id))))
gorb (cond-> gorb
@@ -415,8 +519,9 @@
(u/uri (str (.url ^PresignedGetObjectRequest pgor)))))
(defn- del-object
[{:keys [::bucket ::client ::prefix]} {:keys [id] :as obj}]
(let [dor (.. (DeleteObjectRequest/builder)
[backend {:keys [id] :as object}]
(let [{:keys [::bucket ::client ::prefix]} (resolve-target backend object)
dor (.. (DeleteObjectRequest/builder)
(bucket bucket)
(key (dm/str prefix (impl/id->path id)))
(build))]
@@ -681,131 +681,6 @@
(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)
@@ -892,77 +767,6 @@
(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.
@@ -0,0 +1,97 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.storage-config-test
(:require
[app.common.uri :as u]
[app.config :as cf]
[app.storage.config :as sto.config]
[clojure.java.io :as io]
[clojure.test :as t]))
(defn- write-routes!
[content]
(let [file (java.io.File/createTempFile "storage-routes" ".edn")]
(spit file content)
file))
(defn- delete!
[file]
(io/delete-file file true))
(defn- load-with
[config]
(binding [cf/config (merge cf/config
{:objects-storage-backend :s3}
config)]
(sto.config/load)))
(defn- load-error
"Runs `load` against a routes file with `content` and returns the raised
throwable, or nil when it succeeds."
[content config]
(let [file (write-routes! content)]
(try
(try
(load-with (assoc config :objects-storage-s3-routes-file (.getAbsolutePath file)))
nil
(catch Throwable cause cause))
(finally
(delete! file)))))
(t/deftest returns-empty-when-unset
(t/is (= {:targets nil :routes nil}
(load-with {:objects-storage-s3-routes-file nil}))))
(t/deftest loads-and-normalizes-routes
(let [file (write-routes!
"{:targets {:temp {:bucket \"penpot-temp\" :endpoint \"https://s3.example.com\"}} :routes {\"tempfile\" :temp}}")]
(try
(let [result (load-with {:objects-storage-s3-routes-file (.getAbsolutePath file)})]
(t/is (= #{:temp} (set (keys (:targets result)))))
(t/is (= "penpot-temp" (get-in result [:targets :temp :bucket])))
(t/is (u/uri? (get-in result [:targets :temp :endpoint])))
(t/is (= {"tempfile" :temp} (:routes result))))
(finally
(delete! file)))))
(t/deftest rejects-reserved-default-target
(t/is (some? (load-error "{:targets {:default {:bucket \"x\"}}}"
{}))))
(t/deftest rejects-unknown-route-target
(t/is (some? (load-error
"{:targets {:temp {:bucket \"x\"}} :routes {\"tempfile\" :missing}}"
{}))))
(t/deftest rejects-invalid-semantic-bucket
(t/is (some? (load-error
"{:targets {:temp {:bucket \"x\"}} :routes {\"not-a-bucket\" :temp}}"
{}))))
(t/deftest rejects-unreadable-file
(t/is (some? (load-error "{:targets" {}))))
(t/deftest rejects-routing-when-backend-is-not-s3
(t/is (some? (load-error
"{:targets {:temp {:bucket \"x\"}} :routes {\"tempfile\" :temp}}"
{:objects-storage-backend :fs}))))
(t/deftest rejects-route-pointing-to-default
(let [err (load-error
"{:targets {:temp {:bucket \"x\"}} :routes {\"tempfile\" :default}}"
{})]
(t/is (some? err))
(t/is (= :unknown-storage-target (:code (ex-data err))))))
(t/deftest loads-empty-targets-map
(let [file (write-routes! "{:targets {}}")]
(try
(let [result (load-with {:objects-storage-s3-routes-file (.getAbsolutePath file)})]
(t/is (= {} (:targets result)))
(t/is (nil? (:routes result))))
(finally
(delete! file)))))
+474 -4
View File
@@ -13,13 +13,16 @@
[app.rpc :as-alias rpc]
[app.storage :as sto]
[app.storage.fs :as-alias sto.fs]
[app.storage.gc-deleted :as sto.gc-deleted]
[app.storage.impl :as impl]
[app.storage.pending-gc :as sto.pending-gc]
[app.storage.s3 :as-alias sto.s3]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]
[integrant.core :as ig]
[mockery.core :refer [with-mocks]]
[promesa.core :as p])
(:import
@@ -41,6 +44,17 @@
[storage]
(assoc storage ::sto/backend :fs))
(declare fake-s3-backend-with-targets)
(defn configure-s3-storage
"Returns a storage configured with the fake S3 backend and the given
semantic-bucket -> target routing."
[bucket->target]
(-> (:app.storage/storage th/*system*)
(assoc ::sto/backend :s3)
(assoc-in [::sto/backends :s3] (fake-s3-backend-with-targets))
(assoc ::sto/bucket->target bucket->target)))
(t/deftest put-and-retrieve-object
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -708,7 +722,7 @@
{:id (:id object)})
(with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk
:return (fn [_ ids] (set ids))}]
:return (fn [_ _ ids] (set ids))}]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res)))))
@@ -832,9 +846,146 @@
(defn- fake-s3-backend
[]
{::sto/type :s3
::sto.s3/client (reify S3AsyncClient)
::sto.s3/presigner (reify S3Presigner)})
{::sto/type :s3
::sto.s3/default-target :default
::sto.s3/targets
{:default {::sto.s3/client (reify S3AsyncClient)
::sto.s3/presigner (reify S3Presigner)
::sto.s3/bucket "test-bucket"
::sto.s3/prefix nil}}})
(defn- fake-s3-target
[bucket prefix]
{::sto.s3/client (reify S3AsyncClient)
::sto.s3/presigner (reify S3Presigner)
::sto.s3/bucket bucket
::sto.s3/prefix prefix})
(defn- fake-s3-backend-with-targets
[]
(assoc (fake-s3-backend)
::sto.s3/targets
{:default (fake-s3-target "default" nil)
:temp (fake-s3-target "temp" "tmp/")}))
(t/deftest s3-bulk-delete-resolves-target
(let [backend (fake-s3-backend-with-targets)
captured (atom nil)]
(with-mocks [_mock {:target 'app.storage.s3/del-object-in-bulk
:return (fn [target _ids]
(reset! captured target)
(p/resolved nil))}]
(t/is (= #{} (impl/del-objects-in-bulk backend :temp #{(uuid/next)})))
(t/is (= "temp" (::sto.s3/bucket @captured)))
(t/is (= "tmp/" (::sto.s3/prefix @captured))))))
(t/deftest s3-bulk-delete-rejects-unknown-target
(let [backend (fake-s3-backend-with-targets)
captured (atom nil)]
(with-mocks [_mock {:target 'app.storage.s3/del-object-in-bulk
:return (fn [target _ids]
(reset! captured target)
(p/resolved nil))}]
(let [ex (try
(impl/del-objects-in-bulk backend :missing #{(uuid/next)})
nil
(catch Throwable cause cause))]
(t/is (some? ex))
(t/is (= :invalid-storage-target (:code (ex-data ex))))
(t/is (nil? @captured))))))
(t/deftest s3-get-object-url-rejects-unknown-target
(let [backend (fake-s3-backend-with-targets)
ex (try
(impl/get-object-url backend {:id (uuid/next)
:storage-target "ghost"} {})
nil
(catch Throwable cause cause))]
(t/is (some? ex))
(t/is (= :invalid-storage-target (:code (ex-data ex))))))
(t/deftest s3-get-object-data-rejects-unknown-target
(let [backend (fake-s3-backend-with-targets)
ex (try
(impl/get-object-data backend {:id (uuid/next)
:size 1
:storage-target "ghost"})
nil
(catch Throwable cause cause))]
(t/is (some? ex))
(t/is (= :invalid-storage-target (:code (ex-data ex))))))
(t/deftest s3-target-resolvable-checks-configured-targets
(let [backend (fake-s3-backend-with-targets)]
(t/is (true? (impl/target-resolvable? backend nil)))
(t/is (true? (impl/target-resolvable? backend "default")))
(t/is (true? (impl/target-resolvable? backend :temp)))
(t/is (false? (impl/target-resolvable? backend "ghost")))))
(t/deftest fs-target-is-always-resolvable
(t/is (true? (impl/target-resolvable? {::sto/type :fs} "anything"))))
(t/deftest s3-build-targets-shares-clients-and-closes-them
(let [clients (atom [])
close-count (atom 0)
mk-client (fn [_]
(let [c (reify
clojure.lang.IDeref
(deref [_] (Object.))
java.lang.AutoCloseable
(close [_] (swap! close-count inc)))]
(swap! clients conj c)
c))]
(with-mocks [_c {:target 'app.storage.s3/build-s3-client
:return mk-client}
_p {:target 'app.storage.s3/build-s3-presigner
:return (fn [_] (reify S3Presigner))}]
(let [backend (ig/init-key :app.storage.s3/backend
{:app.storage.s3/region :eu-central-1
:app.storage.s3/bucket "main"
:app.worker/netty-io-executor :executor
:app.storage.s3/targets
{:same {:bucket "same"}
:other {:bucket "other" :region :us-east-1}}})]
;; one client pair per distinct [region endpoint]
(t/is (= 2 (count @clients)))
;; same region reuses the default client
(t/is (= (get-in backend [:app.storage.s3/targets :default :app.storage.s3/client])
(get-in backend [:app.storage.s3/targets :same :app.storage.s3/client])))
;; different region gets its own client
(t/is (not= (get-in backend [:app.storage.s3/targets :default :app.storage.s3/client])
(get-in backend [:app.storage.s3/targets :other :app.storage.s3/client])))
(ig/halt-key! :app.storage.s3/backend backend)
(t/is (= 2 @close-count))))))
(t/deftest s3-build-targets-closes-clients-on-failure
(let [close-count (atom 0)
mk-client (fn [params]
(when (= :us-east-1 (:app.storage.s3/region params))
(throw (RuntimeException. "boom")))
(reify
clojure.lang.IDeref
(deref [_] (Object.))
java.lang.AutoCloseable
(close [_] (swap! close-count inc))))]
(with-mocks [_c {:target 'app.storage.s3/build-s3-client
:return mk-client}
_p {:target 'app.storage.s3/build-s3-presigner
:return (fn [_] (reify S3Presigner))}]
(let [ex (try
(ig/init-key :app.storage.s3/backend
{:app.storage.s3/region :eu-central-1
:app.storage.s3/bucket "main"
:app.worker/netty-io-executor :executor
:app.storage.s3/targets
{:same {:bucket "same"}
:other {:bucket "other" :region :us-east-1}}})
nil
(catch Throwable cause cause))]
(t/is (some? ex))
;; the pair built before the failing one is closed
(t/is (= 1 @close-count))))))
(t/deftest s3-exists-object-returns-true-on-found
(with-mocks [mock {:target 'app.storage.s3/head-object
@@ -874,3 +1025,322 @@
(t/is (= "boom" (ex-message (ex-cause ex)))))
;; one initial attempt plus max-retries
(t/is (= 4 (:call-count @mock)))))
;; --- Storage target metadata / dedup
(t/deftest put-object-routes-bucket-to-storage-target
(let [storage (configure-s3-storage {"tempfile" :temp})]
(with-mocks [_mock {:target 'app.storage.impl/put-object
:return (fn [_ object _] object)}]
(let [object (sto/put-object! storage {::sto/content (sto/content "content")
:bucket "tempfile"
:content-type "text/plain"})
row (th/db-exec-one!
["select backend, metadata->>'~:storage-target' as target
from storage_object where id = ?" (:id object)])]
(t/is (= "s3" (:backend row)))
(t/is (= "temp" (:target row)))))))
(t/deftest put-object-falls-back-to-default-storage-target
(let [storage (configure-s3-storage {"tempfile" :temp})]
(with-mocks [_mock {:target 'app.storage.impl/put-object
:return (fn [_ object _] object)}]
(let [object (sto/put-object! storage {::sto/content (sto/content "content")
:bucket "file-media-object"
:content-type "text/plain"})
row (th/db-exec-one!
["select metadata->>'~:storage-target' as target
from storage_object where id = ?" (:id object)])]
(t/is (= "default" (:target row)))))))
(t/deftest put-object-fs-does-not-set-storage-target
(let [storage (configure-storage-backend (:app.storage/storage th/*system*))
object (sto/put-object! storage {::sto/content (sto/content "content")
:bucket "file-media-object"
:content-type "text/plain"})
row (th/db-exec-one!
["select metadata->>'~:storage-target' as target
from storage_object where id = ?" (:id object)])]
(t/is (nil? (:target row)))))
(t/deftest dedup-is-isolated-per-storage-target
(let [routed (configure-s3-storage {"file-data" :temp})
unrouted (configure-s3-storage {})
content (-> (sto/content "content")
(sto/wrap-with-hash "same-hash"))]
(with-mocks [_mock {:target 'app.storage.impl/put-object
:return (fn [_ object _] object)}]
(let [object1 (sto/put-object! routed {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
object2 (sto/put-object! unrouted {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
row (th/db-exec-one! ["select count(*) from storage_object"])]
;; same semantic bucket, different target: no dedup hit, two rows
(t/is (not= (:id object1) (:id object2)))
(t/is (= 2 (:count row)))))))
(t/deftest dedup-reuses-object-within-same-storage-target
(let [storage (configure-s3-storage {"file-data" :temp})
content (-> (sto/content "content")
(sto/wrap-with-hash "same-hash"))]
(with-mocks [_p {:target 'app.storage.impl/put-object
:return (fn [_ object _] object)}
_e {:target 'app.storage.impl/exists-object?
:return (fn [_ _] true)}]
(let [object1 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
object2 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})]
;; same semantic bucket and target: dedup reuses the object
(t/is (= (:id object1) (:id object2)))))))
(t/deftest dedup-hit-carries-storage-target-metadata
(let [storage (configure-s3-storage {"file-data" :temp})
content (-> (sto/content "content")
(sto/wrap-with-hash "same-hash"))
captured (atom nil)]
(with-mocks [_p {:target 'app.storage.impl/put-object
:return (fn [_ object _] object)}
_e {:target 'app.storage.impl/exists-object?
:return (fn [_ object]
(reset! captured object)
true)}]
(sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
(sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
(t/is (some? @captured))
(t/is (= "temp" (:storage-target (meta @captured)))))))
(t/deftest dedup-repair-carries-storage-target-metadata
(let [storage (configure-s3-storage {"file-data" :temp})
content (-> (sto/content "content")
(sto/wrap-with-hash "same-hash"))
calls (atom [])]
(with-mocks [_p {:target 'app.storage.impl/put-object
:return (fn [_ object _]
(swap! calls conj object)
object)}
_h {:target 'app.storage.s3/head-object
:return (p/rejected (-> (NoSuchKeyException/builder)
(.message "no key")
(.build)))}]
(let [object1 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
;; second put finds the row but the real exists-object? sees a
;; missing blob and repairs it in place
object2 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "file-data"
:content-type "text/plain"})
row (th/db-exec-one!
["select status from storage_object where id = ?" (:id object1)])
count (th/db-exec-one! ["select count(*) from storage_object"])]
(t/is (= (:id object1) (:id object2)))
(t/is (= "valid" (:status row)))
(t/is (= 1 (:count count)))
(t/is (= "temp" (:storage-target (meta (last @calls)))))))))
;; --- GC target routing
(defn- storage-with-s3-targets
[]
(assoc (:app.storage/storage th/*system*)
::sto/backends
{:s3 (fake-s3-backend-with-targets)}))
(t/deftest gc-deleted-deletes-from-routed-target
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)
captured (atom nil)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, deleted_at, status)
values (?, 1, 's3', ?, ?, 'valid')"
id
(db/tjson {:bucket "file-data" :storage-target "temp"})
(ct/in-past {:minutes 1})])
(with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk
:return (fn [_ target _ids]
(reset! captured target)
#{})}]
(t/is (= 1 (:deleted (#'sto.gc-deleted/clean-deleted! cfg))))
(t/is (= "temp" @captured)))))
(t/deftest gc-deleted-refuses-unknown-target-and-keeps-row
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)
logged (atom nil)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, deleted_at, status)
values (?, 1, 's3', ?, ?, 'valid')"
id
(db/tjson {:bucket "file-data" :storage-target "ghost"})
(ct/in-past {:minutes 1})])
(with-mocks [mock {:target 'app.storage.impl/del-objects-in-bulk
:return (fn [_ _ _] #{})}]
(with-redefs [app.storage.gc-deleted/log-refusal!
(fn [backend-id target ids]
(reset! logged [backend-id target (vec ids)]))]
(let [result (#'sto.gc-deleted/clean-deleted! cfg)
row (th/db-exec-one!
["select status, deleted_at, deletion_attempts
from storage_object where id = ?" id])]
(t/is (= 0 (:deleted result)))
(t/is (= 1 (:parked result)))
(t/is (= 0 (:call-count @mock)))
(t/is (= "valid" (:status row)))
(t/is (ct/is-after? (:deleted-at row) (ct/now)))
(t/is (= 0 (:deletion-attempts row)))
(t/is (= [:s3 "ghost" [id]] @logged)))))))
(t/deftest gc-deleted-give-up-not-applied-to-unknown-target
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, deleted_at, status, deletion_attempts)
values (?, 1, 's3', ?, ?, 'valid', 10)"
id
(db/tjson {:bucket "file-data" :storage-target "ghost"})
(ct/in-past {:minutes 1})])
(with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk
:return (fn [_ _ _] #{})}]
(with-redefs [app.storage.gc-deleted/log-refusal! (fn [& _] nil)]
(#'sto.gc-deleted/clean-deleted! cfg)))
(let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" id])]
(t/is (= 1 (:count row))))))
(t/deftest gc-deleted-normal-failure-defers-and-gives-up
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, deleted_at, status)
values (?, 1, 's3', ?, ?, 'valid')"
id
(db/tjson {:bucket "file-data" :storage-target "temp"})
(ct/in-past {:minutes 1})])
(with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk
:return (fn [_ _ ids] (set ids))}]
(let [result (#'sto.gc-deleted/clean-deleted! cfg)
row (th/db-exec-one!
["select deleted_at, deletion_attempts
from storage_object where id = ?" id])]
(t/is (= 0 (:deleted result)))
(t/is (= 0 (:parked result)))
(t/is (ct/is-after? (:deleted-at row) (ct/now)))
(t/is (= 1 (:deletion-attempts row))))
;; force the give-up threshold and let the next pass remove the row
(th/db-update! :storage-object
{:deletion-attempts 7
:deleted-at (ct/in-past {:minutes 1})}
{:id id})
(let [result (#'sto.gc-deleted/clean-deleted! cfg)]
(t/is (= 0 (:deleted result)))
(let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" id])]
(t/is (= 0 (:count row))))))))
(t/deftest pending-gc-deletes-resolvable-target
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)
captured (atom nil)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, created_at, status)
values (?, 1, 's3', ?, ?, 'pending')"
id
(db/tjson {:storage-target "temp"})
(ct/in-past {:days 2})])
(with-mocks [_mock {:target 'app.storage.impl/del-object
:return (fn [_ object]
(reset! captured object)
nil)}]
(let [result (#'sto.pending-gc/process! cfg)]
(t/is (= 1 (:processed result)))
(t/is (= 0 (:parked result)))
(t/is (= "temp" (:storage-target (meta @captured))))
(let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" id])]
(t/is (= 0 (:count row))))))))
(t/deftest pending-gc-refuses-unknown-target-and-keeps-row
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)
logged (atom nil)
captured (atom nil)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, created_at, status)
values (?, 1, 's3', ?, ?, 'pending')"
id
(db/tjson {:storage-target "ghost"})
(ct/in-past {:days 2})])
(with-mocks [mock {:target 'app.storage.impl/del-object
:return (fn [_ object]
(reset! captured object)
nil)}]
(with-redefs [app.storage.pending-gc/log-refusal!
(fn [backend-id target ids]
(reset! logged [backend-id target (vec ids)]))]
(let [result (#'sto.pending-gc/process! cfg)
row (th/db-exec-one!
["select status, deleted_at from storage_object where id = ?" id])]
(t/is (= 0 (:processed result)))
(t/is (= 1 (:parked result)))
(t/is (= 0 (:call-count @mock)))
(t/is (nil? @captured))
(t/is (= "pending" (:status row)))
(t/is (ct/is-after? (:deleted-at row) (ct/now)))
(t/is (= [:s3 "ghost" [id]] @logged)))))))
(t/deftest gc-deleted-legacy-rows-delete-from-default-target
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)
captured (atom nil)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, deleted_at, status)
values (?, 1, 's3', ?, ?, 'valid')"
id
(db/tjson {:bucket "file-data"})
(ct/in-past {:minutes 1})])
(with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk
:return (fn [_ target _ids]
(reset! captured target)
#{})}]
(let [result (#'sto.gc-deleted/clean-deleted! cfg)]
(t/is (= 1 (:deleted result)))
(t/is (= 0 (:parked result)))
(t/is (= "default" @captured))
(let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" id])]
(t/is (= 0 (:count row))))))))
(t/deftest pending-gc-legacy-rows-delete-from-default-target
(let [storage (storage-with-s3-targets)
cfg {::db/pool th/*pool* ::sto/storage storage}
id (uuid/next)
captured (atom nil)]
(th/db-exec! ["insert into storage_object (id, size, backend, metadata, created_at, status)
values (?, 1, 's3', ?, ?, 'pending')"
id
(db/tjson {:bucket "file-data"})
(ct/in-past {:days 2})])
(with-mocks [_mock {:target 'app.storage.impl/del-object
:return (fn [_ object]
(reset! captured object)
nil)}]
(let [result (#'sto.pending-gc/process! cfg)]
(t/is (= 1 (:processed result)))
(t/is (= 0 (:parked result)))
(t/is (= "default" (:storage-target (meta @captured))))
(let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" id])]
(t/is (= 0 (:count row))))))))
-13
View File
@@ -460,19 +460,6 @@
(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]
+9 -160
View File
@@ -940,21 +940,18 @@
(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?)
(and at-p? (= :move-to (:command cmd)))
(let [off (gpt/point (* k ox) (* k oy))]
(recur (inc i) (inc k) start
(recur (inc i) (inc k)
(conj! result (-> cmd
(update-in [:params :x] + (:x off))
(update-in [:params :y] + (:y off))))))
@@ -975,7 +972,7 @@
(= :curve-to (:command nxt))
(-> (update-in [:params :c1x] + (:x off2))
(update-in [:params :c1y] + (:y off2))))]
(recur (+ i 2) (inc k2) start
(recur (+ i 2) (inc k2)
(-> result (conj! cmd') (conj! mv) (conj! nxt'))))
;; Open and offset a closed seam.
@@ -988,20 +985,12 @@
(-> (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) 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)))))
(recur (+ i 2) (inc k) (conj! result cmd')))
;; 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) start
(recur (inc i) (inc k)
(conj! result (cond-> (-> cmd
(update-in [:params :x] + (:x off))
(update-in [:params :y] + (:y off)))
@@ -1010,7 +999,7 @@
(update-in [:params :c2y] + (:y off)))))))
:else
(recur (inc i) k start (conj! result cmd))))))))
(recur (inc i) k (conj! result cmd))))))))
(defn separate-nodes
"Removes segments between points or splits one node into offset open ends."
@@ -1083,7 +1072,7 @@
result (cond-> result
(and (nil? set-a) (nil? set-b))
(conj (hash-set point-a point-b))
(conj #{point-a point-b})
(and (some? set-a) (nil? set-b))
(add-to-set set-a point-b)
@@ -1119,144 +1108,6 @@
(->> 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]
@@ -1265,12 +1116,10 @@
(if (seq segments)
(let [point->merge-point (-> segments
(group-segments)
(calculate-merge-points points))
merge-points (set (vals point->merge-point))]
(calculate-merge-points points))]
(-> content
(separate-nodes points)
(replace-points point->merge-point)
(merge-coincident-nodes merge-points)))
(replace-points point->merge-point)))
content)))
(defn transform-content
+32 -39
View File
@@ -99,30 +99,25 @@
(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. 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
and removed from subpaths the subpaths merged"
[candidate subpaths]
(let [merge-with-candidate
(fn [[candidate result] current]
(cond
(pt= (:to current) (:from current))
;; Subpath is already a closed path
[candidate (conj result current)]
(joins? (:to candidate) (:from current))
(pt= (:to candidate) (:from current))
[(subpaths-join candidate current) result]
(joins? (:from candidate) (:to current))
(pt= (:from candidate) (:to current))
[(subpaths-join current candidate) result]
(joins? (:to candidate) (:to current))
(pt= (:to candidate) (:to current))
[(subpaths-join candidate (reverse-subpath current)) result]
(joins? (:from candidate) (:from current))
(pt= (:from candidate) (:from current))
[(subpaths-join (reverse-subpath current) candidate) result]
:else
@@ -168,37 +163,35 @@
(into [] xf-mapcat-data merged)))
(defn close-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)]
"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)]
(if (some? current)
(let [[new-current new-subpaths]
(if (is-closed? current)
[current subpaths]
(merge-paths current subpaths meet?))]
(if (some? current)
(let [[new-current new-subpaths]
(if (is-closed? current)
[current subpaths]
(merge-paths current 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 (= 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."
+1 -17
View File
@@ -272,20 +272,6 @@
-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))
@@ -301,7 +287,6 @@
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)))))
@@ -317,8 +302,7 @@
([objects position options]
(->> (get-frames objects options)
(filter #(and ^boolean (some? position)
^boolean (gsh/has-point? % position)
^boolean (not (clipped-by-ancestor? objects % position))))
^boolean (gsh/has-point? % position)))
(sort-z-index-objects objects))))
(defn top-nested-frame
@@ -1373,20 +1373,6 @@
(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
@@ -2106,7 +2092,7 @@
(t/is (some? result)))))
(t/deftest path-merge-disconnected-nodes
;; Merging separate subpaths stitches them into one at the shared midpoint.
;; Merging separate subpaths joins them 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}}
@@ -2114,184 +2100,10 @@
{: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 (= [: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}]
(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}]
(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
@@ -1,58 +0,0 @@
;; 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)))))
+5
View File
@@ -157,6 +157,11 @@ services:
# PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: <ENDPOINT>
# PENPOT_OBJECTS_STORAGE_S3_BUCKET: <BUKET_NAME>
## Optional: route specific internal semantic buckets (for example
## tempfile) to additional S3 targets. The file is EDN; see
## docs/technical-guide/configuration.md for the format.
# PENPOT_OBJECTS_STORAGE_S3_ROUTES_FILE: /opt/data/storage-routes.edn
## Telemetry. When enabled, a periodical process will send anonymous data about this
## instance. Telemetry data will enable us to learn how the application is used,
## based on real scenarios. If you want to help us, please leave it enabled. You can
+45
View File
@@ -549,6 +549,51 @@ PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: <endpoint-uri>
These settings are equally useful if you have a Minio storage system.
</p>
#### S3 targets and per-bucket routing
__Since version 2.19.0__
By default the S3 backend stores every object in the configured bucket. You can
route specific internal semantic buckets (for example `tempfile` or `file-data`)
to additional S3 targets. Each target has its own bucket and, optionally, its
own key prefix, region and endpoint.
Targets and routing are declared in an external EDN file referenced by the
`PENPOT_OBJECTS_STORAGE_S3_ROUTES_FILE` environment variable:
```clojure
{:targets
{:temp {:bucket "penpot-temp"}
:cold {:bucket "penpot-cold"
:region :us-east-1
:endpoint "https://s3.us-east-1.amazonaws.com"
:prefix "objects/"}}
:routes
{"tempfile" :temp
"file-data" :temp}}
```
- `:targets` declares the named destinations. `:bucket` is required; `:prefix`,
`:region` and `:endpoint` are optional and inherit the values from the default
target (`PENPOT_OBJECTS_STORAGE_S3_*`) when omitted.
- `:routes` maps a Penpot semantic bucket to a target id. Semantic buckets that
are not listed use the default target (the configured
`PENPOT_OBJECTS_STORAGE_S3_BUCKET`).
- The `:default` target id is reserved and implicit; do not declare it.
- The file is optional and only applies to the `s3` backend. Without it, all
objects use the default bucket and the behavior is unchanged.
The target id is stored in the object metadata, so it must stay stable. If you
remove or rename a target id, objects already written with it cannot be located:
reads and serving of those objects fail, and the garbage-collection tasks refuse
to delete them. Those rows are parked for one day and retried on later runs,
without counting deletion attempts and without ever reaching the give-up window,
so the row is never removed until the routes file declares the target id again.
All backend replicas must mount the same routes file with the same target ids. A
replica with a stale file misroutes new writes and fails reads and garbage
collection for the targets it does not declare.
### File Data Storage
__Since version 2.11.0__
@@ -16,11 +16,12 @@ that may be used for any kind of user uploaded files. Currently:
There is an abstract interface and several implementations (or **backends**),
depending on where the objects are actually stored:
* <code class="language-clojure">:assets-fs</code> stores ojects in the file system, under a given base path.
* <code class="language-clojure">:assets-s3</code> stores them in any cloud storage with an AWS-S3 compatible
* <code class="language-clojure">:fs</code> stores objects in the file system, under a given base path.
* <code class="language-clojure">:s3</code> stores them in any cloud storage with an AWS-S3 compatible
interface.
* <code class="language-clojure">:assets-db</code> stores them inside the PostgreSQL database, in a special table
with a binary column.
Legacy rows may still reference the deprecated <code class="language-clojure">:assets-fs</code> and
<code class="language-clojure">:assets-s3</code> names, which alias the current backends.
## Storage API
@@ -83,6 +84,17 @@ The storage module may use the bucket (hardcoded) to make special treatment to
object, such as storing in a different path, or guessing how to know if an object
is referenced from other place.
When the <code class="language-clojure">:s3</code> backend is used, a semantic bucket may also be routed to a
named S3 **target** (a different bucket and/or key prefix). The chosen target id
is stored in the object metadata (<code class="language-clojure">:storage-target</code>) and resolved again on
reads, URL signing, deduplication and garbage collection. Objects without a
stored target use the default target. See the configuration guide for the
routing file format.
If an object references a target id that is not configured anymore, it cannot be
located: reads and serving fail, and garbage collection refuses to delete the
object, keeping the database row until the target is declared again.
## Sharing and deleting objects
To save storage space, duplicated objects wre shared. So, if for example
File diff suppressed because it is too large. Load diff
@@ -626,92 +626,3 @@ test("Renders background blur clipped by a board with clip content", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
test("Clips a group dragged into a board with clip content", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-shapes-groups-boards.json");
await workspace.goToWorkspace({
id: "53a7ff09-2228-81d3-8006-4b5eac177245",
pageId: "53a7ff09-2228-81d3-8006-4b5eac177246",
});
await workspace.waitForFirstRenderWithoutUI();
// Select the group, then drag it so it straddles the right edge of the
// board. The overflow must not be painted while the pointer is still down.
await workspace.viewport.hover({ position: { x: 1028, y: 548 } });
await page.mouse.down();
await page.mouse.up();
await page.waitForTimeout(200);
await page.mouse.down();
for (const [x, y] of [
[1000, 540],
[920, 530],
[830, 520],
]) {
await workspace.viewport.hover({ position: { x, y } });
await page.waitForTimeout(100);
}
await page.waitForTimeout(600);
await expect(workspace.canvas).toHaveScreenshot();
await page.mouse.up();
await page.waitForTimeout(800);
await expect(workspace.canvas).toHaveScreenshot();
});
test("Clips a group dragged inside a board with clip content", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-shapes-groups-boards.json");
await workspace.goToWorkspace({
id: "53a7ff09-2228-81d3-8006-4b5eac177245",
pageId: "53a7ff09-2228-81d3-8006-4b5eac177246",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.viewport.hover({ position: { x: 1028, y: 548 } });
await page.mouse.down();
await page.mouse.up();
await page.waitForTimeout(200);
// Drop the group inside the board so it becomes one of its children.
await page.mouse.down();
for (const [x, y] of [
[900, 540],
[700, 520],
]) {
await workspace.viewport.hover({ position: { x, y } });
await page.waitForTimeout(100);
}
await page.waitForTimeout(600);
await page.mouse.up();
await page.waitForTimeout(800);
// Drag it towards the right edge, now as a board child.
await page.mouse.down();
for (const [x, y] of [
[750, 520],
[830, 520],
]) {
await workspace.viewport.hover({ position: { x, y } });
await page.waitForTimeout(100);
}
await page.waitForTimeout(600);
await expect(workspace.canvas).toHaveScreenshot();
await page.mouse.up();
await page.waitForTimeout(800);
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -653,32 +653,3 @@ test("Renders background blur on text shapes", async ({ page }) => {
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Flattens texts to paths", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-text-flatten.json");
await workspace.goToWorkspace({
id: "3b0d758a-8c9d-8013-8006-52c8337e5c72",
pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73",
});
await workspace.waitForFirstRender();
const flattenButton = workspace.page.getByRole("button", {
name: "Flatten",
exact: true,
});
for (const layer of ["Flat spacing", "Flat paragraph", "Flat centered"]) {
await workspace.clickLeafLayer(layer);
const renderCount = await workspace.getRenderCount();
await flattenButton.click();
await workspace.waitForNextRender(renderCount);
}
await workspace.page.keyboard.press("Escape");
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 });
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

@@ -15,7 +15,7 @@
(defn clean-edit-state
[state]
(dissoc state :last-point :prev-handler :drag-handler :preview :pending-start))
(dissoc state :last-point :prev-handler :drag-handler :preview))
(defn- drop-trailing-move-to
"Drops a trailing subpath start without segments."
@@ -113,9 +113,7 @@
(update [_ state]
(let [id (st/get-path-id state)
fix-angle? shift?
{:keys [last-point prev-handler pending-start]}
(get-in state [:workspace-local :edit-path id])
{:keys [last-point prev-handler]} (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)
@@ -123,9 +121,6 @@
(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)))))
@@ -403,19 +398,16 @@
(cond-> (some? drop-index)
(with-meta {:index drop-index})))))))))
(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."
(defn- close-drawn-loops
"Adds explicit close commands to completed loops."
[]
(ptk/reify ::clean-drawn-content
(ptk/reify ::close-drawn-loops
ptk/UpdateEvent
(update [_ state]
(d/update-in-when state [:workspace-drawing :object]
(fn [object]
(-> object
(update :content path/merge-coincident-nodes)
(update :content path/close-loops)
(path/update-geometry)))))))
(defn- handle-drawing-end
@@ -435,13 +427,13 @@
(cond
(and (> (count content) 1) restart?)
(rx/of (common/finish-path)
(clean-drawn-content)
(close-drawn-loops)
(setup-frame)
(dwdc/handle-finish-drawing)
(start-created-path-edition shape-id))
(> (count content) 1)
(rx/of (clean-drawn-content)
(rx/of (close-drawn-loops)
(setup-frame)
(dwdc/handle-finish-drawing)
(dwe/clear-edition-mode))
@@ -537,11 +529,16 @@
pos (helpers/node-position content index)
last-idx (dec (count content))
tip? (and (= index last-idx)
(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)))
(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))))))
state)))
(defn change-edit-mode
@@ -163,7 +163,6 @@
(-> 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)))))))
@@ -287,10 +286,7 @@
content-modifiers))]
(-> state
(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))))))))
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
(defn- move-node-indices
[state node-indices from-point to-point]
@@ -470,7 +466,6 @@
(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)
@@ -560,7 +555,6 @@
(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
@@ -759,7 +753,6 @@
(rx/of (move-selected direction shift?)))
(rx/of (apply-content-modifiers)
(tools/merge-coincident-nodes)
(finish-move-selected))))
(rx/empty)))))))
@@ -953,7 +946,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]
@@ -962,9 +955,7 @@
(st/set-content (-> content
(path/split-segments #{from-p to-p} t)
(path/content)))
(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))))))
(update-in (st/get-path-location state) path/update-geometry))))))
(defn create-node-at-position
[params]
@@ -15,12 +15,6 @@
[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]
@@ -186,36 +180,6 @@
: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]
@@ -337,33 +301,17 @@
(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 (coincident-node-indices content selected-nodes)
(let [selected-nodes (into #{} (filter #(node? 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 (node-positions content selected-nodes))
num-nodes (count selected-nodes)
nodes-selected? (seq selected-nodes)
segments-selected? (seq selected-segments)
max-segments (/ (* num-nodes (dec num-nodes)) 2)
@@ -575,12 +523,6 @@
(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,8 +49,7 @@
(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)))
#(helpers/remap-handler-types % old-content new-content))))
state)))
ptk/WatchEvent
@@ -81,11 +80,9 @@
(reduce path/make-curve-point content))))))
(defn- apply-handler-type-modifiers
"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]
"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)]
(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)
@@ -101,13 +98,10 @@
(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]
(let [reference (helpers/handler-type-reference
content selection edited node-index)]
(d/deep-merge acc (apply-handler-type-modifiers content reference type))))
(d/deep-merge acc (apply-handler-type-modifiers content node-index type)))
{} nodes)
new-content (path/apply-content-modifiers content modifiers)]
(-> (st/set-content state new-content)
@@ -142,22 +136,17 @@
(make-curve point)))))))))
(defn- update-path-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)))))
"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)))))
(defn remove-segments
"Removes segments and opens the path at their endpoints."
@@ -238,30 +227,6 @@
(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))
@@ -316,8 +281,9 @@
indices (if (seq selected)
selected
(helpers/node-indices content))
flipped (path/flip-content content indices axis)]
(update-path-content state flipped (merge-coincident flipped indices))))))
content (path/flip-content content indices axis)]
(-> (st/set-content state content)
(update-in (st/get-path-location state) path/update-geometry))))))
(defn align-nodes
"Aligns selected nodes and their handles within their bounds."
@@ -328,8 +294,9 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selected (get (st/get-selection state id) :nodes #{})
aligned (path/align-content content selected axis)]
(update-path-content state aligned (merge-coincident aligned selected))))))
content (path/align-content content selected axis)]
(-> (st/set-content state content)
(update-in (st/get-path-location state) path/update-geometry))))))
(defn distribute-nodes
"Distributes selected nodes evenly along `axis`."
@@ -340,8 +307,9 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selected (get (st/get-selection state id) :nodes #{})
spread (path/distribute-content content selected axis)]
(update-path-content state spread (merge-coincident spread selected))))))
content (path/distribute-content content selected axis)]
(-> (st/set-content state content)
(update-in (st/get-path-location state) path/update-geometry))))))
(defn- axis-point
"Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`."
@@ -411,8 +379,8 @@
(cond-> content
(seq node-idx) (path/set-nodes-coordinate node-idx axis value)
(seq pts) (path/set-handler-points pts))))]
(update-path-content state new-content
(merge-coincident new-content node-idx))))))
(-> (st/set-content state new-content)
(update-in (st/get-path-location state) path/update-geometry))))))
(defn toggle-snap []
(ptk/reify ::toggle-snap
@@ -216,7 +216,8 @@
(fn [event]
(when (kbd/enter? event)
(dom/stop-propagation event)
(on-menu-click event))))]
(on-menu-click event))))
title-width (/ 100 limit)]
[:article {:class (stl/css-case :dashboard-project-row true :first is-first)}
[:header {:class (stl/css :project)}
@@ -226,6 +227,7 @@
: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,14 +92,11 @@
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,7 +467,6 @@
drag-handler
prev-handler
preview
last-point
content-modifiers
selection
moving-nodes
@@ -535,6 +534,9 @@
(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))
@@ -587,7 +589,7 @@
(and is-hover (some? hover-point))))}]))
(when (and preview (not drag-handler))
[:> path-preview* {:segment preview
:from last-point
:from last-p
:zoom zoom}])
;; Let insertion preview clicks reach the segment.
@@ -598,9 +600,9 @@
:is-new true
:zoom zoom}]])
(when (and drag-handler last-point)
(when (and drag-handler last-p)
[:g.drag-handler {:pointer-events "none"}
[:> path-handler* {:point last-point
[:> path-handler* {:point last-p
:handler drag-handler
:edit-mode edit-mode
:zoom zoom}]])
@@ -622,10 +624,10 @@
:drag-cursor drag-cursor
:any-node-selected any-node-selected?}])
(when (and prev-handler last-point)
(when (and prev-handler last-p)
[:g.prev-handler
[:> path-handler*
{:point last-point
{:point last-p
:edit-mode edit-mode
:handler prev-handler
:zoom zoom
@@ -15,7 +15,6 @@
[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]
@@ -116,16 +115,8 @@
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
(mf/with-memo [path-content selected-nodes]
(path.helpers/selected-node-count path-content {:nodes selected-nodes}))
(count (dm/get-in edit-path-state [:selection :nodes]))
files
(mf/deref refs/files)
-4
View File
@@ -2211,10 +2211,6 @@
[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"))))
+1 -9
View File
@@ -370,12 +370,6 @@
(.-type ^ShapeProxy self)
delegate')))))
(def ^:private base-fields
"Base fields of the `Shape` record this proxy stands in for: `cr/defrecord`
nils them on dissoc instead of removing them, and the schema requires them."
#{:name :x :y :width :height :rotation :selrect :points
:transform :transform-inverse :parent-id :frame-id :flip-x :flip-y})
(defn- impl-dissoc
[self k]
(when shape/*shape-changes*
@@ -391,9 +385,7 @@
nil
(.-delegate ^ShapeProxy self))
(let [delegate (.-delegate ^ShapeProxy self)
delegate' (if (contains? base-fields k)
(assoc delegate k nil)
(dissoc delegate k))]
delegate' (dissoc delegate k)]
(if (identical? delegate delegate')
self
(ShapeProxy. (.-id ^ShapeProxy self)
+4 -8
View File
@@ -245,20 +245,16 @@
overlaps-parent?
(fn [clip-parents]
(->> 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?)))]
(->> clip-parents (some (comp not overlaps?)) not))]
;; Shapes after filters of overlapping and criteria
(into (d/ordered-set)
(comp (map #(unchecked-get % "data"))
(filter match-criteria?)
(filter overlaps?)
(filter (comp overlaps-parent? :clip-parents))
(filter (if clip-children?
(comp overlaps-parent? :clip-parents)
(constantly true)))
(keep :id))
result)))
@@ -7,7 +7,6 @@
(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]
@@ -23,8 +22,7 @@
(t/is (true? (:make-corner enabled)))
(t/is (true? (:make-curve enabled)))))
(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.
(t/deftest action-eligibility-keeps-coincident-node-identities
(let [content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
@@ -34,42 +32,8 @@
enabled (path.helpers/check-enabled content #{0 2})]
(t/is (true? (:make-corner enabled)))
(t/is (true? (:make-curve 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/is (true? (:merge-nodes enabled)))
(t/is (true? (:join-nodes enabled)))))
(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/clean-drawn-content
(t/is (= [::path.drawing/close-drawn-loops
::path.drawing/setup-frame
::dwdc/handle-finish-drawing
::dwe/clear-edition-mode]
@@ -175,37 +175,13 @@
true
(fn [emissions]
(t/is (= [::path.common/finish-path
::path.drawing/clean-drawn-content
::path.drawing/close-drawn-loops
::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,11 +306,8 @@
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 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])))))
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))))
;; a coincident closed-seam node moves as one logical node
(let [id (random-uuid)
content (path/content
@@ -322,8 +319,7 @@
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)))
;; the seam is one node, so the subpath closes on it
(t/is (= [:move-to :line-to :close-path] (mapv :command (vec content')))))
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 2))))
;; a selected handler on an independent node moves only its own control point
(let [id (random-uuid)
content (path/content
@@ -363,17 +359,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: becomes the pending origin of a new subpath, which stays
;; out of the content until the next click draws a line from it
;; 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
(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 (= (gpt/point 10 0)
(get-in state' [:workspace-local :edit-path id :pending-start])))
(t/is (= (vec content) (vec content'))))
(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))))
;; the drawing tip: just becomes the pending origin (extends), no new subpath
(let [state (pth/selectable-path-state id content
{:nodes #{2} :segments #{} :handlers #{}})
@@ -381,7 +377,6 @@
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
@@ -420,8 +415,7 @@
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)))
;; the seam commands merge into the subpath close
(t/is (= :close-path (:command (nth (vec content') 4))))))
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 4)))))
(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection
;; Selected segments and nodes translate as one group.
@@ -434,12 +428,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 5) state)
state' (ptk/update (path.tools/set-selection-coordinate :x 10) 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 10 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
(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/is (= (gpt/point 30 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 40 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.
@@ -807,208 +801,4 @@
(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])))))
+4 -16
View File
@@ -438,7 +438,6 @@ pub(crate) struct RenderState {
pub viewport_presented: bool,
}
#[derive(Clone)]
pub struct InteractiveDragCrop {
pub src_doc_bounds: Rect,
pub src_selrect: Rect,
@@ -3726,21 +3725,7 @@ impl RenderState {
);
if use_cached {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id).cloned() {
self.surfaces.canvas(target_surface).save();
self.surfaces.canvas(target_surface).reset_matrix();
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element
.should_use_antialias(scale, self.options.antialias_threshold);
self.clip_target_surface_to_stack(
clips,
target_surface,
scale,
antialias,
);
}
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
let crop_image = &crop.image;
let crop_src_selrect = crop.src_selrect;
@@ -3752,11 +3737,14 @@ impl RenderState {
),
None => (0.0, 0.0),
};
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
let canvas = self.surfaces.canvas(target_surface);
canvas.save();
canvas.reset_matrix();
// If the crop includes shadows/blur (extrect pixels outside the fill/stroke
// silhouette), do NOT apply the silhouette clip or we'd cut those pixels.
let should_clip_crop = element.shadows.is_empty() && element.blur.is_none();
+4
View File
@@ -132,6 +132,10 @@ impl FontStore {
&self.fallback_fonts
}
pub fn get_emoji_font(&self, _size: f32) -> Option<Font> {
None
}
pub fn set_source_url(&mut self, alias: &str, url: String) {
if !url.is_empty() {
self.source_urls.insert(alias.to_string(), url);
+1 -3
View File
@@ -40,9 +40,7 @@ fn draw_surface_src_rect_to_dst(
return;
}
to_canvas.save();
// 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);
to_canvas.clip_rect(dst, None, true);
let sx = dst.width() / src.width();
let sy = dst.height() / src.height();
to_canvas.translate((dst.left, dst.top));
+27 -405
View File
@@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint};
use crate::shapes::{radius_to_sigma, Shadow, Shape, Type};
use crate::shapes::{radius_to_sigma, Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
@@ -25,17 +25,6 @@ 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 {
@@ -50,23 +39,9 @@ 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;
@@ -128,11 +103,11 @@ impl SvgLayerCanvas {
self.out.push_str(markup);
}
/// 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 {
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
ctm = ctm * *draw_matrix;
ctm = ctm * shape.centered_transform();
format!(
"matrix({} {} {} {} {} {})",
ctm.scale_x(),
@@ -148,9 +123,6 @@ 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();
{
@@ -158,7 +130,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, self.silhouette_offset);
draw_clip_geometry(cv, shape, tree, &paint);
}
self.finish_clip_path_fragment(id, canvas);
}
@@ -180,242 +152,49 @@ impl SvgLayerCanvas {
));
}
/// Registers a composite effects `<filter>` (drop/inner shadows + optional
/// layer blur) matching classic SVG filter order, and returns its id.
/// Registers a layer-blur `<filter>` and returns its id.
///
/// 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}"/>"#
));
}
}
/// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`).
/// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs.
pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String {
let id = self.unique("blur");
self.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"{body}</filter>"
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ",
"color-interpolation-filters=\"sRGB\">",
"<feGaussianBlur stdDeviation=\"{sigma}\"/>",
"</filter>"
),
id = id,
region = filter_region_attrs(self),
body = body
sigma = sigma
));
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).
///
/// `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),
) {
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
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, silhouette_offset);
draw_clip_geometry(cv, child, tree, paint);
}
}
return;
}
cv.save();
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);
cv.concat(&shape.centered_transform());
draw_shape_geometry(cv, shape, paint);
cv.restore();
}
/// 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.
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
///
/// 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.
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
/// Shadows still need dedicated re-emission in a later PR.
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
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();
@@ -427,168 +206,11 @@ pub(super) fn opacity_blend_attrs(element: &Shape) -> Option<String> {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
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 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() {
+16 -80
View File
@@ -1,11 +1,8 @@
use crate::error::Result;
use crate::shapes::{Shadow, Shape};
use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter,
shape_with_selrect_outset, SvgLayerCanvas,
};
use super::document::{effect_attrs, SvgLayerCanvas};
use super::images::{emit_fills, emit_strokes};
use super::render_tree;
use crate::render::RenderResources;
@@ -17,61 +14,11 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
// 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);
let effects = 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");
@@ -79,43 +26,32 @@ fn render_frame_body(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space), with linked `<image>` for image fills.
if !element.fills.is_empty() {
// 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),
)?;
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
}
// 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: 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();
// Strokes over children (frame space), outside the content clip.
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
let matrix = builder.silhouette_draw_matrix(element);
emit_strokes(
builder,
shared,
element,
&visible_strokes,
scale,
Some(matrix),
)?;
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}
+7 -48
View File
@@ -1,10 +1,8 @@
use crate::error::Result;
use crate::shapes::{Shadow, Shape};
use crate::shapes::Shape;
use crate::state::ShapesPoolRef;
use super::document::{
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas,
};
use super::document::{effect_attrs, SvgLayerCanvas};
use super::render_tree;
use crate::render::RenderResources;
@@ -15,33 +13,7 @@ pub(super) fn render_group(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
// 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);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
@@ -50,27 +22,14 @@ 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.
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)?;
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}
+9 -41
View File
@@ -15,9 +15,6 @@ 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,
@@ -25,20 +22,19 @@ 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, matrix)?;
emit_image_fill(builder, shared, shape, image_fill, tree)?;
}
fill => {
let matrix = shape.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
@@ -62,27 +58,16 @@ 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,
draw_matrix,
);
emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
Ok(())
}
@@ -91,27 +76,23 @@ 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 = draw_matrix.unwrap_or_else(|| shape.centered_transform());
let matrix = shape.centered_transform();
// strokes[0] is topmost; draw bottom -> top.
for stroke in strokes.iter().rev() {
match &stroke.fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale, matrix)?;
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?;
}
_ => {
let canvas = builder.canvas();
@@ -134,7 +115,6 @@ 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(());
@@ -145,7 +125,7 @@ fn emit_image_stroke(
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&draw_matrix);
cv.concat(&shape.centered_transform());
if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
cv.restore();
return Ok(());
@@ -156,15 +136,7 @@ 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,
draw_matrix,
);
emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id);
Ok(())
}
@@ -188,17 +160,13 @@ 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() {
@@ -206,7 +174,7 @@ pub(super) fn emit_linked_image_element(
} else {
"none"
};
let transform = builder.page_draw_matrix_attr(&draw_matrix);
let transform = builder.page_shape_matrix_attr(shape);
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
String::new()
+25 -43
View File
@@ -60,8 +60,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
/// `<clipPath>`.
///
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// wrapper. Masks and text strokes still need dedicated SVG re-emission.
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
/// image-filled strokes use a linked `<image>` clipped to the stroke.
pub fn render_to_svg(
@@ -135,7 +135,7 @@ use frames::render_frame;
use groups::render_group;
use text::render_text_fill;
use document::{effect_attrs, push_text_silhouette_spread_filter, shape_with_selrect_outset};
use document::effect_attrs;
use images::{emit_fills, emit_strokes};
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
@@ -193,58 +193,40 @@ 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(_)) {
// 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();
}
render_text_fill(builder, shared, element)?;
} else if matches!(element.shape_type, Type::SVGRaw(_)) {
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&draw_matrix);
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_svg(element)?;
canvas.restore();
} else {
emit_fills(
builder,
shared,
&fill_shape,
&fill_shape.fills,
tree,
scale,
Some(draw_matrix),
)?;
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
// Drop/inner shadows are native SVG filters on the effects `<g>` —
// do not draw them via Skia image-filters (SkSVGDevice drops them).
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();
// 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,
Some(draw_matrix),
)?;
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();
}
}
}
}
@@ -1,16 +0,0 @@
---
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>
@@ -1,12 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="237.64102" height="157.64102" viewBox="0 0 237.64102 157.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="237.64102" height="157.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="6.2735023" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.39215687 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#F0F0F0" transform="translate(18.8205 18.8205)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(18.8205 18.8205)" x="20" y="20" width="80" height="60"/>
</g>
<rect fill="#F0F0F0" transform="translate(18.8205 10.8205)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(18.8205 10.8205)" x="20" y="20" width="80" height="60"/>
</svg>
@@ -3,7 +3,7 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="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)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)">
<rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/>
<rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/>
</g></svg>
@@ -1,8 +0,0 @@
---
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>
@@ -1,8 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="123.78461" height="103.78461" viewBox="0 0 123.78461 103.78461"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="123.78461" height="103.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset in="hardAlpha" dx="2" dy="3" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/><feColorMatrix in="shadow" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.7058824 0" result="colored"/><feBlend mode="normal" in="colored" in2="shape" result="inner0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#0080FF" transform="translate(9.8923 8.8923)" width="100" height="80"/>
</g></svg>
@@ -3,6 +3,6 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="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)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)">
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
</g></svg>
@@ -1,10 +0,0 @@
---
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>
@@ -1,10 +0,0 @@
---
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>
@@ -1,18 +0,0 @@
---
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>
@@ -1,14 +0,0 @@
---
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>
@@ -1,8 +0,0 @@
---
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>
@@ -1,8 +0,0 @@
---
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>
@@ -1,13 +0,0 @@
---
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>
@@ -1,12 +0,0 @@
---
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>
@@ -1,8 +0,0 @@
---
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>
@@ -1,8 +0,0 @@
---
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>
@@ -1,8 +0,0 @@
---
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>
+8 -869
View File
@@ -1,8 +1,8 @@
use super::fixtures::*;
use crate::shapes::{
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, Shadow,
ShadowStyle, SolidColor, StrokeCap, StrokeKind,
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, 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(#fx"),
"shape group must reference the effects filter: {svg}"
svg.contains("filter=\"url(#blur"),
"shape group must reference the blur 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(#fx"),
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
"hidden layer blur must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
@@ -185,7 +185,9 @@ 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(#fx").expect("group filter wrapper");
let filter_pos = svg
.find("filter=\"url(#blur")
.expect("group filter wrapper");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
@@ -194,843 +196,6 @@ 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();
@@ -2204,32 +1369,6 @@ 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),
+6 -19
View File
@@ -13,16 +13,10 @@ 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());
@@ -31,12 +25,14 @@ 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, draw_matrix)?;
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
}
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
@@ -48,7 +44,7 @@ pub(super) fn render_text_fill(
};
let canvas = builder.canvas();
canvas.save();
canvas.concat(&draw_matrix);
canvas.concat(&matrix);
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
canvas.restore();
}
@@ -100,7 +96,6 @@ 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(());
@@ -115,21 +110,13 @@ fn emit_text_image_fill(
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&draw_matrix);
cv.concat(&shape.centered_transform());
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(
builder,
shape,
image_fill,
shape.selrect(),
&href,
&clip_id,
draw_matrix,
);
emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
Ok(())
}
+34 -5
View File
@@ -3,9 +3,9 @@ use crate::{
error::Result,
math::Rect,
shapes::{
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset,
Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind,
TextContent, TextDecorationSegment,
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, Paragraph as TextParagraph,
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
TextDecorationSegment, VerticalAlign,
},
utils::{get_fallback_fonts, get_font_collection},
};
@@ -379,8 +379,11 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
.filter_map(|group| group.first())
.map(|p| p.height())
.sum();
let vertical_offset =
vertical_align_offset(selrect.height(), total_text_height, shape.vertical_align());
let vertical_offset = match shape.vertical_align() {
VerticalAlign::Center => (selrect.height() - total_text_height) / 2.0,
VerticalAlign::Bottom => selrect.height() - total_text_height,
_ => 0.0,
};
let mut y_accum = base_y + vertical_offset;
for (index, group) in paragraphs.iter().enumerate() {
@@ -1232,6 +1235,32 @@ fn calculate_decoration_metrics(
)
}
// How to use it?
// Type::Text(text_content) => {
// self.surfaces
// .apply_mut(&[SurfaceId::Fills, SurfaceId::Strokes], |s| {
// s.canvas().concat(&matrix);
// });
// let text_content = text_content.new_bounds(shape.selrect());
// let paths = text_content.get_paths(antialias);
// shadows::render_text_shadows(self, &shape, &paths, antialias);
// text::render(self, &paths, None, None);
// for stroke in shape.visible_strokes().rev() {
// shadows::render_text_path_stroke_shadows(
// self, &shape, &paths, stroke, antialias,
// );
// strokes::render_text_paths(self, &shape, stroke, &paths, None, None, antialias);
// shadows::render_text_path_stroke_inner_shadows(
// self, &shape, &paths, stroke, antialias,
// );
// }
// shadows::render_text_inner_shadows(self, &shape, &paths, antialias);
// }
#[cfg(test)]
mod tests {
use super::*;
+9 -9
View File
@@ -1,5 +1,5 @@
use crate::render::options::RenderOptions;
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
@@ -112,16 +112,16 @@ fn render_selection(
canvas.restore();
}
fn paragraphs_vertical_offset(
fn vertical_align_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
) -> f32 {
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
vertical_align_offset(
shape.selrect().height(),
total_height,
shape.vertical_align(),
)
match shape.vertical_align() {
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
VerticalAlign::Bottom => shape.selrect().height() - total_height,
_ => 0.0,
}
}
fn calculate_cursor_rect(
@@ -141,7 +141,7 @@ fn calculate_cursor_rect(
return None;
}
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.offset;
@@ -236,7 +236,7 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
let para_height = laid_out_para.height();
-4
View File
@@ -1535,10 +1535,6 @@ impl Shape {
return false;
}
if matches!(self.shape_type, Type::Group(_)) {
return false;
}
// If a frame shows overflow (clip_content=false) and its visible content exceeds the
// frame bounds, a cached crop anchored to the frame can easily become incorrect while
// moving (children can extend beyond selrect). Be conservative and render live.
+2 -2
View File
@@ -264,9 +264,9 @@ impl ToPath for Shape {
Type::SVGRaw(_) => Path::default(),
Type::Text(ref text) => {
let text_paths = TextPaths::new(text.new_bounds(self.selrect()));
let text_paths = TextPaths::new(text.clone());
let mut result = Path::default();
for path in text_paths.get_paths(self.vertical_align()) {
for (path, _) in text_paths.get_paths(true) {
result = join_paths(result, Path::from_skia_path(path));
}
+6 -36
View File
@@ -332,7 +332,7 @@ impl TextDecorationSegment {
}
}
pub fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
match valign {
VerticalAlign::Center => (container_h - content_h) / 2.0,
VerticalAlign::Bottom => container_h - content_h,
@@ -1859,8 +1859,11 @@ pub fn calculate_text_layout_data(
// 2. Position each built paragraph using the heights from step 1.
let total_text_height: f32 = paragraph_heights.iter().sum();
let vertical_offset =
vertical_align_offset(selrect_height, total_text_height, shape.vertical_align());
let vertical_offset = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - total_text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - total_text_height,
_ => 0.0,
};
let mut paragraph_layouts: Vec<ParagraphLayout> = Vec::new();
let mut y_accum = base_y + vertical_offset;
for (i, group_paragraphs) in built_groups.into_iter().enumerate() {
@@ -1968,39 +1971,6 @@ pub fn calculate_position_data(
mod tests {
use super::*;
#[test]
fn vertical_align_top_keeps_the_content_at_the_origin() {
assert_eq!(vertical_align_offset(200.0, 60.0, VerticalAlign::Top), 0.0);
}
#[test]
fn vertical_align_center_takes_half_the_slack() {
assert_eq!(
vertical_align_offset(200.0, 60.0, VerticalAlign::Center),
70.0
);
}
#[test]
fn vertical_align_bottom_takes_all_the_slack() {
assert_eq!(
vertical_align_offset(200.0, 60.0, VerticalAlign::Bottom),
140.0
);
}
#[test]
fn vertical_align_offset_is_negative_when_content_overflows() {
assert_eq!(
vertical_align_offset(60.0, 200.0, VerticalAlign::Center),
-70.0
);
assert_eq!(
vertical_align_offset(60.0, 200.0, VerticalAlign::Bottom),
-140.0
);
}
#[test]
fn capitalize_basic_words() {
assert_eq!(capitalize_words("hello world"), "Hello World");
+165 -56
View File
@@ -1,81 +1,190 @@
use crate::render::text::decoration_segments;
use crate::shapes::text::{vertical_align_offset, Paragraph, TextContent};
use crate::shapes::VerticalAlign;
use crate::get_resources;
use crate::shapes::text::TextContent;
use skia_safe::{
self as skia,
textlayout::{paragraph::VisitorInfo, Paragraph as SkiaParagraph},
Point,
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
};
use std::ops::Deref;
pub struct TextPaths(TextContent);
// Note: This class is not being currently used.
// It's an example of how to convert texts to paths
#[allow(dead_code)]
impl TextPaths {
pub fn new(text_content: TextContent) -> Self {
Self(text_content)
}
pub fn get_paths(&self, vertical_align: VerticalAlign) -> Vec<skia::Path> {
let layout_width = self.0.get_width(self.bounds.width());
let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None);
let mut paragraphs: Vec<SkiaParagraph> = paragraph_builders
.iter_mut()
.filter_map(|group| group.first_mut())
.map(|paragraph_builder| {
let mut paragraph = paragraph_builder.build();
paragraph.layout(layout_width);
paragraph
})
.collect();
let total_height: f32 = paragraphs.iter().map(|p| p.height()).sum();
let mut offset_y = self.bounds.y()
+ vertical_align_offset(self.bounds.height(), total_height, vertical_align);
pub fn get_paths(&self, antialias: bool) -> Vec<(skia::Path, skia::Paint)> {
let mut paths = Vec::new();
for (paragraph, text_paragraph) in paragraphs.iter_mut().zip(self.0.paragraphs()) {
let origin = Point::new(self.bounds.x(), offset_y);
Self::collect_paragraph_paths(paragraph, text_paragraph, origin, &mut paths);
offset_y += paragraph.height();
}
let mut offset_y = self.bounds.y();
let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None);
for paragraphs in paragraph_builders.iter_mut() {
for paragraph_builder in paragraphs.iter_mut() {
// 1. Get paragraph and set the width layout
let mut skia_paragraph = paragraph_builder.build();
let text = paragraph_builder.get_text();
let paragraph_width = self.bounds.width();
skia_paragraph.layout(paragraph_width);
let mut line_offset_y = offset_y;
// 2. Iterate through each line in the paragraph
for line_metrics in skia_paragraph.get_line_metrics() {
let line_baseline = line_metrics.baseline as f32;
let start = line_metrics.start_index;
let end = line_metrics.end_index;
// 3. Get styles present in line for each text span
let style_metrics = line_metrics.get_style_metrics(start..end);
let mut offset_x = 0.0;
for (i, (start_index, style_metric)) in style_metrics.iter().enumerate() {
let end_index = style_metrics.get(i + 1).map_or(end, |next| next.0);
let start_byte = text
.char_indices()
.nth(*start_index)
.map(|(i, _)| i)
.unwrap_or(0);
let end_byte = text
.char_indices()
.nth(end_index)
.map(|(i, _)| i)
.unwrap_or(text.len());
let span_text = &text[start_byte..end_byte];
let font = skia_paragraph.get_font_at(*start_index);
let blob_offset_x = self.bounds.x() + line_metrics.left as f32 + offset_x;
let blob_offset_y = line_offset_y;
// 4. Get the path for each text span
if let Some((text_path, paint)) = self.generate_text_path(
span_text,
&font,
blob_offset_x,
blob_offset_y,
style_metric,
antialias,
) {
let text_width = font.measure_text(span_text, None).0;
offset_x += text_width;
paths.push((text_path, paint));
}
}
line_offset_y = offset_y + line_baseline;
}
offset_y += skia_paragraph.height();
}
}
paths
}
fn collect_paragraph_paths(
paragraph: &mut SkiaParagraph,
text_paragraph: &Paragraph,
origin: Point,
paths: &mut Vec<skia::Path>,
) {
for deco in decoration_segments(paragraph, text_paragraph, origin.x, origin.y) {
let mut builder = skia::PathBuilder::new();
builder.add_rect(deco.rect(), None, None);
paths.push(builder.detach());
}
fn generate_text_path(
&self,
span_text: &str,
font: &skia::Font,
blob_offset_x: f32,
blob_offset_y: f32,
style_metric: &skia::textlayout::StyleMetrics,
antialias: bool,
) -> Option<(skia::Path, skia::Paint)> {
// Convert text to path, including text decoration
// TextBlob might be empty and, in this case, we return None
// This is used to avoid rendering empty paths, but we can
// revisit this logic later
if let Some((text_blob_path, text_blob_bounds)) =
Self::get_text_blob_path(span_text, font, blob_offset_x, blob_offset_y)
{
let text_width = font.measure_text(span_text, None).0;
paragraph.visit(|_: usize, info: Option<&VisitorInfo>| {
let Some(info) = info else {
return;
let decoration = style_metric.text_style.decoration();
let font_metrics = style_metric.font_metrics;
let blob_left = blob_offset_x;
let blob_top = blob_offset_y;
let blob_height = text_blob_bounds.height();
let text_path = {
let mut pb = skia::PathBuilder::new_path(&text_blob_path);
if let Some(decoration_rect) = self.calculate_text_decoration_rect(
decoration.ty,
font_metrics,
blob_left,
blob_top,
text_width,
blob_height,
) {
pb.add_rect(decoration_rect, None, None);
}
pb.detach()
};
let font = info.font();
let run_origin = origin + info.origin();
let mut builder = skia::PathBuilder::new();
let mut has_glyphs = false;
let mut paint = style_metric.text_style.foreground();
paint.set_anti_alias(antialias);
for (glyph, position) in info.glyphs().iter().zip(info.positions().iter()) {
let Some(glyph_path) = font.get_path(*glyph) else {
continue;
};
builder.add_path(&glyph_path.with_offset(run_origin + *position));
has_glyphs = true;
}
return Some((text_path, paint));
}
None
}
if has_glyphs {
paths.push(builder.detach());
fn calculate_text_decoration_rect(
&self,
decoration: skia::textlayout::TextDecoration,
font_metrics: FontMetrics,
blob_left: f32,
blob_offset_y: f32,
text_width: f32,
blob_height: f32,
) -> Option<Rect> {
match decoration {
skia::textlayout::TextDecoration::LINE_THROUGH => {
let underline_thickness = font_metrics.underline_thickness().unwrap_or(0.0);
let underline_position = blob_height / 2.0;
Some(Rect::new(
blob_left,
blob_offset_y + underline_position - underline_thickness / 2.0,
blob_left + text_width,
blob_offset_y + underline_position + underline_thickness / 2.0,
))
}
});
skia::textlayout::TextDecoration::UNDERLINE => {
let underline_thickness = font_metrics.underline_thickness().unwrap_or(0.0);
let underline_position = blob_height - underline_thickness;
Some(Rect::new(
blob_left,
blob_offset_y + underline_position - underline_thickness / 2.0,
blob_left + text_width,
blob_offset_y + underline_position + underline_thickness / 2.0,
))
}
_ => None,
}
}
fn get_text_blob_path(
span_text: &str,
font: &skia::Font,
blob_offset_x: f32,
blob_offset_y: f32,
) -> Option<(skia::Path, skia::Rect)> {
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
let emoji_font = get_resources().fonts.get_emoji_font(font.size());
let use_font = emoji_font.as_ref().unwrap_or(font);
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
let path = SkiaParagraph::get_path(&mut text_blob);
let d = Point::new(blob_offset_x, blob_offset_y);
let offset_path = path.with_offset(d);
let bounds = text_blob.bounds();
return Some((offset_path, *bounds));
}
None
}
}
+4 -55
View File
@@ -5,7 +5,6 @@ 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.
@@ -39,20 +38,6 @@ 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
# ═══════════════════════════════════════════════════════════════════════════════
@@ -108,11 +93,11 @@ def check_regex(message):
def check_subject_length(message):
"""Subject line must be ≤ 70 characters."""
"""Subject line must be ≤ 90 characters."""
first_line = message.split("\n")[0]
if len(first_line) > 70:
if len(first_line) > 90:
return False, (
f"Subject line exceeds 70 characters ({len(first_line)} chars):\n"
f"Subject line exceeds 90 characters ({len(first_line)} chars):\n"
f" {first_line}"
)
return True, None
@@ -163,41 +148,6 @@ 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:
@@ -229,11 +179,10 @@ def main():
validators = [
("Regex pattern", check_regex),
("Subject ≤ 70 chars", check_subject_length),
("Subject ≤ 90 chars", check_subject_length),
("No trailing period in subject", check_subject_no_trailing_dot),
("Subject capitalized", check_subject_capitalized),
("Blank line after subject", check_body_blank_line),
("Body wrapped at 76 chars", check_body_line_length),
]
all_ok = True
-136
View File
@@ -1,136 +0,0 @@
#!/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)