diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..b83d5a8f6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: CI
+
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
+concurrency:
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ gate:
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # Same craft-fonts commit as release.yml.
+ craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+ timeout-minutes: 90
+ packages: >-
+ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+ libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+ libgl1-mesa-dev
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 6ee868152..0f91ed6d0 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -1,25 +1,19 @@
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
- branches: [main]
- paths:
- - ".github/workflows/freebsd.yml"
- - "packaging/freebsd/**"
- - "packaging/env.sh"
- - "Cargo.toml"
- - "Cargo.lock"
- - "crates/**"
+ branches: [release]
pull_request:
- branches: [main]
paths:
- - ".github/workflows/freebsd.yml"
- - "packaging/freebsd/**"
- - "packaging/env.sh"
- - "Cargo.toml"
- - "Cargo.lock"
- - "crates/**"
+ - .github/workflows/freebsd.yml
+ - packaging/freebsd/**
+ - packaging/env.sh
+ schedule:
+ - cron: "17 4 * * *"
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 54a8e53fb..5fd996c43 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,14 +13,56 @@ on:
default: ""
permissions:
- contents: write
+ contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
+ git-sources:
+ name: Git dependencies come from storytold
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Git dependencies come from storytold
+ run: |
+ set -euo pipefail
+ test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
+ # Source replacement, patches and path overrides in cargo config never show up
+ # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
+ # Cargo still reads the legacy .cargo/config.
+ python3 - <<'PY'
+ import os, sys, tomllib
+ for path in (".cargo/config", ".cargo/config.toml"):
+ if not os.path.exists(path):
+ continue
+ config = tomllib.load(open(path, "rb"))
+ replaced = [name for name, source in config.get("source", {}).items()
+ if isinstance(source, dict) and "replace-with" in source]
+ if replaced or "patch" in config or "paths" in config:
+ print(f"{path} replaces, patches or overrides a source")
+ sys.exit(1)
+ PY
+ # Fails when Cargo.lock is out of date with the manifests.
+ cargo metadata --locked --format-version 1 > /dev/null
+ python3 - <<'PY'
+ import sys, tomllib
+ lock = tomllib.load(open("Cargo.lock", "rb"))
+ bad = sorted({p["source"] for p in lock.get("package", [])
+ if p.get("source", "").startswith("git+")
+ and not p["source"].startswith("git+https://github.com/storytold/")})
+ if bad:
+ print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
+ sys.exit(1)
+ PY
+
release:
+ needs: git-sources
+ permissions:
+ contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..e78fe1ea3
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index f3ba1eeaa..2dc19466c 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -5,23 +5,17 @@
# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
+# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
+# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
+ schedule:
+ - cron: "3 5 * * *"
push:
- branches: [main]
- paths:
- - ".github/workflows/windows-arm64.yml"
- - "packaging/windows/**"
- - "Cargo.toml"
- - "Cargo.lock"
- - "crates/**"
+ branches: [release]
pull_request:
- branches: [main]
paths:
- - ".github/workflows/windows-arm64.yml"
- - "packaging/windows/**"
- - "Cargo.toml"
- - "Cargo.lock"
- - "crates/**"
+ - .github/workflows/windows-arm64.yml
+ - packaging/windows/**
workflow_dispatch:
concurrency:
diff --git a/AGENTS.md b/AGENTS.md
index 0b6171cd3..61f141079 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
diff --git a/ASSETS.md b/ASSETS.md
index f8121c35b..02c6d3b87 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -169,6 +169,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-para-bullets.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three dots, each before a line | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/CLAUDE.md b/CLAUDE.md
index 899f22e39..cbf9de129 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
diff --git a/Cargo.lock b/Cargo.lock
index ffd1b3d45..7c6755fa7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -64,7 +64,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@@ -129,7 +129,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
- "cpufeatures",
+ "cpufeatures 0.2.17",
]
[[package]]
@@ -208,21 +208,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
-version = "3.6.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -344,13 +344,13 @@ dependencies = [
[[package]]
name = "async-recursion"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.9",
]
[[package]]
@@ -385,7 +385,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -449,15 +449,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -468,10 +459,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -479,6 +473,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -506,7 +510,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
dependencies = [
- "objc2 0.5.2",
+ "objc2 0.5.3",
]
[[package]]
@@ -515,7 +519,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -531,6 +535,30 @@ dependencies = [
"piper",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
[[package]]
name = "brotli"
version = "8.0.4"
@@ -575,7 +603,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -606,14 +634,13 @@ dependencies = [
[[package]]
name = "calloop"
-version = "0.14.4"
+version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
- "slab",
"tracing",
]
@@ -635,7 +662,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
- "calloop 0.14.4",
+ "calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
@@ -643,9 +670,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.5.1"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -674,6 +701,26 @@ dependencies = [
"libc",
]
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -731,9 +778,9 @@ dependencies = [
[[package]]
name = "combine"
-version = "4.6.8"
+version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [
"bytes",
"memchr",
@@ -788,6 +835,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -806,6 +859,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "craft-docpath"
version = "0.1.0"
@@ -839,11 +901,20 @@ dependencies = [
"serde",
]
+[[package]]
+name = "craft-window"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b"
+dependencies = [
+ "egui",
+ "wayland-client",
+]
+
[[package]]
name = "crc32fast"
-version = "1.5.2"
+version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
@@ -948,7 +1019,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -959,7 +1030,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1021,7 +1092,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
@@ -1089,7 +1160,7 @@ dependencies = [
"bytemuck",
"egui",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"profiling",
@@ -1151,9 +1222,9 @@ dependencies = [
[[package]]
name = "either"
-version = "1.18.0"
+version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
@@ -1222,7 +1293,7 @@ dependencies = [
"ecolor",
"emath",
"epaint_default_fonts",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1244,9 +1315,9 @@ checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
[[package]]
name = "equivalent"
-version = "1.0.2"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -1322,9 +1393,9 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "find-msvc-tools"
-version = "0.1.14"
+version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fixedbitset"
@@ -1381,9 +1452,9 @@ dependencies = [
[[package]]
name = "font-types"
-version = "0.12.5"
+version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
+checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
dependencies = [
"bytemuck",
]
@@ -1417,7 +1488,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1468,7 +1539,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1540,6 +1611,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
+ "rand_core",
]
[[package]]
@@ -1622,7 +1694,7 @@ dependencies = [
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2286,9 +2358,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libloading"
@@ -2315,7 +2387,7 @@ dependencies = [
"bitflags 2.13.2",
"libc",
"plain",
- "redox_syscall 0.9.4",
+ "redox_syscall 0.9.5",
]
[[package]]
@@ -2357,9 +2429,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
-version = "1.0.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]]
name = "lock_api"
@@ -2449,7 +2521,7 @@ dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2581,9 +2653,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
[[package]]
name = "objc2"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b"
dependencies = [
"objc-sys",
"objc2-encode",
@@ -2591,9 +2663,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -2607,7 +2679,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"libc",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-core-data",
"objc2-core-image",
"objc2-foundation 0.2.2",
@@ -2622,7 +2694,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -2636,7 +2708,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-core-location",
"objc2-foundation 0.2.2",
]
@@ -2648,7 +2720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2660,7 +2732,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2672,7 +2744,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -2683,7 +2755,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -2695,7 +2767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-metal 0.2.2",
]
@@ -2707,7 +2779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-contacts",
"objc2-foundation 0.2.2",
]
@@ -2737,7 +2809,7 @@ dependencies = [
"block2 0.5.1",
"dispatch",
"libc",
- "objc2 0.5.2",
+ "objc2 0.5.3",
]
[[package]]
@@ -2748,7 +2820,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -2759,7 +2831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -2770,7 +2842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@@ -2783,7 +2855,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2795,7 +2867,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -2807,7 +2879,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-metal 0.2.2",
]
@@ -2819,7 +2891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -2832,7 +2904,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
dependencies = [
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2844,7 +2916,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-image",
@@ -2864,7 +2936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -2876,7 +2948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2888,7 +2960,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-core-location",
"objc2-foundation 0.2.2",
]
@@ -3213,15 +3285,6 @@ dependencies = [
"zerovec",
]
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
[[package]]
name = "presser"
version = "0.3.1"
@@ -3254,16 +3317,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
"rand",
- "rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -3330,38 +3394,25 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
-version = "0.9.5"
+version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
- "rand_chacha",
- "rand_core",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
+ "getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
-version = "0.9.5"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
"rand_core",
]
@@ -3384,7 +3435,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -3427,7 +3478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"once_cell",
]
@@ -3438,7 +3489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
dependencies = [
"bytemuck",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"once_cell",
]
@@ -3449,7 +3500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
- "font-types 0.12.5",
+ "font-types 0.12.7",
"once_cell",
]
@@ -3473,9 +3524,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.9.4"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
+checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
dependencies = [
"bitflags 2.13.2",
]
@@ -3517,7 +3568,7 @@ dependencies = [
"js-sys",
"libc",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -3726,14 +3777,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -3750,14 +3801,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "serde_spanned"
-version = "1.1.1"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
@@ -3769,7 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -3873,9 +3924,9 @@ dependencies = [
[[package]]
name = "smallvec"
-version = "1.16.2"
+version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
dependencies = [
"serde",
]
@@ -3912,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
- "calloop 0.14.4",
+ "calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
@@ -4064,9 +4115,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "3.0.6"
+version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -4081,7 +4132,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -4143,7 +4194,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -4225,18 +4276,18 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "toml"
-version = "1.1.6+spec-1.1.0"
+version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
@@ -4249,18 +4300,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
-version = "0.25.15+spec-1.1.0"
+version = "0.25.17+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"toml_datetime",
@@ -4270,18 +4321,18 @@ dependencies = [
[[package]]
name = "toml_parser"
-version = "1.1.3+spec-1.1.0"
+version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
-version = "1.1.2+spec-1.1.0"
+version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tracing"
@@ -4399,15 +4450,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
-version = "1.0.26"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-normalization"
-version = "0.1.25"
+version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
@@ -4512,9 +4563,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.26.1"
+version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"js-sys",
"serde_core",
@@ -4523,18 +4574,20 @@ dependencies = [
[[package]]
name = "vectorcraft"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"arboard",
"block2 0.6.2",
+ "chrono",
"clipboard-win",
"craft-print",
+ "craft-window",
"eframe",
"egui",
"image",
"log",
"muda",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-text",
@@ -4555,12 +4608,13 @@ dependencies = [
"winit",
"winresource",
"winsafe",
+ "x11rb",
"zbus",
]
[[package]]
name = "vectorcraft-affinity"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4571,7 +4625,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4583,7 +4637,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4599,7 +4653,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4621,7 +4675,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4633,8 +4687,9 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
+ "chrono",
"hayro-ccitt",
"image",
"jpeg-encoder",
@@ -4651,7 +4706,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4667,7 +4722,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"crc32fast",
@@ -4705,13 +4760,14 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"fax",
"flate2",
"hayro-ccitt",
"image",
"kurbo 0.13.1",
+ "memchr",
"ruzstd",
"serde_json",
"vectorcraft-brush",
@@ -4728,7 +4784,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"flate2",
"proptest",
@@ -4743,7 +4799,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4755,7 +4811,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"image",
"log",
@@ -4774,7 +4830,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4788,7 +4844,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4800,9 +4856,10 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"aes",
+ "chrono",
"craft-pdf",
"flate2",
"image",
@@ -4830,7 +4887,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"serde",
"serde_json",
@@ -4844,7 +4901,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"flate2",
"gif",
@@ -4871,14 +4928,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -4903,7 +4960,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"fax",
"image",
@@ -4921,7 +4978,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4935,13 +4992,14 @@ dependencies = [
"unicode-segmentation",
"vectorcraft-doc",
"vectorcraft-geom",
+ "vello_cpu 0.2.0",
"windows-registry",
"write-fonts",
]
[[package]]
name = "vectorcraft-three-d"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4949,7 +5007,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4965,7 +5023,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4977,14 +5035,16 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
+ "bytemuck",
"craft-ui",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
+ "rustix 1.1.5",
"serde",
"serde_json",
"toml",
@@ -5011,7 +5071,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"eframe",
"egui",
@@ -5165,7 +5225,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"wasm-bindgen-shared",
]
@@ -5180,12 +5240,12 @@ dependencies = [
[[package]]
name = "wasm-encoder"
-version = "0.261.0"
+version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
+checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
- "wasmparser 0.261.0",
+ "wasmparser 0.262.0",
]
[[package]]
@@ -5240,9 +5300,9 @@ dependencies = [
[[package]]
name = "wasmparser"
-version = "0.261.0"
+version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
+checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags 2.13.2",
"indexmap",
@@ -5251,9 +5311,9 @@ dependencies = [
[[package]]
name = "wast"
-version = "261.0.0"
+version = "262.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
+checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23"
dependencies = [
"bumpalo",
"leb128fmt",
@@ -5264,9 +5324,9 @@ dependencies = [
[[package]]
name = "wat"
-version = "1.261.0"
+version = "1.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
+checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1"
dependencies = [
"wast",
]
@@ -5435,7 +5495,7 @@ dependencies = [
"jni",
"log",
"ndk-context",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
@@ -5576,7 +5636,7 @@ dependencies = [
"naga",
"naga-types",
"ndk-sys",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -5758,7 +5818,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
@@ -5767,16 +5827,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -5794,31 +5845,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -5836,96 +5870,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
@@ -5949,7 +5935,7 @@ dependencies = [
"libc",
"memmap2",
"ndk",
- "objc2 0.5.2",
+ "objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
@@ -5989,9 +5975,9 @@ dependencies = [
[[package]]
name = "winresource"
-version = "0.1.31"
+version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -6123,7 +6109,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.14.0"
+version = "0.16.0"
dependencies = [
"serde_json",
]
@@ -6141,13 +6127,13 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
@@ -6219,7 +6205,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -6227,9 +6213,9 @@ dependencies = [
[[package]]
name = "zbus_names"
-version = "4.3.4"
+version = "4.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
dependencies = [
"serde",
"winnow",
@@ -6259,18 +6245,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.59"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.59"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
@@ -6294,7 +6280,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
@@ -6330,7 +6316,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -6362,9 +6348,9 @@ dependencies = [
[[package]]
name = "zvariant"
-version = "5.15.0"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
dependencies = [
"endi",
"enumflags2",
@@ -6377,14 +6363,14 @@ dependencies = [
[[package]]
name = "zvariant_derive"
-version = "5.15.0"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"zvariant_utils",
]
@@ -6397,6 +6383,6 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn 3.0.6",
+ "syn 3.0.9",
"winnow",
]
diff --git a/Cargo.toml b/Cargo.toml
index 30eff51df..f45b0b047 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
-version = "0.14.0"
+version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -15,6 +15,9 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b77
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
+# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
+craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -22,8 +25,9 @@ vectorcraft-doc = { path = "crates/doc" }
vectorcraft-pathops = { path = "crates/pathops" }
vectorcraft-brush = { path = "crates/brush" }
vectorcraft-trace = { path = "crates/trace" }
-# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
-# library crate never forces them on an embedder that supplies its fonts at run time.
+# Without its default `bundled-fonts`: a crate that wants the fonts asks for them (the apps and
+# vectorcraft-testkit through the engine's `bundled-fonts`), so a library crate never forces them on
+# an embedder that supplies its fonts at run time.
vectorcraft-text = { path = "crates/text", default-features = false }
vectorcraft-effects = { path = "crates/effects" }
vectorcraft-three-d = { path = "crates/three-d" }
@@ -32,7 +36,9 @@ vectorcraft-svg = { path = "crates/svg" }
vectorcraft-pdf = { path = "crates/pdf" }
vectorcraft-format = { path = "crates/format" }
vectorcraft-tools = { path = "crates/tools" }
-vectorcraft-engine = { path = "crates/engine" }
+# Without its default `bundled-fonts`, as vectorcraft-text: each crate that wants the fonts asks for
+# them, so an embedder can build without them (docs/development.md › Leaving out the bundled fonts).
+vectorcraft-engine = { path = "crates/engine", default-features = false }
vectorcraft-ui-egui = { path = "crates/ui-egui" }
vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" }
@@ -50,6 +56,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
crc32fast = "1.5"
# tests: a TCP port bound without listening (connections to it are refused)
@@ -83,6 +91,8 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
hayro-ccitt = "0.3"
+# Substring search for the EPS reader's PostScript `search`.
+memchr = "2"
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
@@ -114,11 +124,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
+# Workspace crates keep line tables, so panics and backtraces name file and line; dependencies
+# carry no debug info. Full debug info would make the dev target dir several times larger.
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.release]
lto = "thin"
diff --git a/README.md b/README.md
index 294824164..d85cef156 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
VectorCraft
@@ -202,10 +206,10 @@ the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayl
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
-pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
-preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
-attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
-[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
+pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse and the app reads its
+pressure: set the same preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use
+XWayland only when a pen is attached as VectorCraft starts, and restart; or start the app with
+`WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
@@ -238,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
-and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
-import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
+and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
+pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in
+(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
@@ -253,7 +258,7 @@ The scores are self-assessed from our code against public documentation;
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
-- most of the raster effects (11 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation and Stamp under Sketch) and the Effect Gallery;
+- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
diff --git a/ROADMAP.md b/ROADMAP.md
index 8ae1cf14a..28c15f326 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
-| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 46/57, 3D 4/4 (initial types, no materials) |
+| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -75,8 +75,8 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 |
| Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
-| Live vector effects | 85% | 6–10 |
-| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 81% | 8–14 |
+| Live vector effects | 85% | 4–8 |
+| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 |
| 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@@ -123,21 +123,34 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
-5. **Raster effects** (G2) alongside: the 11 remaining filters (7 Artistic, 3 Sketch, 1 more) and the Effect Gallery, 8–14 h.
+5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
+- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
+- 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn.
+- 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph.
+- 2026-10-22: The Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged.
+- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
+
+- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
+- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
+- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.
+
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
| Date | What landed |
|---|---|
+| 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. |
+| 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. |
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
+| 2026-10-21 | Affinity import (AF.5), brought over from DesignCraft's (storytold/designcraft#291): master pages show on the pages that use them (a locked layer at the bottom); tables open as a text frame per cell and a line per edge; each paragraph keeps its alignment, indents, spacing and leading; frame text takes its text scale, not its frame's; drop shadows and outer glows become live effects, outlines strokes, colour and gradient overlays are baked into the paint; embedded Affinity documents open as editable content, and their artboards no longer become artboards; large pixel layers come in at a reduced resolution; text placed as Affinity places it (lines of differently aligned paragraphs, a frame's first baseline at cap height, last lines whose descenders hang out of the frame), underlines and strikethrough, line breaks inside paragraphs, table cells taller than their row, no stroke on shapes whose stroke style is None, CMYK pixel layers (phone photos in CMYK documents) shown through the document's own CMYK profile instead of the profile-free formula and its blue cast, outlines on stroked shapes. Headline numbers unchanged. Hostile files stay bounded (one budget for the whole read, embedded documents included; overlay work; master page copies), covered by the import fuzzer and limit tests. |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from the out port (#2713, part of #2572), a panel per swatch library with Persistent (#2714, #2603), installed plug-ins kept across restarts (#2695, #2541). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
@@ -185,6 +198,14 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
+| 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. |
+| 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. |
+| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. |
+| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. |
+| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. |
+| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). |
+| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
+| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml
index c31998d00..3827ab1eb 100644
--- a/apps/vectorcraft-cli/Cargo.toml
+++ b/apps/vectorcraft-cli/Cargo.toml
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
-vectorcraft-engine = { workspace = true }
+vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-format = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs
index 96cc2d0fb..10a859ac5 100644
--- a/apps/vectorcraft-cli/src/main.rs
+++ b/apps/vectorcraft-cli/src/main.rs
@@ -1,8 +1,8 @@
//! `vectorcraft-cli`: VectorCraft from the command line.
//!
//! ```text
-//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
-//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
+//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
+//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
@@ -68,19 +68,24 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
USAGE:
- vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
+ vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
(vectorcraft --control 7979), falling back to a headless in-process session.
--automation-read-root / --automation-write-root confine the files the agent's commands read
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
- DIR, links followed; a root left out grants none of its access. They imply --headless: a
- running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
+ DIR, links followed; each may be given more than once, a path inside any of its folders being
+ allowed, and a kind left out grants none of its access. They imply --headless: a running app
+ enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
- vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
+ vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
+ --commands reads steps from FILE, or from standard input for -, where it stands among the
+ --cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
+ \"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
+ Errors name the step, counting from 0 as \"$N\" references do.
vectorcraft-cli commands
Print the command catalogue as JSON.
@@ -96,8 +101,9 @@ USAGE:
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
precision (1–7, default 3). OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
- --replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
- draws differently from its PDF part (which can be older), as long as it can be read. The
+ --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
+ even where that draws differently from its PDF part or page (which can be older), as long as
+ it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
per artboard, OUT-.ai when there are several; default pdf, PDF-compatible), with
@@ -176,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless {
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option = None;
let mut headless = false;
- let (mut read_root, mut write_root): (Option, Option) = (None, None);
+ let (mut read_roots, mut write_roots): (Vec, Vec) = (vec![], vec![]);
let mut it = args.iter();
while let Some(a) = it.next() {
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
@@ -189,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--headless" => headless = true,
"--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
- let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
- if slot.replace(dir).is_some() {
- return Err(format!("{flag} is given twice: it takes one folder"));
- }
+ if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
}
other => return Err(format!("unknown mcp option `{other}`")),
}
@@ -200,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
if headless && connect.is_some() {
return Err("use either --connect or --headless".into());
}
- let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
+ let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
+ let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
+ let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control --automation-read-root --automation-write-root `"
.into());
@@ -208,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box = if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
- let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
- eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
+ eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(with_stores(Headless::with_document()))
@@ -337,28 +341,127 @@ enum Step {
Export(String),
}
+/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
+const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
+/// Most steps one `run --commands` takes.
+const MAX_COMMANDS_STEPS: usize = 100_000;
+
+/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
+/// [`MAX_COMMANDS_BYTES`].
+fn read_commands(source: &str) -> Result {
+ use std::io::Read;
+ let mut bytes = vec![];
+ let read = if source == "-" {
+ std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
+ } else {
+ std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
+ };
+ read.map_err(|e| format!("--commands {source}: {e}"))?;
+ if bytes.len() as u64 > MAX_COMMANDS_BYTES {
+ return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
+ }
+ String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
+}
+
+/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
+/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
+/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
+/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
+fn parse_commands(source: &str, text: &str, first: usize) -> Result, String> {
+ // Notepad on Windows starts UTF-8 files with a byte order mark.
+ let text = text.strip_prefix('\u{feff}').unwrap_or(text);
+ let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
+ // Each entry with where it is in the text: its line for JSON Lines.
+ let mut entries: Vec<(Option, Value)> = vec![];
+ if text.trim_start().starts_with('[') {
+ let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
+ let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
+ if items.len() > MAX_COMMANDS_STEPS {
+ return Err(too_many());
+ }
+ entries.extend(items.into_iter().map(|v| (None, v)));
+ } else {
+ for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
+ if entries.len() == MAX_COMMANDS_STEPS {
+ return Err(too_many());
+ }
+ let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
+ entries.push((Some(n + 1), v));
+ }
+ }
+ entries
+ .into_iter()
+ .enumerate()
+ .map(|(k, (line, v))| {
+ command_step(v).map_err(|e| {
+ let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
+ format!("--commands {source}: step {} ({place}): {e}", first + k)
+ })
+ })
+ .collect()
+}
+
+/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
+fn command_step(v: Value) -> Result {
+ let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
+ let id = match (o.remove("command"), o.remove("id")) {
+ (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
+ (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
+ (None, None) => return Err("no `command`".into()),
+ _ => return Err("`command` must be a string, a command id".into()),
+ };
+ let params = match o.remove("params") {
+ None | Some(Value::Null) => json!({}),
+ Some(p @ Value::Object(_)) => p,
+ Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
+ };
+ if let Some(key) = o.keys().next() {
+ return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
+ }
+ Ok(Step::Cmd(id, params))
+}
+
fn run(args: &[String]) -> Result<(), String> {
let mut input: Option = None;
let mut steps: Vec = vec![];
+ // The step of the `--cmd` just given, which a `--params` sets.
+ let mut params_for: Option = None;
+ let mut stdin_read = false;
let mut scale = 1.0;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
match a.as_str() {
"--in" | "-i" => input = Some(val("a file")?),
- "--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
+ "--cmd" | "-c" => {
+ steps.push(Step::Cmd(val("a command id")?, json!({})));
+ params_for = Some(steps.len() - 1);
+ }
"--params" | "-p" => {
let raw = val("JSON")?;
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
if !p.is_object() {
return Err(format!("--params must be a JSON object, got {raw}"));
}
- match steps.last_mut() {
+ match params_for.and_then(|i| steps.get_mut(i)) {
Some(Step::Cmd(_, params)) => *params = p,
_ => return Err("--params must follow a --cmd".into()),
}
}
- "--export" | "-o" => steps.push(Step::Export(val("a file")?)),
+ "--commands" => {
+ let source = val("a file, or - for standard input")?;
+ if source == "-" && std::mem::replace(&mut stdin_read, true) {
+ return Err("--commands - is given twice: standard input is read once".into());
+ }
+ let text = read_commands(&source)?;
+ let read = parse_commands(&source, &text, steps.len())?;
+ steps.extend(read);
+ params_for = None;
+ }
+ "--export" | "-o" => {
+ steps.push(Step::Export(val("a file")?));
+ params_for = None;
+ }
"--scale" | "-s" => {
let raw = val("a number")?;
scale = raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?;
@@ -393,10 +496,12 @@ fn run(args: &[String]) -> Result<(), String> {
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
let mut results = vec![];
for step in steps {
+ // The step's index, as `"$N"` names its result.
+ let n = results.len();
let r = match step {
Step::Cmd(id, params) => {
- let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("{id}: {e}"))?;
- let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
+ let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
+ let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
emit(json!({"step": "cmd", "command": id, "result": r}))?;
r
}
@@ -406,7 +511,7 @@ fn run(args: &[String]) -> Result<(), String> {
} else {
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
};
- let r = r.map_err(|e| format!("export {path}: {e}"))?;
+ let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs
index 3aa1db6a4..d760cfefe 100644
--- a/apps/vectorcraft-cli/src/perf.rs
+++ b/apps/vectorcraft-cli/src/perf.rs
@@ -23,13 +23,16 @@ impl Rng {
}
}
+/// The synthetic document's page, in points.
+const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0);
+
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
pub fn synthetic(n: usize) -> Result {
- let mut d = Document::new(1600.0, 1200.0);
+ let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1);
let l = d.layers.first().map(|l| l.id);
let mut r = Rng(42);
for i in 0..n {
- let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
+ let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0);
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
let id = d.alloc_id();
let mut node = match i % 3 {
@@ -282,7 +285,7 @@ fn deep(n: usize) -> Result<(), String> {
let key = format!("photo {i}");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo));
let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0);
- let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() };
+ let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default(), pixels: None };
let id = d.alloc_id();
d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?;
}
@@ -296,6 +299,27 @@ fn deep(n: usize) -> Result<(), String> {
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
note("after rendering the photos with 3 renderers");
}
+ // An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the
+ // importer's passes over the text before usvg reads it, most of which is the data URL. The
+ // picture is the synthetic page drawn at 1.25x (about 2 MB as a PNG; the row names its size);
+ // the hidden rectangle is a marker, so the prepass walks the tree as it does for a file that
+ // has one, instead of returning at its first check.
+ {
+ let k = 1.25;
+ let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32);
+ let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?;
+ let data = vectorcraft_svg::base64_encode(&photo);
+ let svg = format!(
+ r#" "#
+ );
+ // One import checked first, so a failure is reported rather than timed; the same text
+ // imports the same way each time. Its document is kept for the memory reading.
+ let imported = vectorcraft_svg::import_with_report(&svg).map_err(|e| e.to_string())?;
+ let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg)));
+ rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms));
+ note("after the import of an embedded picture");
+ drop(imported);
+ }
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
let doc = synthetic(n)?;
diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs
similarity index 72%
rename from apps/vectorcraft-cli/tests/cli.rs
rename to apps/vectorcraft-cli/tests/it/cli.rs
index b376a139f..e06444b39 100644
--- a/apps/vectorcraft-cli/tests/cli.rs
+++ b/apps/vectorcraft-cli/tests/it/cli.rs
@@ -1,5 +1,3 @@
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
@@ -84,11 +82,125 @@ fn scale_sizes_raster_exports_only() {
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
assert!(!out.status.success());
- assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
+ let stderr = String::from_utf8_lossy(&out.stderr).to_string();
+ assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success());
}
+/// The JSON lines `run` printed.
+fn json_lines(stdout: &[u8]) -> Vec {
+ String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
+}
+
+/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
+/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
+/// placed file's `dataBase64` longer than a Windows command line holds fits.
+#[test]
+fn run_reads_steps_from_a_commands_file() {
+ let steps = tmp("steps.json");
+ let svg = tmp("steps.svg");
+ // Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
+ let padding = "x".repeat(40_000);
+ let placed = format!(
+ r##" "##
+ );
+ let data = vectorcraft_format::base64_encode(placed.as_bytes());
+ assert!(data.len() > 32_767);
+ let file = json!([
+ {"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
+ {"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
+ {"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
+ ]);
+ std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
+ let out = Command::new(BIN)
+ .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
+ .args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let lines = json_lines(&out.stdout);
+ let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
+ assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
+ // `$2` in the file and in the `--cmd` after it is the placed art.
+ assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
+ let written = std::fs::read_to_string(&svg).unwrap();
+ assert!(written.contains("118833") && written.contains("barcode"), "{written}");
+}
+
+/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
+/// CRLF line ends are fine, and the first step may be `file.new`.
+#[test]
+fn run_reads_json_lines_from_standard_input() {
+ let svg = tmp("stdin.svg");
+ let mut child = Command::new(BIN)
+ .args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
+ child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
+ let out = child.wait_with_output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let lines = json_lines(&out.stdout);
+ assert_eq!(lines.len(), 4);
+ assert_eq!(lines[0]["command"], "file.new");
+ assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
+}
+
+/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
+/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
+/// refused; a step that fails names its number.
+#[test]
+fn run_refuses_malformed_commands_naming_the_step() {
+ let fails = |args: &[&str]| {
+ let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
+ assert!(!out.status.success(), "{args:?}");
+ (String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
+ };
+ let bad = |name: &str, text: &[u8], wanted: &[&str]| {
+ let path = tmp(name);
+ std::fs::write(&path, text).unwrap();
+ let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
+ assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
+ assert_eq!(printed, 0, "{name}: nothing runs");
+ };
+ bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
+ bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
+ bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
+ bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
+ bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
+ bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
+ bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
+ bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]);
+ bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
+ bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
+ let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
+ bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
+ // Past 64 MiB (a sparse file: nothing is written).
+ let huge = tmp("huge.json");
+ std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
+ let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
+ assert!(e.contains("more than 64 MiB"), "{e}");
+ let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
+ assert!(e.contains("missing.json"), "{e}");
+ // `--params` sets the `--cmd` just before it, never a step of the file.
+ let ok = tmp("ok.json");
+ std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
+ let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
+ assert!(e.contains("--params must follow a --cmd"), "{e}");
+ let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
+ assert!(e.contains("standard input is read once"), "{e}");
+ // A step that fails is named by its number, after the steps before it ran.
+ let failing = tmp("failing.json");
+ std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
+ let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
+ assert!(e.contains("step 2 (nope.nothing)"), "{e}");
+ assert_eq!(printed, 2);
+}
+
#[test]
fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@@ -130,6 +242,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
automation_roots(&empty_dir(dir).join("roots"));
}
+/// #3546: each root flag may be given more than once: a path inside any of the read roots opens,
+/// one inside any of the write roots is written, and everything else, a write root's sibling read
+/// root included, is refused.
+#[test]
+fn mcp_automation_roots_take_several_folders() {
+ let base = tmp("several-roots");
+ for d in ["in", "in2", "out", "out2", "outside"] {
+ std::fs::create_dir_all(base.join(d)).unwrap();
+ }
+ let svg = r#" "#;
+ for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] {
+ std::fs::write(base.join(f), svg).unwrap();
+ }
+ let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
+ let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
+ let mut child = Command::new(BIN)
+ .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
+ .args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
+ let calls = [
+ (call(1, "open_file", json!({"path": file("in", "a.svg")})), false),
+ (call(2, "open_file", json!({"path": file("in2", "b.svg")})), false),
+ (call(3, "open_file", json!({"path": file("outside", "c.svg")})), true),
+ (call(4, "export", json!({"path": file("out", "a.png")})), false),
+ (call(5, "export", json!({"path": file("out2", "b.png")})), false),
+ (call(6, "export", json!({"path": file("outside", "c.png")})), true),
+ (call(7, "export", json!({"path": file("in2", "d.png")})), true),
+ (call(8, "open_file", json!({"path": file("out2", "b.png")})), true),
+ ];
+ {
+ let mut stdin = child.stdin.take().unwrap();
+ for (m, _) in &calls {
+ writeln!(stdin, "{m}").unwrap();
+ }
+ }
+ let out = child.wait_with_output().unwrap();
+ assert!(out.status.success());
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}");
+ let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
+ let errors: Vec = replies.iter().map(|r| r["result"]["isError"] == true).collect();
+ assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::>(), "{replies:?}");
+ assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]);
+ assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file());
+ assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists());
+}
+
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
@@ -153,7 +317,7 @@ fn automation_roots(base: &std::path::Path) {
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
};
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
- fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
+ fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs
similarity index 97%
rename from apps/vectorcraft-cli/tests/fileio.rs
rename to apps/vectorcraft-cli/tests/it/fileio.rs
index 4de10d932..104aeb0fe 100644
--- a/apps/vectorcraft-cli/tests/fileio.rs
+++ b/apps/vectorcraft-cli/tests/it/fileio.rs
@@ -1,6 +1,4 @@
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
diff --git a/apps/vectorcraft-cli/tests/it/main.rs b/apps/vectorcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..6918ea19b
--- /dev/null
+++ b/apps/vectorcraft-cli/tests/it/main.rs
@@ -0,0 +1,7 @@
+//! Integration tests: one test binary for the crate, one module per area.
+// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod cli;
+mod fileio;
+mod mcp_e2e;
diff --git a/apps/vectorcraft-cli/tests/mcp_e2e.rs b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs
similarity index 98%
rename from apps/vectorcraft-cli/tests/mcp_e2e.rs
rename to apps/vectorcraft-cli/tests/it/mcp_e2e.rs
index e75ef0940..5a353070a 100644
--- a/apps/vectorcraft-cli/tests/mcp_e2e.rs
+++ b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs
@@ -1,6 +1,4 @@
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml
index f67a377ef..211bfdb18 100644
--- a/apps/vectorcraft-web/Cargo.toml
+++ b/apps/vectorcraft-web/Cargo.toml
@@ -14,7 +14,7 @@ name = "vectorcraft-web"
path = "src/main.rs"
[dependencies]
-vectorcraft-engine = { workspace = true }
+vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml
index 345968911..86235afbb 100644
--- a/apps/vectorcraft/Cargo.toml
+++ b/apps/vectorcraft/Cargo.toml
@@ -17,13 +17,17 @@ accessibility = ["eframe/accesskit"]
windows7 = ["eframe/glow"]
[dependencies]
-vectorcraft-engine = { workspace = true }
+vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
+# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
+# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217.
+craft-window = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -47,8 +51,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
+# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
+# read a pen's barrel button from its pointer messages.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
-windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
+windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
@@ -74,6 +80,10 @@ pollster = "1"
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME/Wayland needs client-side window controls without a bundled title font.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
+# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a
+# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build
+# through winit with the `xinput` feature.
+x11rb = { version = "0.13", default-features = false, features = ["xinput"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
diff --git a/apps/vectorcraft/src/automation_args.rs b/apps/vectorcraft/src/automation_args.rs
index 26157b76d..979f42e7b 100644
--- a/apps/vectorcraft/src/automation_args.rs
+++ b/apps/vectorcraft/src/automation_args.rs
@@ -6,29 +6,29 @@ use std::ffi::OsString;
use std::path::PathBuf;
pub struct AutomationArgs {
- pub read_root: Option,
- pub write_root: Option,
+ pub read_roots: Vec,
+ pub write_roots: Vec,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
+ /// The roots the environment gives (`VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`), until
+ /// the command line gives its own.
pub fn new(read_root: Option, write_root: Option) -> Self {
- Self { read_root, write_root, read_given: false, write_given: false }
+ Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect(), read_given: false, write_given: false }
}
- /// Returns whether this argument is a root flag. CLI values override environment defaults
- /// once; a second occurrence is an error, including when the two spellings are mixed.
+ /// Returns whether this argument is a root flag. Each flag may be given more than once (#3546),
+ /// in either spelling: the first replaces the environment's root of its kind, the others add
+ /// folders.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator- ) -> Result
{
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
- let (root, given) = match flag {
- "--automation-read-root" => (&mut self.read_root, &mut self.read_given),
- "--automation-write-root" => (&mut self.write_root, &mut self.write_given),
+ let (roots, given) = match flag {
+ "--automation-read-root" => (&mut self.read_roots, &mut self.read_given),
+ "--automation-write-root" => (&mut self.write_roots, &mut self.write_given),
_ => return Ok(false),
};
- if *given {
- return Err(format!("{flag} given twice"));
- }
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -42,8 +42,10 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
- *root = Some(value.into());
- *given = true;
+ if !std::mem::replace(given, true) {
+ roots.clear();
+ }
+ roots.push(value.into());
Ok(true)
}
}
@@ -97,17 +99,24 @@ mod tests {
use super::*;
#[test]
- fn roots_override_environment_once_and_leave_other_arguments() {
- let mut roots = AutomationArgs::new(Some("environment".into()), None);
+ fn roots_override_environment_and_add_up() {
+ let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
- assert_eq!(roots.read_root, Some("a folder".into()));
- assert_eq!(roots.write_root, Some("out".into()));
+ assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
+ assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
- assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
- assert_eq!(roots.read_root, Some("a folder".into()));
+ // #3546: a flag given again adds a folder, in either spelling.
+ assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
+ assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
+ assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
+ assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
+ // A kind the command line leaves alone keeps the environment's root.
+ let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
+ assert!(roots.consume("--automation-read-root=a", &mut std::iter::empty()).unwrap());
+ assert_eq!(roots.write_roots, [PathBuf::from("environment out")]);
}
#[test]
@@ -117,11 +126,15 @@ mod tests {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
- assert_eq!(roots.read_root, Some("read".into()));
- assert_eq!(roots.write_root, Some("write".into()));
+ assert_eq!(roots.read_roots, [PathBuf::from("read")]);
+ assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
+ // A bad second folder is an error too, and keeps the first.
+ assert!(roots.consume(&format!("{flag}=first"), &mut std::iter::empty()).unwrap());
+ assert!(roots.consume(flag, &mut std::iter::empty()).is_err());
+ assert_eq!([roots.read_roots.len(), roots.write_roots.len()].iter().sum::(), 1);
}
}
@@ -130,8 +143,8 @@ mod tests {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
- assert_eq!(roots.read_root, Some("-folder".into()));
- assert_eq!(roots.write_root, Some("./-folder".into()));
+ assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
+ assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
fn control(args: &[&str]) -> Result, String> {
diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs
index 165c0a81d..957d302d5 100644
--- a/apps/vectorcraft/src/clipboard.rs
+++ b/apps/vectorcraft/src/clipboard.rs
@@ -143,6 +143,13 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option {
Some(p) if p.ends_with('\r') => PathBuf::from(p.trim_end_matches('\r')),
_ => path,
};
+ // A paste sent by automation reads only what its roots allow.
+ match path.to_str() {
+ Some(p) => vectorcraft_engine::file_access::check_read(p).ok()?,
+ // A lossy name would check another file than the one opened.
+ None if vectorcraft_engine::file_access::current().is_none() => {}
+ None => return None,
+ }
let mut head = Vec::with_capacity(FILE_HEAD);
std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?;
diff --git a/apps/vectorcraft/src/linux_stylus.rs b/apps/vectorcraft/src/linux_stylus.rs
new file mode 100644
index 000000000..f86a9cc53
--- /dev/null
+++ b/apps/vectorcraft/src/linux_stylus.rs
@@ -0,0 +1,116 @@
+//! Linux pen tablets: winit reports barrel buttons as [`winit::event::MouseButton::Other`]
+//! (for example `BTN_STYLUS`), and egui-winit drops those before they reach the UI (#3632).
+
+use eframe::AppCreator;
+use winit::application::ApplicationHandler;
+use winit::event::{DeviceEvent, DeviceId, StartCause, WindowEvent};
+use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
+use winit::window::WindowId;
+
+use eframe::UserEvent;
+
+/// `BTN_STYLUS` and neighbours (`linux/input-event-codes.h`): pen barrel buttons Wayland passes
+/// through the pointer protocol as non-standard mouse buttons.
+const BTN_STYLUS: u16 = 0x014b;
+const BTN_STYLUS2: u16 = 0x014c;
+const BTN_STYLUS3: u16 = 0x014d;
+
+/// Like [`eframe::run_native`], but turns pen barrel buttons into mouse buttons egui understands.
+pub fn run_native(app_name: &str, mut native_options: eframe::NativeOptions, app_creator: AppCreator<'_>) -> eframe::Result {
+ let mut builder = EventLoop::::with_user_event();
+ if let Some(hook) = native_options.event_loop_builder.take() {
+ hook(&mut builder);
+ }
+ let event_loop = builder.build().map_err(eframe::Error::WinitEventLoop)?;
+ event_loop.set_control_flow(ControlFlow::Poll);
+ let inner = eframe::create_native(app_name, native_options, app_creator, &event_loop);
+ let mut app = Forwarder { inner };
+ event_loop.run_app(&mut app).map_err(eframe::Error::WinitEventLoop)?;
+ Ok(())
+}
+
+struct Forwarder<'a> {
+ inner: eframe::EframeWinitApplication<'a>,
+}
+
+impl ApplicationHandler for Forwarder<'_> {
+ fn resumed(&mut self, event_loop: &ActiveEventLoop) {
+ self.inner.resumed(event_loop);
+ }
+
+ fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) {
+ self.inner.window_event(event_loop, window_id, map_window_event(event));
+ }
+
+ fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) {
+ self.inner.new_events(event_loop, cause);
+ }
+
+ fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
+ self.inner.user_event(event_loop, event);
+ }
+
+ fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) {
+ self.inner.device_event(event_loop, device_id, event);
+ }
+
+ fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
+ self.inner.about_to_wait(event_loop);
+ }
+
+ fn suspended(&mut self, event_loop: &ActiveEventLoop) {
+ self.inner.suspended(event_loop);
+ }
+
+ fn exiting(&mut self, event_loop: &ActiveEventLoop) {
+ self.inner.exiting(event_loop);
+ }
+
+ fn memory_warning(&mut self, event_loop: &ActiveEventLoop) {
+ self.inner.memory_warning(event_loop);
+ }
+}
+
+/// Map a tablet button code winit labels `MouseButton::Other` to a standard mouse button.
+pub(crate) fn map_tablet_button(code: u16) -> Option {
+ use winit::event::MouseButton;
+ match code {
+ BTN_STYLUS | BTN_STYLUS2 => Some(MouseButton::Right),
+ BTN_STYLUS3 => Some(MouseButton::Middle),
+ _ => None,
+ }
+}
+
+fn map_window_event(event: WindowEvent) -> WindowEvent {
+ if let WindowEvent::MouseInput { device_id, state, button: winit::event::MouseButton::Other(code), .. } = event
+ && let Some(button) = map_tablet_button(code)
+ {
+ WindowEvent::MouseInput { device_id, state, button }
+ } else {
+ event
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use winit::event::{ElementState, MouseButton};
+
+ #[test]
+ fn stylus_barrel_buttons_become_secondary_clicks() {
+ assert_eq!(map_tablet_button(BTN_STYLUS), Some(MouseButton::Right));
+ assert_eq!(map_tablet_button(BTN_STYLUS2), Some(MouseButton::Right));
+ assert_eq!(map_tablet_button(BTN_STYLUS3), Some(MouseButton::Middle));
+ assert_eq!(map_tablet_button(0x110), None);
+ }
+
+ #[test]
+ fn window_mouse_input_is_remapped_before_eframe() {
+ let device_id = winit::event::DeviceId::dummy();
+ let press = WindowEvent::MouseInput { device_id, state: ElementState::Pressed, button: MouseButton::Other(BTN_STYLUS) };
+ let mapped = map_window_event(press);
+ assert!(matches!(mapped, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Pressed, .. }));
+ let release = map_window_event(WindowEvent::MouseInput { device_id, state: ElementState::Released, button: MouseButton::Other(BTN_STYLUS2) });
+ assert!(matches!(release, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Released, .. }));
+ }
+}
diff --git a/apps/vectorcraft/src/logging.rs b/apps/vectorcraft/src/logging.rs
index cd2ea1eba..a9fded642 100644
--- a/apps/vectorcraft/src/logging.rs
+++ b/apps/vectorcraft/src/logging.rs
@@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
-/// value can overflow it.
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days.saturating_add(719_468);
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
+/// so month, day and time of day come from chrono and only the year is carried, which keeps every
+/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -387,6 +395,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs
index 519c98097..fe2097157 100644
--- a/apps/vectorcraft/src/mac_save_format.rs
+++ b/apps/vectorcraft/src/mac_save_format.rs
@@ -26,9 +26,7 @@ use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePan
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
-use vectorcraft_ui_egui::{FilePick, i18n};
-
-type Filter = (&'static str, &'static [&'static str]);
+use vectorcraft_ui_egui::{FileFilter as Filter, FilePick, i18n};
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
@@ -70,7 +68,8 @@ define_class!(
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec {
- let named: Vec = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
+ let named: Vec =
+ filters.iter().filter(|(_, extensions)| !extensions.is_empty() && !extensions.iter().any(|x| x == "*")).cloned().collect();
if named.len() < 2 { Vec::new() } else { named }
}
@@ -95,10 +94,10 @@ fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
-fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
- let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
+fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&str> {
+ let first = filters.get(selected).map_or(&[][..], |(_, extensions)| extensions.as_slice());
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
- first.iter().chain(others).copied().collect()
+ first.iter().chain(others).map(String::as_str).collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
@@ -184,26 +183,28 @@ fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec, s
mod tests {
use super::*;
- const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
+ fn filters() -> Vec {
+ FilePick::filters([("VectorCraft", &["vectorcraft"][..]), ("SVG", &["svg"]), ("PDF", &["pdf"])])
+ }
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
- let save = menu_formats(&fileio::save_filters("vectorcraft"));
+ let save = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
assert!(save.len() >= 2, "{save:?}");
- assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
- assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
- assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
- assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
- assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
- assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
+ assert_eq!(save.first().map(|(_, e)| e.as_slice()), Some(&["vectorcraft".to_owned()][..]));
+ assert!(menu_formats(&FilePick::filters(fileio::save_filters("svg"))).first().is_some_and(|(_, e)| e.iter().any(|e| e == "svg")));
+ assert!(menu_formats(&FilePick::filters(fileio::template_filters("template"))).len() >= 2);
+ assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..])])).is_empty());
+ assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("Any", &[])])).is_empty());
+ assert_eq!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("SVG", &["svg"])])).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
- assert_eq!(initial_format(name, FILTERS), index, "{name}");
+ assert_eq!(initial_format(name, &filters()), index, "{name}");
}
}
@@ -219,7 +220,7 @@ mod tests {
("photo.png", "photo.svg"),
("", ""),
] {
- assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
+ assert_eq!(filename(name, "svg", &filters()), expected, "{name}");
}
}
@@ -227,27 +228,19 @@ mod tests {
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
- assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
- assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
- assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
- assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
+ assert_eq!(allowed_types(&filters(), 0), ["vectorcraft", "svg", "pdf"]);
+ assert_eq!(allowed_types(&filters(), 1), ["svg", "vectorcraft", "pdf"]);
+ assert_eq!(allowed_types(&filters(), 2), ["pdf", "vectorcraft", "svg"]);
+ assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
- let filters = menu_formats(&fileio::save_filters("vectorcraft"));
+ let filters = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
for (label, extensions) in &filters {
- let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
- assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
- }
- }
-
- /// The menu's label is translated in every language with complete menus.
- #[test]
- fn the_label_is_translated() {
- for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
- assert!(i18n::has(lang, "Format:"), "{}", lang.code());
+ let name = filename("Untitled-1.vectorcraft", &extensions[0], &filters);
+ assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}");
}
}
}
diff --git a/apps/vectorcraft/src/mac_tablet.rs b/apps/vectorcraft/src/mac_tablet.rs
new file mode 100644
index 000000000..b624262aa
--- /dev/null
+++ b/apps/vectorcraft/src/mac_tablet.rs
@@ -0,0 +1,275 @@
+//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
+//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
+//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
+//!
+//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
+//! each event it is about to dispatch" in this app:
+//! )
+//! reads each mouse and tablet event on the main thread before winit's view handles it, and passes
+//! it on unchanged:
+//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
+//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
+//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
+//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
+//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
+//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
+//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
+//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
+//! (`NSPointingDeviceTypeCursor`) is a mouse.
+//!
+//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
+//! merges the drags that queue up between two reads into one: every report of the pen arrives.
+//!
+//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
+//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
+//! reads them.
+#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
+
+use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
+
+/// `NSEventType` values (AppKit headers).
+mod event_type {
+ pub const TABLET_POINT: usize = 23;
+ pub const TABLET_PROXIMITY: usize = 24;
+ /// Left, right and other mouse down, up, moved and dragged: winit makes a pointer move of each.
+ pub const MOUSE: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 25, 26, 27];
+}
+
+/// `NSEventSubtype` values of mouse events.
+mod subtype {
+ pub const TABLET_POINT: i16 = 1;
+ pub const TABLET_PROXIMITY: i16 = 2;
+}
+
+/// `NSPointingDeviceType` values.
+mod device {
+ pub const CURSOR: usize = 2;
+ pub const ERASER: usize = 3;
+}
+
+/// The tablet fields of one `NSEvent`, as plain values.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+struct RawEvent {
+ /// `type`.
+ kind: usize,
+ /// `subtype` (mouse events only).
+ subtype: i16,
+ /// `pressure` (tablet point events and mouse events of that subtype only).
+ pressure: f32,
+ /// `pointingDeviceType` (proximity events only).
+ device: usize,
+ /// `isEnteringProximity` (proximity events only).
+ entering: bool,
+}
+
+impl RawEvent {
+ fn mouse(&self) -> bool {
+ event_type::MOUSE.contains(&self.kind)
+ }
+
+ fn proximity(&self) -> bool {
+ self.kind == event_type::TABLET_PROXIMITY || (self.mouse() && self.subtype == subtype::TABLET_PROXIMITY)
+ }
+
+ fn point(&self) -> bool {
+ self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
+ }
+}
+
+/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
+fn event_mask() -> u64 {
+ event_type::MOUSE.iter().chain(&[event_type::TABLET_POINT, event_type::TABLET_PROXIMITY]).fold(0, |m, t| m | 1u64 << t)
+}
+
+/// What an event means for the feed.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Update {
+ /// Nothing: an event the monitor doesn't map.
+ Keep,
+ /// The pressure and end of a pen, or `None` for a mouse.
+ Set(Option),
+ /// A pen's sample that comes with a pointer move.
+ Move(PenSample),
+}
+
+impl Update {
+ fn apply(self, feed: &StylusFeed) {
+ match self {
+ Update::Keep => {}
+ Update::Set(sample) => feed.set_sample(sample),
+ Update::Move(sample) => feed.set_move(sample),
+ }
+ }
+}
+
+/// Which end of the pen is near the tablet.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+struct Pen {
+ eraser: bool,
+}
+
+impl Pen {
+ fn handle(&mut self, e: &RawEvent) -> Update {
+ if e.proximity() {
+ // Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
+ self.eraser = e.entering && e.device == device::ERASER;
+ let pen = e.entering && e.device != device::CURSOR;
+ return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
+ }
+ let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
+ match (e.point(), e.mouse()) {
+ (true, true) => Update::Move(sample),
+ (true, false) => Update::Set(Some(sample)),
+ (false, true) => Update::Set(None),
+ (false, false) => Update::Keep,
+ }
+ }
+}
+
+/// Start reading pen pressure: the feed the monitor writes into, or `None` (logged) when AppKit
+/// refuses the monitor or this isn't the main thread.
+#[cfg(target_os = "macos")]
+pub fn start() -> Option {
+ // The monitor writes on the main thread before winit hands the event on: a press never waits.
+ let feed = StylusFeed::on_ui_thread();
+ match monitor::install(feed.clone()) {
+ Ok(()) => Some(feed),
+ Err(e) => {
+ log::warn!("tablet pressure: {e}");
+ None
+ }
+ }
+}
+
+#[cfg(target_os = "macos")]
+mod monitor {
+ use std::cell::Cell;
+ use std::ptr::NonNull;
+
+ use block2::RcBlock;
+ use objc2::MainThreadMarker;
+ use objc2_app_kit::{NSEvent, NSEventMask};
+ use vectorcraft_ui_egui::stylus::StylusFeed;
+
+ use super::{Pen, RawEvent, event_mask};
+
+ /// Install the monitor for the life of the app. Main thread only.
+ pub(super) fn install(feed: StylusFeed) -> Result<(), &'static str> {
+ MainThreadMarker::new().ok_or("the event monitor can only be installed from the main thread")?;
+ let pen = Cell::new(Pen::default());
+ let handler = RcBlock::new(move |event: NonNull| -> *mut NSEvent {
+ // SAFETY: AppKit calls the handler with the event it is about to dispatch, which it
+ // keeps alive while the handler runs.
+ #[allow(unsafe_code)]
+ let e = unsafe { event.as_ref() };
+ let mut p = pen.get();
+ p.handle(&read(e)).apply(&feed);
+ pen.set(p);
+ event.as_ptr()
+ });
+ // SAFETY: the handler returns the event it was given, a valid pointer ("the block's return
+ // must be a valid pointer or null"). AppKit copies the block and keeps it while the monitor
+ // is installed.
+ #[allow(unsafe_code)]
+ let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask(event_mask()), &handler) };
+ // The monitor stays until the app quits: its token is never handed to `removeMonitor:`.
+ std::mem::forget(token.ok_or("AppKit refused the event monitor")?);
+ NSEvent::setMouseCoalescingEnabled(false);
+ Ok(())
+ }
+
+ /// The tablet fields of `e`. `subtype` raises an exception for events without one, and the
+ /// pressure and proximity fields are read only for the events that define them.
+ fn read(e: &NSEvent) -> RawEvent {
+ let mut raw = RawEvent { kind: e.r#type().0, ..Default::default() };
+ if raw.mouse() {
+ raw.subtype = e.subtype().0;
+ }
+ if raw.point() {
+ raw.pressure = e.pressure();
+ }
+ if raw.proximity() {
+ raw.device = e.pointingDeviceType().0;
+ raw.entering = e.isEnteringProximity();
+ }
+ raw
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const DRAG: usize = 6;
+ const KEY_DOWN: usize = 10;
+
+ fn pen(kind: usize, pressure: f32) -> RawEvent {
+ RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, ..Default::default() }
+ }
+
+ fn proximity(device: usize, entering: bool) -> RawEvent {
+ RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
+ }
+
+ fn tip(pressure: f32) -> PenSample {
+ PenSample { pressure, eraser: false }
+ }
+
+ #[test]
+ fn a_pens_mouse_events_are_moves_with_its_pressure() {
+ let mut p = Pen::default();
+ assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)));
+ assert_eq!(p.handle(&pen(1, 0.25)), Update::Move(tip(0.25)), "a press");
+ assert_eq!(p.handle(&pen(event_type::TABLET_POINT, 0.8)), Update::Set(Some(tip(0.8))), "no move");
+ }
+
+ #[test]
+ fn other_mouse_events_are_a_mouse_and_other_events_are_kept() {
+ let mut p = Pen::default();
+ for subtype in [0, 3, 99, -1] {
+ let e = RawEvent { kind: DRAG, subtype, pressure: 1.0, ..Default::default() };
+ assert_eq!(p.handle(&e), Update::Set(None), "subtype {subtype}");
+ }
+ for kind in [0, KEY_DOWN, 22, 29, 34, usize::MAX] {
+ // Other events' subtypes don't make them a pen's.
+ let e = RawEvent { kind, subtype: subtype::TABLET_POINT, ..Default::default() };
+ assert_eq!(p.handle(&e), Update::Keep, "type {kind}");
+ }
+ }
+
+ #[test]
+ fn proximity_says_which_end_is_near() {
+ let mut p = Pen::default();
+ let eraser = |pressure| PenSample { pressure, eraser: true };
+ assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
+ assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
+ assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
+ assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
+ assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
+ assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
+ assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
+ let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
+ assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
+ }
+
+ #[test]
+ fn the_feed_gets_each_moves_pressure_within_0_to_1() {
+ let feed = StylusFeed::default();
+ let mut p = Pen::default();
+ for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
+ p.handle(&e).apply(&feed);
+ }
+ assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
+ p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
+ assert_eq!(feed.held(), None, "a mouse moved");
+ }
+
+ #[test]
+ fn the_mask_covers_the_mapped_events() {
+ let m = event_mask();
+ for t in [1, 2, 3, 4, 5, 6, 7, 23, 24, 25, 26, 27] {
+ assert!(m & (1 << t) != 0, "{t}");
+ }
+ assert_eq!(m & (1 << KEY_DOWN), 0, "keys are not read");
+ }
+}
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index a457d9808..79e4c1929 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -1,6 +1,6 @@
//! VectorCraft desktop app.
//!
-//! Usage: `vectorcraft [--control [--automation-read-root ] [--automation-write-root ]]
+//! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...]
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
@@ -30,6 +30,8 @@ mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
+#[cfg(target_os = "linux")]
+mod linux_stylus;
mod logging;
#[cfg(target_os = "macos")]
mod mac_fonts;
@@ -39,6 +41,7 @@ mod mac_menu;
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
+mod mac_tablet;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
@@ -49,8 +52,10 @@ mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
+mod tablet;
mod window;
mod window_frame;
+mod windows_stylus;
mod xwayland;
use vectorcraft_engine::Session;
@@ -70,6 +75,12 @@ struct App {
frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option,
+ /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
+ winit_window: Option>,
+ /// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
+ pending_maximize: bool,
+ /// Frames left to retry [`window::apply_pending_maximize`].
+ maximize_tries: u8,
}
impl eframe::App for App {
@@ -89,6 +100,9 @@ impl eframe::App for App {
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
+ // A pen contact with the barrel button held presses the secondary button (Windows Ink).
+ #[cfg(all(windows, not(target_vendor = "win7")))]
+ windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
@@ -144,14 +158,22 @@ impl App {
// again on its document when it answers.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() {
- // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
- // again on its document when it answers.
- if !self.app.file_dialog_open() {
- open_files(&mut self.app, open_documents::take());
+ let files = open_documents::take();
+ if !files.is_empty() {
+ bring_window_forward(ctx);
}
+ open_files(&mut self.app, files);
}
self.app.logic(ctx);
- window::track(ctx, &mut self.app.ui.window);
+ if self.pending_maximize {
+ if let Some(w) = &self.winit_window {
+ self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
+ } else {
+ self.pending_maximize = false;
+ }
+ }
+ let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
+ window::track(ctx, &mut self.app.ui.window, host_maximized);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -174,6 +196,14 @@ fn end_before_teardown(frames: u64) {
}
}
+/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
+/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
+#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
+fn bring_window_forward(ctx: &egui::Context) {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
+ ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
+}
+
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec) {
@@ -251,10 +281,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option {
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
- if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
- for workspace in workspaces {
- if let Some(object) = workspace.as_object_mut() {
- recover(object, "dockingHidden");
+ // Saved workspaces and the layout changes kept for each workspace carry the same layouts.
+ for key in ["custom_workspaces", "workspace_changes"] {
+ if let Some(Value::Array(workspaces)) = object.get_mut(key) {
+ for workspace in workspaces {
+ if let Some(object) = workspace.as_object_mut() {
+ recover(object, "dockingHidden");
+ }
}
}
}
@@ -293,7 +326,7 @@ type Parent = Option>;
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
- fn filter(self, name: &str, exts: &[&str]) -> Self;
+ fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
@@ -307,7 +340,7 @@ macro_rules! dialog_settings {
fn title(self, title: &str) -> Self {
self.set_title(title)
}
- fn filter(self, name: &str, exts: &[&str]) -> Self {
+ fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
@@ -333,10 +366,9 @@ fn dialog_for(d: D, request: &PickRequest) -> D {
}
PickRequest::Folder => return d,
};
- let d = pick
- .filters
- .iter()
- .fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts));
+ let d = pick.filters.iter().fold(d, |d, (name, exts)| {
+ d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
+ });
let d = match &pick.folder {
Some(folder) => d.folder(folder),
None => d,
@@ -528,6 +560,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
+ // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764).
+ #[cfg(target_os = "linux")]
+ let x11 = parent
+ .as_ref()
+ .and_then(|w| w.window_handle().ok())
+ .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
@@ -541,6 +579,11 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
+ #[cfg(target_os = "linux")]
+ stylus: if x11 { tablet::start() } else { None },
+ // macOS reads it from AppKit's events (#852).
+ #[cfg(target_os = "macos")]
+ stylus: mac_tablet::start(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
@@ -629,13 +672,73 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
/// macOS keeps its traffic lights over the integrated title strip.
+///
+/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
+/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
+#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
-/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
-/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
-/// before the window opens; a missing or unreadable value keeps the default.
+/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
+/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
+/// `false` (the default). This preference forces the desktop/native frame back on.
+fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
+ saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
+}
+
+/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
+/// decided and the user's System Title Bar preference.
+///
+/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
+/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
+/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
+/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
+/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
+/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
+/// title bar; the preference always forces the native frame back on.
+///
+/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
+/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
+/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
+/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
+/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
+ matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
+}
+
+/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
+/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
+/// Windows draws its own caption unless the user opted into the system title bar.
+#[cfg(any(target_os = "macos", target_os = "windows"))]
+fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
+ CUSTOM_TITLEBAR && !opted_into_system
+}
+
+/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
+/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
+/// Title Bar preference. Call once at startup, before [`native_options`].
+///
+/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
+/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
+/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
+/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
- CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
+ let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
+ let opted_into_system = opted_into_system_title_bar(saved);
+ #[cfg(target_os = "linux")]
+ {
+ let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
+ // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
+ // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
+ // present, the same two conditions the launch path checks below — that run is effectively
+ // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
+ // the probe's own decision (app-drawn on GNOME) must stand.
+ let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
+ if choice.overridden().is_none() && forced_to_x11 {
+ return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
+ }
+ }
+ custom_titlebar_from(choice.frame(), opted_into_system)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
@@ -667,7 +770,9 @@ Options:
--control Start the localhost JSON-lines control server on
(or VECTORCRAFT_CONTROL_PORT)
--automation-read-root Confine the files control requests read to
+ (may be given more than once)
--automation-write-root Confine the files control requests write to
+ (may be given more than once)
--in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit
@@ -716,7 +821,7 @@ fn main() -> std::process::ExitCode {
_ => files.push(a),
}
}
- let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
+ let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port {
Some(port) => Some(port),
@@ -755,12 +860,14 @@ fn main() -> std::process::ExitCode {
// The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() {
- if read_root.is_some() || write_root.is_some() {
+ if !read_roots.is_empty() || !write_roots.is_empty() {
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
}
None
} else {
- match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
+ let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect();
+ let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect();
+ match AutomationRoots::new(&read, &write) {
Ok(roots) => roots,
Err(e) => {
log::error!("{e}");
@@ -832,6 +939,18 @@ fn main() -> std::process::ExitCode {
}
options
};
+ // With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message
+ // hook notes each pointer contact and whether it starts with the barrel button held
+ // (`windows_stylus`).
+ #[cfg(all(windows, not(target_vendor = "win7")))]
+ let options = {
+ use winit::platform::windows::EventLoopBuilderExtWindows as _;
+ let mut options = options;
+ options.event_loop_builder = Some(Box::new(|builder| {
+ builder.with_msg_hook(windows_stylus::msg_hook);
+ }));
+ options
+ };
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
@@ -876,9 +995,18 @@ fn main() -> std::process::ExitCode {
#[cfg(feature = "wgpu")]
let created = startup.clone();
// A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
+ let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| {
+ #[cfg(target_os = "linux")]
+ {
+ linux_stylus::run_native("VectorCraft", options, app_creator)
+ }
+ #[cfg(not(target_os = "linux"))]
+ {
+ eframe::run_native("VectorCraft", options, app_creator)
+ }
+ };
let outcome = vectorcraft_engine::guard::catch_panic(|| {
- eframe::run_native(
- "VectorCraft",
+ run(
options,
Box::new(move |cc| {
let mut session = Session::new();
@@ -891,16 +1019,22 @@ fn main() -> std::process::ExitCode {
// which takes over from the copy the preferences file used to hold.
prefs_dir::attach_pdf_preset_store(&mut app.session);
// Fit the window to its monitor, or put it back where it was (still hidden).
- if let Some(w) = cc.winit_window() {
- app.ui.window = Some(window::restore(w, saved_window));
+ let winit_window = cc.winit_window().cloned();
+ if let Some(w) = &winit_window {
+ let g = window::restore(w, saved_window);
+ app.ui.window = Some(g);
#[cfg(windows)]
set_taskbar_icon(w);
}
- // User Defined swatch and graphic style libraries live next to the preferences.
+ // Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
+ let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
+ // User Defined swatch, graphic style and symbol libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
+ let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string()));
+ app.session.symbol_libraries.set_user_dir(symbols);
// With the User Defined libraries listed, the persistent library panels reopen.
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
// So do the Libraries panel's libraries; runs without preferences (agents' test
@@ -957,7 +1091,7 @@ fn main() -> std::process::ExitCode {
// Everywhere the app doesn't say whose work it is (worker threads), the
// roots hold: unconfined is only what the person at the keyboard does.
file_access::confine_process(roots.clone());
- log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
+ log::info!("control channel confined to the automation roots: {}", roots.describe());
}
}
#[cfg(target_os = "macos")]
@@ -976,7 +1110,16 @@ fn main() -> std::process::ExitCode {
created.ready();
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
- Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
+ Ok(Box::new(App {
+ app,
+ graphics_loss,
+ graphics_lost: false,
+ frames: 0,
+ window_frame,
+ winit_window,
+ pending_maximize,
+ maximize_tries: 8,
+ }))
}),
)
});
@@ -1001,6 +1144,17 @@ mod tests {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
+ #[test]
+ fn opening_files_unminimizes_and_focuses_the_window() {
+ let ctx = egui::Context::default();
+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx()));
+ // Nothing paints in the test: drop the font atlas upload egui expects to be applied.
+ out.textures_delta.clear();
+ let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands;
+ assert!(cmds.contains(&egui::ViewportCommand::Minimized(false)));
+ assert!(cmds.contains(&egui::ViewportCommand::Focus));
+ }
+
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
@@ -1010,6 +1164,7 @@ mod tests {
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
+ app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
@@ -1020,6 +1175,8 @@ mod tests {
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
+ raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
+ raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
@@ -1036,6 +1193,8 @@ mod tests {
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
+ assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
+ assert!(loaded.workspace_changes[0].docking.is_none());
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
@@ -1048,17 +1207,41 @@ mod tests {
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
- // Preferences › User Interface › System Title Bar gives the window back its system
- // decorations on Windows and Linux; macOS always has them.
- assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
+ use craft_window::{Frame, Layout};
+
+ // The System Title Bar preference (Preferences › User Interface) is read the same way on
+ // every platform; a missing or unreadable value is `false`.
+ assert!(!super::opted_into_system_title_bar(None), "no preferences: the default");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
- assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
+ assert!(!super::opted_into_system_title_bar(Some(&off)));
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
- assert!(!super::custom_titlebar(Some(&on)));
- assert_eq!(super::native_options(false).viewport.decorations, Some(true));
- if super::CUSTOM_TITLEBAR {
- assert_eq!(super::native_options(true).viewport.decorations, Some(false));
+ assert!(super::opted_into_system_title_bar(Some(&on)));
+
+ // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title
+ // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native
+ // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws
+ // our own title bar — unless the user opted into the system title bar, which forces the
+ // native frame back on.
+ let app = Frame::App(Layout::default());
+ let desktop = Frame::Desktop;
+ #[cfg(not(any(target_os = "macos", target_os = "windows")))]
+ {
+ assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own");
+ assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native");
+ assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame");
+ assert!(!super::custom_titlebar_from(&desktop, true));
}
+ // macOS and Windows keep today's behavior, independent of the probed frame: the pref still
+ // forces the native frame back on where a custom one would otherwise be drawn.
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
+ {
+ assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR);
+ assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows");
+ assert!(!super::custom_titlebar_from(&desktop, true));
+ }
+
+ assert_eq!(super::native_options(false).viewport.decorations, Some(true));
+ assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs
index c374c8949..b6362107e 100644
--- a/apps/vectorcraft/src/portal_save.rs
+++ b/apps/vectorcraft/src/portal_save.rs
@@ -326,7 +326,11 @@ mod tests {
let pick = FilePick {
name: "Untitled-1.vectorcraft".into(),
folder: Some("/home/me/Art".into()),
- filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
+ filters: vec![
+ FilePick::filter("VectorCraft", &["vectorcraft"]),
+ FilePick::filter("SVG", &["svg"]),
+ FilePick::filter("PDF-compatible .ai", &["ai"]),
+ ],
};
SaveRequest::new(&pick, "x11:3a0000f".into())
}
diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs
new file mode 100644
index 000000000..f2c1fd725
--- /dev/null
+++ b/apps/vectorcraft/src/tablet.rs
@@ -0,0 +1,417 @@
+//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a
+//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from
+//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
+//! reads.
+//!
+//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without
+//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw
+//! events reach every client that selects them, also while winit holds the pointer grab of a
+//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen
+//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label),
+//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the
+//! valuator's range to 0..1, goes into the feed. Any other pointer
+//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so
+//! the last pressure of each pen is kept.
+//!
+//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains
+//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's
+//! "… Eraser (0x…)" of type `ERASER`, XWayland's "xwayland-tablet eraser:N"). Its samples go into
+//! the feed marked as the eraser end, and the canvas switches to the Eraser tool
+//! ([`vectorcraft_ui_egui::stylus`]).
+//!
+//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland
+//! that receives none of the window's input.
+//!
+//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with
+//! the pressure axis only.
+#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
+
+use std::collections::HashMap;
+
+use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
+
+/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer
+/// than 16).
+const MAX_AXES: u16 = 64;
+
+/// Devices kept (a server could list thousands).
+const MAX_DEVICES: usize = 256;
+
+/// The XInput types of finger devices (`XI_TOUCHPAD` and `XI_TOUCHSCREEN`): xf86-input-evdev labels
+/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens.
+const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"];
+
+/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Axis {
+ number: u16,
+ min: f64,
+ max: f64,
+}
+
+impl Axis {
+ /// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a
+ /// mouse's, 1.
+ fn pressure(&self, v: f64) -> f32 {
+ if !v.is_finite() {
+ return 1.0;
+ }
+ let span = self.max - self.min;
+ let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v };
+ unit.clamp(0.0, 1.0) as f32
+ }
+}
+
+/// A pointer device the X server lists.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Device {
+ id: u16,
+ /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.
+ pressure: Option,
+ /// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.
+ eraser: bool,
+}
+
+impl Device {
+ /// From what the X server lists for the device: its name and each valuator's number, label,
+ /// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).
+ fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator- ) -> Self {
+ let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));
+ let pressure = valuators
+ .into_iter()
+ .filter(|(number, ..)| *number < MAX_AXES)
+ .find(|(_, label, ..)| label == "Abs Pressure")
+ .map(|(number, _, min, max)| Axis { number, min, max })
+ .filter(|_| !finger);
+ let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));
+ Self { id, pressure, eraser }
+ }
+}
+
+/// What a raw event means for the feed.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Update {
+ /// Nothing: a device the last listing didn't have (a master, or one added since).
+ Keep,
+ /// The pressure and end of a pen, or `None` for another pointer.
+ Set(Option
),
+}
+
+/// The pointer devices and the last pressure of each pen.
+#[derive(Debug, Default)]
+struct Pens {
+ devices: HashMap,
+ last: HashMap,
+}
+
+impl Pens {
+ /// Replace the device list (at the start, and when devices come and go or change).
+ fn set_devices(&mut self, devices: impl IntoIterator- ) {
+ self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
+ self.last.retain(|id, _| self.devices.contains_key(id));
+ }
+
+ /// A raw motion or button event of device `id`, with the valuators that changed.
+ fn handle(&mut self, id: u16, valuators: impl IntoIterator
- ) -> Update {
+ let Some(device) = self.devices.get(&id) else { return Update::Keep };
+ let Some(axis) = device.pressure else { return Update::Set(None) };
+ let eraser = device.eraser;
+ let last = self.last.entry(id).or_insert(0.0);
+ for (number, v) in valuators {
+ if number == axis.number {
+ *last = axis.pressure(v);
+ }
+ }
+ Update::Set(Some(PenSample { pressure: *last, eraser }))
+ }
+
+ /// A raw event as the X server sends it: its valuator mask and the values of the valuators
+ /// set in it. A pen's sample or another pointer's `None` goes into `feed`.
+ fn raw_event(&mut self, feed: &StylusFeed, id: u16, mask: &[u32], values: &[f64]) {
+ if let Update::Set(sample) = self.handle(id, valuators(mask, values)) {
+ feed.set_sample(sample);
+ }
+ }
+}
+
+/// The values a raw event carries: the valuator numbers set in `mask` (32 to a word, lowest bit
+/// first), each paired in order with one of `values`. Mask bits past the last value are ignored.
+fn valuators<'a>(mask: &'a [u32], values: &'a [f64]) -> impl Iterator
- + 'a {
+ let numbers = mask
+ .iter()
+ .enumerate()
+ .flat_map(|(w, bits)| (0..32u32).filter(move |b| bits & (1 << b) != 0).map(move |b| w.saturating_mul(32).saturating_add(b as usize)));
+ numbers.zip(values.iter().copied()).filter_map(|(n, v)| u16::try_from(n).ok().map(|n| (n, v)))
+}
+
+/// An X 32.32 fixed-point value.
+fn fp3232(integral: i32, frac: u32) -> f64 {
+ f64::from(integral) + f64::from(frac) / 4_294_967_296.0
+}
+
+/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None`
+/// (logged) when the X server can't be reached or has no XInput 2.1.
+#[cfg(target_os = "linux")]
+pub fn start() -> Option
{
+ let feed = StylusFeed::default();
+ match x11::spawn(feed.clone()) {
+ Ok(()) => Some(feed),
+ Err(e) => {
+ log::warn!("tablet pressure: {e}");
+ None
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+mod x11 {
+ use std::collections::HashMap;
+
+ use vectorcraft_ui_egui::stylus::StylusFeed;
+ use x11rb::connection::{Connection as _, RequestConnection as _};
+ use x11rb::protocol::Event;
+ use x11rb::protocol::xinput::{self, ConnectionExt as _, DeviceClassData, DeviceType, XIEventMask};
+ use x11rb::protocol::xproto::ConnectionExt as _;
+ use x11rb::rust_connection::RustConnection;
+
+ use super::{Device, MAX_AXES, MAX_DEVICES, Pens, fp3232};
+
+ /// `XIAllDevices`: every device, the slave devices that name a pen included.
+ const ALL_DEVICES: u16 = 0;
+
+ /// Connect to `$DISPLAY` (the display winit opened the window on), check for XInput 2.1 (raw
+ /// events for every client that selects them), select the raw events, and read them on a
+ /// thread of its own until the connection closes.
+ pub fn spawn(feed: StylusFeed) -> Result<(), String> {
+ let (conn, screen) = RustConnection::connect(None).map_err(|e| format!("can't connect to the X server: {e}"))?;
+ let root = conn.setup().roots.get(screen).map(|s| s.root).ok_or_else(|| format!("X screen {screen} not found"))?;
+ let version =
+ conn.xinput_xi_query_version(2, 2).map_err(|e| e.to_string())?.reply().map_err(|e| format!("the X server has no XInput 2: {e}"))?;
+ if (version.major_version, version.minor_version) < (2, 1) {
+ return Err(format!("the X server has XInput {}.{}, and raw events need 2.1", version.major_version, version.minor_version));
+ }
+ let mask = XIEventMask::RAW_MOTION
+ | XIEventMask::RAW_BUTTON_PRESS
+ | XIEventMask::RAW_BUTTON_RELEASE
+ | XIEventMask::HIERARCHY
+ | XIEventMask::DEVICE_CHANGED;
+ conn.xinput_xi_select_events(root, &[xinput::EventMask { deviceid: ALL_DEVICES, mask: vec![mask] }])
+ .map_err(|e| e.to_string())?
+ .check()
+ .map_err(|e| format!("XISelectEvents failed: {e}"))?;
+ let mut pens = Pens::default();
+ pens.set_devices(query_devices(&conn));
+ std::thread::Builder::new()
+ .name("vectorcraft-tablet-x11".into())
+ .spawn(move || run(&conn, &mut pens, &feed))
+ .map(drop)
+ .map_err(|e| format!("can't start the tablet thread: {e}"))
+ }
+
+ fn run(conn: &RustConnection, pens: &mut Pens, feed: &StylusFeed) {
+ // A connection error (the display went away) ends the loop; an event that x11rb cannot
+ // parse is skipped, and `Pens` bounds what the rest can hold.
+ while let Ok(raw) = conn.wait_for_raw_event() {
+ let Ok(event) = conn.parse_event(&raw) else { continue };
+ match event {
+ Event::XinputRawMotion(e) | Event::XinputRawButtonPress(e) | Event::XinputRawButtonRelease(e) => {
+ let values: Vec = e.axisvalues.iter().map(|v| fp3232(v.integral, v.frac)).collect();
+ pens.raw_event(feed, e.deviceid, &e.valuator_mask, &values);
+ }
+ Event::XinputHierarchy(_) | Event::XinputDeviceChanged(_) => pens.set_devices(query_devices(conn)),
+ _ => {}
+ }
+ }
+ }
+
+ /// The slave pointer devices with their names and pressure valuators (none on any error).
+ fn query_devices(conn: &RustConnection) -> Vec {
+ let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
+ let kinds = device_kinds(conn);
+ let mut out = Vec::new();
+ for info in reply.infos.iter().take(MAX_DEVICES) {
+ if info.type_ != DeviceType::SLAVE_POINTER && info.type_ != DeviceType::FLOATING_SLAVE {
+ continue;
+ }
+ let valuators = info.classes.iter().take(usize::from(MAX_AXES)).filter_map(|c| match &c.data {
+ DeviceClassData::Valuator(v) => {
+ let label = atom_name(conn, v.label)?;
+ Some((v.number, label, fp3232(v.min.integral, v.min.frac), fp3232(v.max.integral, v.max.frac)))
+ }
+ _ => None,
+ });
+ let name = String::from_utf8_lossy(&info.name);
+ out.push(Device::new(info.deviceid, &name, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>()));
+ }
+ out
+ }
+
+ /// Each device's XInput type name, by device id (none on any error).
+ fn device_kinds(conn: &RustConnection) -> HashMap {
+ let Ok(reply) = conn.xinput_list_input_devices().map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return HashMap::new() };
+ reply.devices.iter().take(MAX_DEVICES).filter_map(|d| Some((u16::from(d.device_id), atom_name(conn, d.device_type)?))).collect()
+ }
+
+ fn atom_name(conn: &RustConnection, atom: u32) -> Option {
+ if atom == 0 {
+ return None;
+ }
+ let reply = conn.get_atom_name(atom).ok()?.reply().ok()?;
+ Some(String::from_utf8_lossy(&reply.name).into_owned())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to
+ /// 65,535.
+ fn pen_valuators() -> Vec<(u16, String, f64, f64)> {
+ vec![
+ (0, "Abs X".to_string(), 0.0, 44704.0),
+ (1, "Abs Y".to_string(), 0.0, 27940.0),
+ (2, "Abs Pressure".to_string(), 0.0, 65535.0),
+ (3, "Abs Tilt X".to_string(), -64.0, 63.0),
+ ]
+ }
+
+ fn pen(id: u16) -> Device {
+ Device::new(id, "Wacom Intuos Pro M Pen stylus", Some("STYLUS"), pen_valuators())
+ }
+
+ fn mouse(id: u16) -> Device {
+ Device::new(id, "Logitech USB Optical Mouse", Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)])
+ }
+
+ #[test]
+ fn the_mask_pairs_valuator_numbers_with_values() {
+ let v: Vec<_> = valuators(&[0b10_0101, 1], &[1.0, 2.0, 3.0, 4.0]).collect();
+ assert_eq!(v, vec![(0, 1.0), (2, 2.0), (5, 3.0), (32, 4.0)]);
+ // Fewer values than mask bits (malformed): the values that are there.
+ assert_eq!(valuators(&[u32::MAX; 4], &[7.0]).count(), 1);
+ assert_eq!(valuators(&[], &[7.0]).count(), 0);
+ }
+
+ #[test]
+ fn a_device_with_an_abs_pressure_valuator_is_a_pen() {
+ assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));
+ assert_eq!(mouse(12).pressure, None);
+ let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
+ assert_eq!(pad.pressure, None, "a tablet's pad is no pen");
+ let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
+ assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");
+ // The types libinput and XWayland give pens, and a device whose type wasn't listed.
+ for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {
+ assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");
+ }
+ // xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".
+ for kind in ["TOUCHPAD", "TOUCHSCREEN"] {
+ assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");
+ }
+ }
+
+ /// The eraser end of a pen is a device of its own, whose name or XInput type contains
+ /// "eraser", as xf86-input-wacom, xf86-input-libinput and XWayland list it.
+ #[test]
+ fn an_eraser_device_is_named_so() {
+ let erasers = [
+ ("Wacom Intuos Pro M Pen eraser", Some("ERASER")),
+ ("HUION Kamvas Pen Eraser (0x1)", Some("ERASER")),
+ ("xwayland-tablet eraser:16", Some("xwayland-tablet eraser")),
+ ("Wacom Intuos Pro M Pen eraser", None),
+ ("Tablet Pen (0x2)", Some("ERASER")),
+ ];
+ for (name, kind) in erasers {
+ let d = Device::new(10, name, kind, pen_valuators());
+ assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");
+ }
+ let tips = [
+ ("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),
+ ("HUION Kamvas Pen Pen (0x1)", Some("STYLUS")),
+ ("xwayland-tablet stylus:16", Some("xwayland-tablet stylus")),
+ ("Logitech USB Optical Mouse", Some("MOUSE")),
+ ("", None),
+ ];
+ for (name, kind) in tips {
+ assert!(!Device::new(9, name, kind, pen_valuators()).eraser, "{name} ({kind:?})");
+ }
+ }
+
+ #[test]
+ fn the_eraser_device_sets_the_eraser_end() {
+ let mut pens = Pens::default();
+ pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);
+ let feed = StylusFeed::default();
+ pens.raw_event(&feed, 9, &[0b100], &[32767.5]);
+ assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));
+ assert_eq!(feed.eraser(), Some(false));
+ // Turned over, the pen hovers with its eraser end: the eraser device's first event, with
+ // no pressure yet.
+ pens.raw_event(&feed, 10, &[0b1], &[100.0]);
+ assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));
+ assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));
+ pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
+ assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));
+ // Back to the tip, with the tip's own last pressure.
+ pens.raw_event(&feed, 9, &[0b1], &[200.0]);
+ assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.5)));
+ // A mouse: no pen end.
+ pens.raw_event(&feed, 12, &[0b1], &[3.0]);
+ assert_eq!(feed.eraser(), None);
+ }
+
+ #[test]
+ fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {
+ let mut pens = Pens::default();
+ pens.set_devices([pen(9), mouse(12)]);
+ let feed = StylusFeed::default();
+ pens.raw_event(&feed, 9, &[0b101], &[100.0, 32767.5]);
+ assert_eq!(feed.held(), Some(0.5));
+ // Raw events carry only the valuators that changed: a move without pressure keeps it.
+ pens.raw_event(&feed, 9, &[0b1], &[200.0]);
+ assert_eq!(feed.held(), Some(0.5));
+ pens.raw_event(&feed, 9, &[0b100], &[65535.0]);
+ assert_eq!(feed.held(), Some(1.0));
+ // The mouse moves: no pen pressure until the pen moves again.
+ pens.raw_event(&feed, 12, &[0b1], &[3.0]);
+ assert_eq!(feed.held(), None);
+ pens.raw_event(&feed, 9, &[0b1], &[300.0]);
+ assert_eq!(feed.held(), Some(1.0), "the pen's last pressure");
+ // A master device or one not listed yet changes nothing.
+ pens.raw_event(&feed, 2, &[0b100], &[0.0]);
+ assert_eq!(feed.held(), Some(1.0));
+ // A pen whose first event carries no pressure is hovering: it presses with 0.
+ pens.set_devices([pen(9), pen(10)]);
+ pens.raw_event(&feed, 10, &[0b1], &[5.0]);
+ assert_eq!(feed.press(), Some(0.0));
+ }
+
+ #[test]
+ fn hostile_device_data_gives_a_pressure_within_0_to_1() {
+ let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];
+ for (min, max) in ranges {
+ let mut pens = Pens::default();
+ pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);
+ for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {
+ let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
+ assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");
+ }
+ }
+ let mut pens = Pens::default();
+ pens.set_devices((0..2000u16).map(|i| Device::new(i, "", None, [])));
+ assert!(pens.devices.len() <= MAX_DEVICES, "the device list is bounded");
+ // Values for valuators the pen doesn't have are ignored.
+ pens.set_devices([pen(3)]);
+ let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::>()) else {
+ panic!("the pen gave no pressure")
+ };
+ assert!(p < 0.001, "{p}");
+ }
+
+ #[test]
+ fn fixed_point_values() {
+ assert_eq!(fp3232(3, 1 << 31), 3.5);
+ assert_eq!(fp3232(-2, 0), -2.0);
+ }
+}
diff --git a/apps/vectorcraft/src/window.rs b/apps/vectorcraft/src/window.rs
index e95326423..abe60f69f 100644
--- a/apps/vectorcraft/src/window.rs
+++ b/apps/vectorcraft/src/window.rs
@@ -110,7 +110,8 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
-/// geometry.
+/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
+/// until the host reports maximized (fixes #3635).
pub fn restore(window: &winit::window::Window, saved: Option) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
@@ -121,22 +122,41 @@ pub fn restore(window: &winit::window::Window, saved: Option) ->
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
+ #[cfg(not(target_os = "linux"))]
if g.maximized {
window.set_maximized(true);
}
g
}
+/// Keep asking the host to maximize until it sticks, or give up after a few frames.
+pub fn apply_pending_maximize(window: &winit::window::Window, want: bool, tries: &mut u8) -> bool {
+ if !want {
+ return false;
+ }
+ if window.is_maximized() {
+ return false;
+ }
+ if *tries == 0 {
+ return false;
+ }
+ *tries -= 1;
+ window.set_maximized(true);
+ true
+}
+
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
-pub fn track(ctx: &egui::Context, geometry: &mut Option) {
+/// `host_maximized` is the window manager's idea of maximized when egui does not report it (#3635).
+pub fn track(ctx: &egui::Context, geometry: &mut Option, host_maximized: Option) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
- if v.maximized == Some(true) {
+ let maximized = v.maximized == Some(true) || host_maximized == Some(true);
+ if maximized {
if let Some(g) = geometry {
g.maximized = true;
}
@@ -222,7 +242,7 @@ mod tests {
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
- ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
+ ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, None)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
@@ -253,4 +273,19 @@ mod tests {
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
assert_eq!(back.window, None);
}
+
+ #[test]
+ fn the_host_maximized_flag_is_remembered_when_egui_does_not_report_it() {
+ let ctx = egui::Context::default();
+ let mut geometry = Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: false });
+ let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1920.0, 1080.0));
+ let mut input = egui::RawInput::default();
+ input.viewports.insert(
+ egui::ViewportId::ROOT,
+ egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: None, ..Default::default() },
+ );
+ let mut out = ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, Some(true)));
+ out.textures_delta.clear();
+ assert_eq!(geometry, Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: true }));
+ }
}
diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs
new file mode 100644
index 000000000..cf17976b5
--- /dev/null
+++ b/apps/vectorcraft/src/windows_stylus.rs
@@ -0,0 +1,299 @@
+//! Windows pen tablets: with Windows Ink, winit reports a pen in contact as a touch and drops its
+//! barrel button, and egui-winit presses the primary button for the touch it makes the pointer,
+//! so a pen pressed with its barrel button held clicks and drags with the left button. The
+//! `WM_POINTERDOWN` message winit is about to handle still has the barrel button's state in its
+//! second-button flag ("analogous to a mouse right button down",
+//! ): [`msg_hook`] notes
+//! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one.
+#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
+
+use std::cell::RefCell;
+
+use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
+
+/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
+/// `wParam`, a pen in contact with its barrel button pressed.
+const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020;
+/// At most this many pointer contacts wait for their touch event.
+const MAX_PENDING: usize = 16;
+
+thread_local! {
+ static STROKES: RefCell = RefCell::new(BarrelStrokes::default());
+}
+
+/// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each
+/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
+/// and whether the barrel button was held.
+/// It never consumes the message.
+#[cfg(all(windows, not(target_vendor = "win7")))]
+#[allow(unsafe_code)]
+pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
+ use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN};
+ // SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
+ // call; `as_ref` gives `None` for a null pointer.
+ let Some(msg) = (unsafe { msg.cast::().as_ref() }) else {
+ return false;
+ };
+ if msg.message == WM_POINTERDOWN {
+ STROKES.with(|s| {
+ if let Ok(mut s) = s.try_borrow_mut() {
+ s.pointer_down(msg.wParam.0);
+ }
+ });
+ }
+ false
+}
+
+/// Before egui reads a frame's input: the primary button of a contact that started with the
+/// barrel button held becomes the secondary button.
+pub fn raw_input_hook(raw: &mut egui::RawInput) {
+ STROKES.with(|s| {
+ if let Ok(mut s) = s.try_borrow_mut() {
+ s.rewrite(&mut raw.events);
+ }
+ });
+}
+
+/// The pen contacts that press the secondary button.
+#[derive(Default)]
+struct BarrelStrokes {
+ /// Each `WM_POINTERDOWN`'s pointer id and whether the barrel button was held, oldest first,
+ /// until the touch of that contact starts.
+ pending: Vec<(u64, bool)>,
+ /// The contact whose touch started with the barrel button held, until it ends, the same pointer
+ /// starts another contact, or another contact starts with the barrel button held.
+ right: Option,
+}
+
+/// A contact that started with the barrel button held.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Right {
+ id: TouchId,
+ /// Its secondary button is down: the press went to egui and the release has not.
+ down: bool,
+}
+
+impl BarrelStrokes {
+ /// A `WM_POINTERDOWN` with this `wParam`, whose low word is the pointer id and whose high word
+ /// holds the pointer flags.
+ fn pointer_down(&mut self, wparam: usize) {
+ if self.pending.len() >= MAX_PENDING {
+ self.pending.remove(0);
+ }
+ self.pending.push(((wparam & 0xffff) as u64, (wparam >> 16) & POINTER_MESSAGE_FLAG_SECONDBUTTON != 0));
+ }
+
+ /// Makes the primary button of the right contact the secondary one. egui-winit sends a touch's
+ /// pointer button with the touch event it comes from: the press after the touch's start and
+ /// the pointer's move to it, the release right after its end. A canceled contact, or one
+ /// whose end never came (Windows does not promise one), gets a secondary release of its own.
+ fn rewrite(&mut self, events: &mut Vec) {
+ let release = |pos: Pos2| Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE };
+ let mut out = Vec::with_capacity(events.len() + 1);
+ // The button event that comes next for the right contact: `Some(true)` for its press,
+ // `Some(false)` for its release.
+ let mut next = None;
+ for mut event in events.drain(..) {
+ let mut after = None;
+ match &mut event {
+ Event::Touch { id, phase, pos, .. } => {
+ let (id, phase, pos) = (*id, *phase, *pos);
+ if phase == TouchPhase::Start {
+ // Each start decides afresh, from the oldest contact of its pointer still waiting.
+ let barrel = match self.pending.iter().position(|&(p, _)| p == id.0) {
+ Some(i) => self.pending.remove(i).1,
+ None => false,
+ };
+ if let Some(r) = self.right
+ && (barrel || r.id == id)
+ {
+ if r.down {
+ out.push(release(pos));
+ }
+ self.right = None;
+ }
+ if barrel {
+ self.right = Some(Right { id, down: false });
+ }
+ }
+ next = None;
+ if let Some(r) = self.right.filter(|r| r.id == id) {
+ match phase {
+ TouchPhase::Start => next = Some(true),
+ TouchPhase::Move => {}
+ TouchPhase::End => {
+ self.right = None;
+ next = Some(false);
+ }
+ TouchPhase::Cancel => {
+ self.right = None;
+ if r.down {
+ after = Some(release(pos));
+ }
+ }
+ }
+ }
+ }
+ Event::PointerMoved(_) if next == Some(true) => {}
+ Event::PointerButton { button, pressed, .. } if next == Some(*pressed) && *button == PointerButton::Primary => {
+ *button = PointerButton::Secondary;
+ if let Some(r) = self.right.as_mut() {
+ r.down = *pressed;
+ }
+ next = None;
+ }
+ _ => next = None,
+ }
+ out.push(event);
+ out.extend(after);
+ }
+ *events = out;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use egui::{TouchDeviceId, pos2};
+
+ /// `POINTER_MESSAGE_FLAG_INRANGE`, `_INCONTACT` and `_FIRSTBUTTON` (`winuser.h`): a pen tip
+ /// touching down.
+ const TIP: usize = 0x0002 | 0x0004 | 0x0010;
+ /// `POINTER_MESSAGE_FLAG_INRANGE` and `_INCONTACT` with the barrel button.
+ const BARREL: usize = 0x0002 | 0x0004 | POINTER_MESSAGE_FLAG_SECONDBUTTON;
+
+ fn down(strokes: &mut BarrelStrokes, id: usize, flags: usize) {
+ strokes.pointer_down((flags << 16) | id);
+ }
+
+ /// The events egui reads after the hook.
+ fn rewrite(strokes: &mut BarrelStrokes, events: &[Event]) -> Vec {
+ let mut events = events.to_vec();
+ strokes.rewrite(&mut events);
+ events
+ }
+
+ fn touch(id: u64, phase: TouchPhase) -> Event {
+ Event::Touch { device_id: TouchDeviceId(1), id: TouchId(id), phase, pos: pos2(10.0, 20.0), force: Some(0.5) }
+ }
+
+ fn button(button: PointerButton, pressed: bool) -> Event {
+ Event::PointerButton { pos: pos2(10.0, 20.0), button, pressed, modifiers: Modifiers::NONE }
+ }
+
+ /// What egui-winit sends for the touch that drives the pointer.
+ fn start(id: u64) -> Vec {
+ vec![touch(id, TouchPhase::Start), Event::PointerMoved(pos2(10.0, 20.0)), button(PointerButton::Primary, true)]
+ }
+
+ fn end(id: u64) -> Vec {
+ vec![touch(id, TouchPhase::End), button(PointerButton::Primary, false), Event::PointerGone]
+ }
+
+ fn buttons(events: &[Event]) -> Vec<(PointerButton, bool)> {
+ events.iter().filter_map(|e| if let Event::PointerButton { button, pressed, .. } = e { Some((*button, *pressed)) } else { None }).collect()
+ }
+
+ const SECONDARY_DOWN: (PointerButton, bool) = (PointerButton::Secondary, true);
+ const SECONDARY_UP: (PointerButton, bool) = (PointerButton::Secondary, false);
+ const PRIMARY_DOWN: (PointerButton, bool) = (PointerButton::Primary, true);
+ const PRIMARY_UP: (PointerButton, bool) = (PointerButton::Primary, false);
+
+ #[test]
+ fn a_pen_contact_with_the_barrel_button_presses_the_secondary_button() {
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ assert_eq!(buttons(&rewrite(&mut strokes, &start(7))), [SECONDARY_DOWN]);
+ let moved = [touch(7, TouchPhase::Move), Event::PointerMoved(pos2(15.0, 25.0))];
+ assert_eq!(rewrite(&mut strokes, &moved), moved);
+ assert_eq!(buttons(&rewrite(&mut strokes, &end(7))), [SECONDARY_UP]);
+ assert_eq!(strokes.right, None, "the next contact starts afresh");
+ assert!(strokes.pending.is_empty());
+ }
+
+ #[test]
+ fn a_pen_contact_with_the_tip_alone_keeps_the_primary_button() {
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, TIP);
+ let events = [start(7), end(7)].concat();
+ assert_eq!(rewrite(&mut strokes, &events), events);
+ }
+
+ #[test]
+ fn contacts_of_a_pen_within_one_frame_keep_their_order() {
+ // A tap with the barrel button held, then one with the tip alone, before the next frame.
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ down(&mut strokes, 7, TIP);
+ let events = rewrite(&mut strokes, &[start(7), end(7), start(7), end(7)].concat());
+ assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]);
+ }
+
+ #[test]
+ fn a_mouse_button_right_after_a_barrel_contact_keeps_its_button() {
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ let events = [start(7), vec![button(PointerButton::Primary, true)], end(7), vec![button(PointerButton::Primary, false)]].concat();
+ assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, SECONDARY_UP, PRIMARY_UP]);
+ }
+
+ #[test]
+ fn other_touches_and_the_mouse_keep_the_primary_button_during_a_barrel_contact() {
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ down(&mut strokes, 9, TIP);
+ let events = [start(7), start(9), vec![button(PointerButton::Primary, true)], end(9)].concat();
+ assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, PRIMARY_DOWN, PRIMARY_UP]);
+ assert_eq!(strokes.right, Some(Right { id: TouchId(7), down: true }), "the barrel contact goes on");
+ }
+
+ #[test]
+ fn a_contact_after_a_lost_end_releases_the_secondary_button_first() {
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ rewrite(&mut strokes, &start(7));
+ // No end arrives for that contact; the pen touches down again with the tip alone.
+ down(&mut strokes, 7, TIP);
+ let events = rewrite(&mut strokes, &[start(7), end(7)].concat());
+ assert_eq!(buttons(&events), [SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]);
+ assert_eq!(strokes.right, None);
+ // Another pen's barrel contact takes over from one whose end was lost.
+ down(&mut strokes, 7, BARREL);
+ rewrite(&mut strokes, &start(7));
+ down(&mut strokes, 8, BARREL);
+ let events = rewrite(&mut strokes, &[start(8), end(8)].concat());
+ assert_eq!(buttons(&events), [SECONDARY_UP, SECONDARY_DOWN, SECONDARY_UP]);
+ }
+
+ #[test]
+ fn a_canceled_barrel_contact_releases_the_secondary_button() {
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ let events = rewrite(&mut strokes, &[start(7), vec![touch(7, TouchPhase::Cancel), Event::PointerGone]].concat());
+ assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP]);
+ assert_eq!(strokes.right, None);
+ }
+
+ #[test]
+ fn a_barrel_contact_that_never_pressed_releases_nothing() {
+ // A finger drives the pointer, so egui-winit sends no button for the pen.
+ let mut strokes = BarrelStrokes::default();
+ down(&mut strokes, 7, BARREL);
+ let events = [vec![touch(7, TouchPhase::Start)], vec![touch(7, TouchPhase::Cancel)]].concat();
+ assert_eq!(rewrite(&mut strokes, &events), events);
+ down(&mut strokes, 7, BARREL);
+ down(&mut strokes, 7, TIP);
+ let events = [vec![touch(7, TouchPhase::Start)], start(7)].concat();
+ assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]);
+ }
+
+ #[test]
+ fn contacts_whose_touch_never_arrives_are_bounded() {
+ let mut strokes = BarrelStrokes::default();
+ for id in 0..40 {
+ down(&mut strokes, id, BARREL);
+ }
+ assert_eq!(strokes.pending.len(), MAX_PENDING);
+ assert_eq!(strokes.pending.last(), Some(&(39, true)));
+ }
+}
diff --git a/apps/vectorcraft/tests/automation_args.rs b/apps/vectorcraft/tests/it/automation_args.rs
similarity index 80%
rename from apps/vectorcraft/tests/automation_args.rs
rename to apps/vectorcraft/tests/it/automation_args.rs
index ffb8773ef..93b33fa16 100644
--- a/apps/vectorcraft/tests/automation_args.rs
+++ b/apps/vectorcraft/tests/it/automation_args.rs
@@ -1,6 +1,3 @@
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
@@ -25,7 +22,9 @@ fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
.stderr(Stdio::piped())
.spawn()
.unwrap();
- let deadline = Instant::now() + Duration::from_secs(5);
+ // Long enough for the binary to start on a loaded machine (the workspace's other tests run
+ // meanwhile), short enough to catch a desktop that started instead of exiting.
+ let deadline = Instant::now() + Duration::from_secs(30);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
@@ -58,11 +57,17 @@ fn missing_empty_or_option_operands_fail_before_startup() {
}
}
+/// #3546: a root flag may be given more than once, in either spelling; a bad later operand still
+/// stops the app before it starts.
#[test]
-fn duplicate_roots_fail_for_both_spellings() {
+fn repeated_roots_are_accepted_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] {
- rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
- rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
+ let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
+ assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
+ assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
+ assert!(out.stderr.is_empty());
+ rejects(&[flag, ".", flag, ""], &format!("{flag} needs a non-empty folder"));
+ rejects(&[&format!("{flag}=."), flag], &format!("{flag} needs a folder"));
}
}
diff --git a/apps/vectorcraft/tests/help.rs b/apps/vectorcraft/tests/it/help.rs
similarity index 92%
rename from apps/vectorcraft/tests/help.rs
rename to apps/vectorcraft/tests/it/help.rs
index 5c77ecdbb..7c7624031 100644
--- a/apps/vectorcraft/tests/help.rs
+++ b/apps/vectorcraft/tests/it/help.rs
@@ -1,6 +1,3 @@
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
diff --git a/apps/vectorcraft/tests/it/main.rs b/apps/vectorcraft/tests/it/main.rs
new file mode 100644
index 000000000..d9dcf39cb
--- /dev/null
+++ b/apps/vectorcraft/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! Integration tests: one test binary for the crate, one module per area.
+// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod automation_args;
+mod help;
diff --git a/assets/icons/dc-para-bullets.svg b/assets/icons/dc-para-bullets.svg
new file mode 100644
index 000000000..b87e67860
--- /dev/null
+++ b/assets/icons/dc-para-bullets.svg
@@ -0,0 +1 @@
+
diff --git a/contributors/people.toml b/contributors/people.toml
index 62fcd8d18..f8c651239 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -140,3 +140,22 @@ sync_github_name = false
[people.KaboomFox]
sync_github_name = true
+
+[people.odysseexp]
+real_name = "Harley Paiva"
+sync_github_name = true
+
+[people.addibble]
+sync_github_name = true
+
+[people.mockydevs]
+real_name = "Don Omondi"
+display_name = "Don Omondi"
+
+[people.alpha-rock]
+display_name = "Aditya Sisodiya"
+
+[people.Ronfo2013]
+real_name = "Angelo Bernardini"
+display_name = "Angelo Bernardini"
+sync_github_name = false
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index 4ffdbabd8..62e2974d2 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -11,6 +11,7 @@ Affinity files.
| Affinity | VectorCraft |
|---|---|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
+| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
@@ -21,22 +22,24 @@ Affinity files.
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
-| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
+| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
| Fill layers (`FRst`) | filled rectangles |
-| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
+| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
+| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
-| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
-| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
+| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
+| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
+| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
-dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
-transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
+dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
+transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
-active one is used), outlined or scaled text, text fields such as page numbers, frames with
-columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
-document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
+active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
+columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
+without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
with a warning that names the reason; that preview can't be placed, used as a template or mined
@@ -78,10 +81,22 @@ of CC0 sample files.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
-The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
+The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
+`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
+overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
+([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
+here, from the same public fixtures and the thumbnails of documents their owners made, used only
+locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
+was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
+text-frame fixture.
## Validation
+* `engine/tests/it/affinity_features.rs` covers pages, several artboards per canvas and per spread,
+ master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
+ documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
+ `engine/tests/it/affinity_local.rs` compares every artboard of local documents with their thumbnail
+ when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations.
@@ -91,7 +106,7 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
feature probes plus one separately identified derived regression input from
[SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance
notices were inspected, never that project's importer or authoring scripts.
-* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current
+* `tests/it/real_files.rs` parses every file and freezes the modeled features of all 33 current
`.af` files. Native raster tests compare decoded pixels with independent embedded images;
text tests assert Unicode run boundaries, fallback fonts and feature retention.
* `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft`
@@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
multiple spreads and resolution conversion, alongside text recovery and native save/export.
-* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
- that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
- reader (`container`) and the object stream (`stream`):
+* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
+ documents with layer effects, colour overlays (also over tables), line styles, paragraph and
+ character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
+ and the embedded stream), and checks that whatever opens also renders and exports.
+ `tests/it/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
+ cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
+ of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
+ stream (`stream`):
```sh
cd crates/affinity
@@ -132,11 +152,23 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
-Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
-(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
-million characters per node before allocation, with a cumulative 16 MiB budget for owned font
-names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
-text, preserving its characters.
+Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
+megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
+their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
+they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
+the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
+content that many layers link, or a document embedded many times, can't multiply them. Embedded
+documents nest four deep at most; both of their readings are charged to that budget and to the
+importing document's archive limits, also when they fail part of the way. Tables have at most
+4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
+limited to 1024 stops. Text collection is bounded to a million characters per node before
+allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
+features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
+warns and uses default attributes for remaining text, preserving its characters. Colour and
+gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
+each is baked into); past it they are left out, and said. In VectorCraft, master pages are
+copied onto at most as many objects, characters and pixels as a document may hold, a few times
+over, and objects beyond `MAX_COORD` of the origin are left out.
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs
index 31179a3a0..6b1df9510 100644
--- a/crates/affinity/src/container.rs
+++ b/crates/affinity/src/container.rs
@@ -263,6 +263,21 @@ impl<'a> Archive<'a> {
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
+ /// What is left of the limits for documents embedded in this one.
+ pub(crate) fn remaining(&self) -> Limits {
+ Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) }
+ }
+
+ /// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
+ pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
+ let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
+ if total > self.limits.max_total {
+ return Err(Error::Limit("document too large"));
+ }
+ self.extracted = total;
+ Ok(())
+ }
+
pub fn read(&mut self, name: &str) -> Result, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs
index 5d74638ad..8e84e4807 100644
--- a/crates/affinity/src/geometry.rs
+++ b/crates/affinity/src/geometry.rs
@@ -215,7 +215,13 @@ fn anchors(records: &[Value]) -> Option> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
- let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
+ // The largest radius of each record, once (not a scan of all of them for each anchor).
+ let mut largest: std::collections::HashMap = std::collections::HashMap::new();
+ for (rec, r) in radii {
+ let e = largest.entry(*rec).or_insert(0.0);
+ *e = e.max(*r);
+ }
+ let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs
index 4be46c777..38d86a899 100644
--- a/crates/affinity/src/lib.rs
+++ b/crates/affinity/src/lib.rs
@@ -14,6 +14,7 @@ mod access;
pub mod container;
mod geometry;
pub mod model;
+pub mod overlay;
pub mod paint;
pub mod preview;
mod raster;
diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs
index 298e32be2..7f10f8823 100644
--- a/crates/affinity/src/model.rs
+++ b/crates/affinity/src/model.rs
@@ -1,4 +1,4 @@
-//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
+//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes,
//! text and images, with transforms composed into document space. Anything else is reported
//! by [`Document::warnings`] instead of being dropped silently.
@@ -175,17 +175,54 @@ pub struct TextRun {
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
pub features: Vec,
pub all_caps: bool,
+ /// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
+ /// owner made, all other characters there and in the public corpus have 0).
+ pub underline: bool,
+ /// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
+ /// through in another document its owner made).
+ pub strikethrough: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
+ /// Horizontal scale of the glyphs (1 = as designed).
+ pub h_scale: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option,
pub fill: Paint,
+ /// Outlined characters: the outline's paint and width (node space).
+ pub stroke: Option,
+ /// The run's paragraph: alignment, indents and spacing.
+ pub paragraph: Paragraph,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
+/// node's text scale).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Paragraph {
+ pub align: Align,
+ /// Indent of every line but the first, from the left.
+ pub left: f64,
+ pub right: f64,
+ /// Indent of the first line, from the left.
+ pub first: f64,
+ pub space_before: f64,
+ pub space_after: f64,
+ /// Fixed distance between baselines, when the paragraph sets one.
+ pub leading: Option,
+ /// Automatic leading as a multiple of the font's own line height (ascent plus descent).
+ pub auto_leading: f64,
+}
+
+impl Default for Paragraph {
+ fn default() -> Self {
+ Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
+ #[default]
Left,
Center,
Right,
@@ -202,12 +239,69 @@ pub struct Text {
pub frame: Option,
/// Node space to document pixels.
pub transform: Affine,
+ /// Node space to document pixels for the type (its linear part matters): artistic text scales
+ /// with its node, frame text only with its frame's text scale.
+ pub text_transform: Affine,
+}
+
+/// Vertical alignment of a table cell's text.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum VAlign {
+ Top,
+ Center,
+ Bottom,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct TableCell {
+ pub runs: Vec,
+ /// Space between the cell edges and its text, in node space: left, top, right, bottom.
+ pub inset: [f64; 4],
+ pub valign: VAlign,
+ /// Part of the cell to its left (merged): it has no text of its own.
+ pub merged_left: bool,
+}
+
+/// A table: a grid of cells with text, and a line on every cell edge.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Table {
+ /// Column boundaries (one more than the columns), increasing, in node space.
+ pub columns: Vec,
+ /// Row boundaries (one more than the rows), increasing, in node space.
+ pub rows: Vec,
+ /// Row by row.
+ pub cells: Vec,
+ /// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
+ /// the list is empty when the file's didn't match the grid.
+ pub vertical: Vec>,
+ /// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
+ /// as `vertical` can be).
+ pub horizontal: Vec >,
+ /// Node space to document pixels.
+ pub transform: Affine,
+ /// Node space to document pixels for the cells' type: resizing a table moves its grid, not
+ /// its type.
+ pub text_transform: Affine,
+}
+
+impl Table {
+ pub fn ncols(&self) -> usize {
+ self.columns.len().saturating_sub(1)
+ }
+ pub fn nrows(&self) -> usize {
+ self.rows.len().saturating_sub(1)
+ }
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec),
+ /// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
+ /// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
+ /// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
+ /// into a blue cast.
+ Cmyka8(Vec),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec),
}
@@ -246,11 +340,78 @@ pub enum Kind {
even_odd: bool,
},
Text(Text),
+ Table(Table),
Image(Image),
+ /// A master page shown on this spread's pages: pages `page..page + count` show the master
+ /// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
+ MasterInstance {
+ master: usize,
+ page: usize,
+ master_page: usize,
+ count: usize,
+ },
/// Something this reader does not import; its children are still imported.
Unsupported,
}
+/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
+/// (radians, clockwise from +x as y points down: π/2 puts it below).
+#[derive(Debug, Clone, PartialEq)]
+pub enum Effect {
+ DropShadow {
+ color: crate::paint::Color,
+ opacity: f64,
+ radius: f64,
+ distance: f64,
+ angle: f64,
+ },
+ InnerShadow {
+ color: crate::paint::Color,
+ opacity: f64,
+ radius: f64,
+ distance: f64,
+ angle: f64,
+ },
+ OuterGlow {
+ color: crate::paint::Color,
+ opacity: f64,
+ radius: f64,
+ },
+ /// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
+ Outline {
+ color: crate::paint::Color,
+ opacity: f64,
+ width: f64,
+ align: u16,
+ },
+ /// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
+ /// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
+ /// `inner` is false for the outer, emboss and pillow kinds.
+ Bevel {
+ size: f64,
+ depth: f64,
+ angle: f64,
+ highlight: crate::paint::Color,
+ highlight_opacity: f64,
+ shadow: crate::paint::Color,
+ shadow_opacity: f64,
+ inner: bool,
+ },
+ /// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
+ /// `opacity` (the node's own alpha is kept).
+ ColorOverlay {
+ color: crate::paint::Color,
+ opacity: f64,
+ blend: Blend,
+ },
+ /// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
+ GradientOverlay {
+ gradient: crate::paint::Gradient,
+ opacity: f64,
+ blend: Blend,
+ },
+}
+
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
@@ -264,6 +425,8 @@ pub struct Node {
pub mask: Option,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option,
+ /// Layer effects the importer can show or bake in (others are reported).
+ pub effects: Vec,
/// Bottom to top.
pub children: Vec,
}
@@ -285,12 +448,21 @@ pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec,
+ /// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
+ pub masters: Vec,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec,
+ /// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
+ /// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
+ /// colorimetric intent and black-point compensation in every document checked.
+ pub cmyk_profile: Option>,
}
+/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
+const MAX_PROFILE_BYTES: usize = 32 << 20;
+
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
@@ -300,6 +472,32 @@ const MAX_NODES: usize = 500_000;
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
+/// Embedded documents inside embedded documents are followed this deep.
+const MAX_EMBED_DEPTH: usize = 4;
+/// Characters of all stories together (a story may be shared by many text layers).
+const MAX_DOCUMENT_CHARS: usize = 16 << 20;
+/// Table cells of all tables together (each becomes a text object with its edge lines).
+const MAX_DOCUMENT_CELLS: usize = 1 << 18;
+
+/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
+/// documents are read with what their embedding document left, so linking or embedding the
+/// same content many times can't multiply the work.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct Budget {
+ /// Layer nodes ([`MAX_NODES`] in all).
+ pub(crate) nodes: usize,
+ /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
+ pub(crate) pixels: u64,
+ /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
+ pub(crate) chars: usize,
+ /// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
+ pub(crate) cells: usize,
+}
+
+impl Budget {
+ pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
+}
+
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result {
read_within(bytes, limits, MAX_LAYER_PIXELS)
@@ -307,35 +505,84 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result {
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result {
- let mut archive = Archive::open(bytes, limits)?;
+ read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
+}
+
+/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
+/// either way.
+struct ReadAt {
+ document: Result,
+ extracted: usize,
+}
+
+/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
+/// deep). What it uses is taken from `budget`, also when it fails part of the way.
+/// The most characters of a layer's name kept.
+const MAX_NAME_CHARS: usize = 1024;
+
+fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
+ let mut archive = match Archive::open(bytes, limits) {
+ Ok(a) => a,
+ Err(e) => return ReadAt { document: Err(e), extracted: 0 },
+ };
+ let document = read_archive(&mut archive, budget, base, depth);
+ ReadAt { document, extracted: archive.extracted() }
+}
+
+fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result {
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
- let mut reader = Reader::new(&s, &mut archive);
- reader.pixels_left = pixels;
- reader.document()
+ let mut reader = Reader::new(&s, archive, base, depth);
+ reader.budget = *budget;
+ let document = reader.document();
+ *budget = reader.budget;
+ document
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
- warnings: BTreeMap,
- nodes: usize,
+ pub(crate) warnings: BTreeMap,
active: HashSet,
- /// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
- pub(crate) pixels_left: u64,
+ /// Where the spreads go: the identity, or an embedding document's placement.
+ base: Affine,
+ /// The linear part of `base`, which also scales frame text.
+ pub(crate) type_base: Affine,
+ /// How deep inside embedded documents this one is.
+ depth: usize,
+ /// An embedded document's content, waiting to become its node's children.
+ pending: Option>,
+ /// The master spreads, in the order of [`Document::masters`].
+ master_ids: Vec,
+ /// What the rest of the read may use.
+ pub(crate) budget: Budget,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
- fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
- Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
+ pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
+ let [a, b, c, d, ..] = base.0;
+ Self {
+ s,
+ archive,
+ dpi: 72.0,
+ warnings: BTreeMap::new(),
+ active: HashSet::new(),
+ base,
+ type_base: Affine([a, b, c, d, 0.0, 0.0]),
+ depth,
+ pending: None,
+ master_ids: Vec::new(),
+ budget: Budget::FULL,
+ }
}
pub(crate) fn warn(&mut self, what: impl Into) {
*self.warnings.entry(what.into()).or_default() += 1;
}
- fn document(mut self) -> Result {
+ /// The document.
+ fn document(&mut self) -> Result {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
@@ -347,40 +594,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
+ self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") {
- if !s.is(spread, b"Sprd") {
- self.warn("an unknown kind of page");
- continue;
+ if let Some(sp) = self.spread(doc, spread)? {
+ spreads.push(sp);
}
- let pages = pages(s, spread);
- let default_size =
- s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
- let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
- x0: 0.0,
- y0: 0.0,
- x1: 0.0,
- y1: 0.0,
- });
- let transparent = s.bool(spread, b"SprT").unwrap_or(false);
- let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
- if mask.is_some() {
- self.warn("mask layers directly on a page (imported without them)");
- }
- spreads.push(Spread { bounds, pages, transparent, nodes });
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
- if !s.objs(doc, b"MpCh").is_empty() {
- self.warn("master pages (their content is not placed on the pages that use them)");
+ let mut masters = Vec::new();
+ for master in self.master_ids.clone() {
+ match self.spread(doc, master)? {
+ Some(sp) => masters.push(sp),
+ // Keep the indices of `MasterInstance::master` valid.
+ None => masters.push(Spread {
+ bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
+ pages: Vec::new(),
+ transparent: true,
+ nodes: Vec::new(),
+ }),
+ }
}
- let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
- Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
+ let cmyk_profile = s
+ .obj(root, b"CMYP")
+ .and_then(|p| s.bytes(p, b"Data"))
+ .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
+ .map(<[u8]>::to_vec);
+ let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
+ Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
}
- /// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
- fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option), Error> {
+ /// A spread (`Sprd`) of the document `doc`, or of its master spreads.
+ fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> {
+ let s = self.s;
+ if !s.is(spread, b"Sprd") {
+ self.warn("an unknown kind of page");
+ return Ok(None);
+ }
+ let pages = pages(s, spread);
+ let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
+ let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
+ x0: 0.0,
+ y0: 0.0,
+ x1: 0.0,
+ y1: 0.0,
+ });
+ let transparent = s.bool(spread, b"SprT").unwrap_or(false);
+ let (nodes, mask) = self.children(spread, self.base, 0)?;
+ if mask.is_some() {
+ self.warn("mask layers directly on a page (imported without them)");
+ }
+ Ok(Some(Spread { bounds, pages, transparent, nodes }))
+ }
+
+ /// The children of `parent`, and the mask layer among them that masks the parent (with its
+ /// placement), read later over just what it masks.
+ #[allow(clippy::type_complexity)]
+ fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
@@ -391,10 +663,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
- match crate::raster::mask(self, c, local.then(world))? {
- Some(m) if mask.is_none() => mask = Some(m),
- Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
- None => {}
+ if mask.is_none() {
+ mask = Some((c, local.then(world)));
+ } else {
+ self.warn("several pixel masks on one layer (only the first is used)");
}
continue;
}
@@ -411,10 +683,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
- self.nodes += 1;
- if self.nodes > MAX_NODES {
+ let Some(left) = self.budget.nodes.checked_sub(1) else {
return Err(Error::Limit("too many layers"));
- }
+ };
+ self.budget.nodes = left;
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
@@ -423,7 +695,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
- let name = s.str(id, b"Desc").unwrap_or_default().to_string();
+ // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
+ let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
@@ -441,15 +714,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} else {
self.kind(id, class, world, parent)?
};
+ // This node's embedded content, before its children's `node` calls can take it.
+ let embedded = self.pending.take();
// A compound's children are its operands, already merged into its outline.
- let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
- let (mask, attached_mask) = self.attached_masks(id, world)?;
+ let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
+ if let Some(embedded) = embedded {
+ children.extend(embedded);
+ }
+ // Pixel masks are read only over what they mask (they can span a whole spread at print
+ // resolution, a gigapixel).
+ let over = mask_extent(&kind, &children);
+ let child_mask = match child_mask {
+ Some((c, at)) => crate::raster::mask(self, c, at, over)?,
+ None => None,
+ };
+ let (mask, attached_mask) = self.attached_masks(id, world, over)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
- if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
- self.warn("layer effects (shadows, glows, outlines, bevels…)");
- }
- Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
+ let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
+ // Gradient overlays are laid out over the node's bounds, also while it is hidden.
+ let bounds = tree_bounds(&node);
+ node.effects = self.effects(id, world, bounds);
+ Ok(Some(node))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result {
@@ -471,6 +757,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
+ b"TxtT" => match crate::text::table(self, id, world) {
+ Some(t) => Kind::Table(t),
+ None => {
+ self.warn("tables that could not be read");
+ Kind::Unsupported
+ }
+ },
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
@@ -482,15 +775,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
- b"EmbN" => match crate::raster::embedded(self, id, world)? {
- Some(img) => {
- self.warn("embedded documents and symbols (imported as pictures of them)");
- Kind::Image(img)
+ b"MPIN" => {
+ // `SLnk` links the instance with the master spread it shows.
+ let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
+ let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
+ let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
+ match master {
+ Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
+ None => {
+ self.warn("master page instances without their master");
+ Kind::Unsupported
+ }
}
- None => {
- self.warn("embedded documents and symbols without a cached picture");
- Kind::Unsupported
+ }
+ b"EmbN" => match self.embedded_document(id, world) {
+ // The embedded document itself, as editable content.
+ Some(nodes) => {
+ self.pending = Some(nodes);
+ Kind::Group
}
+ None => match crate::raster::embedded(self, id, world)? {
+ Some(img) => {
+ self.warn("embedded documents and symbols (imported as pictures of them)");
+ Kind::Image(img)
+ }
+ None => {
+ self.warn("embedded documents and symbols without a cached picture");
+ Kind::Unsupported
+ }
+ },
},
b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space.
@@ -515,9 +828,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
})
}
+ /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
+ /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
+ fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec {
+ let s = self.s;
+ let mut out = Vec::new();
+ for e in s.objs(id, b"FiEf").into_iter().take(64) {
+ if s.bool(e, b"Enab") == Some(false) {
+ continue;
+ }
+ // "Scale with object": lengths follow the node's transform; otherwise they are absolute.
+ let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
+ let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
+ let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
+ let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
+ let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
+ let class = s.class(e).map(|c| c.0.to_be_bytes());
+ match (class.as_ref(), color) {
+ (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
+ (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
+ (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
+ (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
+ let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
+ out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
+ }
+ (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
+ (Some(b"BevE" | b"EmbE"), _) => {
+ // No radius, no bevel (as Affinity draws it).
+ let size = len(b"Radi");
+ if size > 0.0 {
+ let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
+ let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
+ let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
+ let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
+ let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
+ let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
+ let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
+ let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
+ out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
+ }
+ }
+ (Some(b"ColO"), Some(color)) => {
+ let blend = self.effect_blend(e);
+ out.push(Effect::ColorOverlay { color, opacity, blend });
+ }
+ (Some(b"GrdO"), _) => {
+ let blend = self.effect_blend(e);
+ // The gradient's unit space spans the node's bounding box from -1 to 1, centred
+ // on it (y down): checked against the stored previews of real documents.
+ let space =
+ bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
+ match (s.obj(e, b"GrFl"), space) {
+ (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
+ Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
+ Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
+ _ => {}
+ },
+ // Nothing visible to lay it over.
+ (Some(_), None) => {}
+ (None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
+ }
+ }
+ (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
+ (Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
+ _ => self.warn("layer effects of other kinds (left out)"),
+ }
+ }
+ out
+ }
+
+ /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
+ fn effect_blend(&mut self, e: ObjId) -> Blend {
+ match self.s.enumeration(e, b"BlnM") {
+ None => Blend::Normal,
+ Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
+ self.warn("an unknown blend mode (imported as Normal)");
+ Blend::Normal
+ }),
+ }
+ }
+
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
- fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> {
+ fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option = None;
@@ -534,7 +927,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
- match crate::raster::mask(self, a, local)? {
+ match crate::raster::mask(self, a, local, over)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
@@ -546,6 +939,93 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((mask, pixels))
}
+ /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
+ /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
+ /// placed so the centre of its page, first artboard or content is the node's origin.
+ fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> {
+ let s = self.s;
+ let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
+ if entry.is_empty() {
+ return None;
+ }
+ if self.depth >= MAX_EMBED_DEPTH {
+ self.warn("embedded documents nested more than four deep");
+ return None;
+ }
+ let data = self.archive.read(&entry).ok()?;
+ let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
+ if !crate::is_affinity(inner) {
+ return None;
+ }
+ // A first reading finds its page and resolution, the second places it.
+ // Both readings are charged to the budget and the archive's limits, also when they fail:
+ // each does the work.
+ let mut budget = self.budget;
+ let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
+ self.budget = budget;
+ if self.archive.charge(probe.extracted).is_err() {
+ self.warn("embedded documents beyond the size limit (left out)");
+ return None;
+ }
+ let probe = match probe.document {
+ Ok(d) => d,
+ Err(e) => {
+ self.warn(format!("embedded documents that could not be read ({e})"));
+ return None;
+ }
+ };
+ let spread = probe.spreads.first()?;
+ // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
+ let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
+ let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
+ let page = if content { None } else { spread.pages.first().copied().or(first_board) };
+ let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
+ // Its pixels aren't needed any more: never hold both readings' at once.
+ drop(probe);
+ // Its document pixels are the node's units, whatever either document's resolution.
+ let k = 1.0;
+ let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
+ let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
+ if !base.is_finite() {
+ self.warn("embedded documents with an invalid placement");
+ return None;
+ }
+ let mut budget = self.budget;
+ let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
+ self.budget = budget;
+ if self.archive.charge(placed.extracted).is_err() {
+ self.warn("embedded documents beyond the size limit (left out)");
+ return None;
+ }
+ let doc = placed.document.ok()?;
+ for w in doc.warnings {
+ self.warn(format!("{w} [in embedded documents]"));
+ }
+ if doc.spreads.len() > 1 {
+ self.warn("embedded documents with several pages (the first is used)");
+ }
+ let mut nodes = doc.spreads.into_iter().next()?.nodes;
+ // Its master page instances count its own masters, which aren't kept: in this document
+ // they would show other masters.
+ fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
+ let mut found = false;
+ for n in nodes {
+ if matches!(n.kind, Kind::MasterInstance { .. }) {
+ n.kind = Kind::Unsupported;
+ found = true;
+ }
+ if depth < MAX_TREE_DEPTH {
+ found |= strip_masters(&mut n.children, depth + 1);
+ }
+ }
+ found
+ }
+ if strip_masters(&mut nodes, 0) {
+ self.warn("master pages of embedded documents (left out)");
+ }
+ Some(nodes)
+ }
+
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
@@ -580,6 +1060,96 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
}
+/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
+/// and tables), in document pixels.
+pub fn nodes_bounds(nodes: &[Node]) -> Option {
+ let mut out = None;
+ walk_bounds(nodes, true, 0, &mut out);
+ out
+}
+
+/// The bounds of `node` and everything in it, hidden or not.
+fn tree_bounds(node: &Node) -> Option {
+ let mut out = None;
+ walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
+ out
+}
+
+fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) {
+ if depth > MAX_TREE_DEPTH {
+ return;
+ }
+ for n in nodes.iter().filter(|n| n.visible || !visible_only) {
+ kind_points(&n.kind, &mut |p| add_point(out, p));
+ walk_bounds(&n.children, visible_only, depth + 1, out);
+ }
+}
+
+/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
+/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
+/// artistic text, whose glyphs only the layout places.
+fn mask_extent(kind: &Kind, children: &[Node]) -> Option {
+ fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool {
+ if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
+ return false;
+ }
+ let mut own = kind_bounds(kind);
+ if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
+ // As far as a stroke can reach out of its path, miter joins included.
+ let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
+ own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
+ }
+ if let Some(r) = own {
+ *out = Some(out.map_or(r, |o| union(o, r)));
+ }
+ children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
+ }
+ let mut out = None;
+ if visit(kind, children, 0, &mut out) { out } else { None }
+}
+
+/// The bounds of what one node's own kind draws (not its children): its outline's points, its
+/// image's or frame's corners, or artistic text's anchor.
+pub fn kind_bounds(kind: &Kind) -> Option {
+ let mut out = None;
+ kind_points(kind, &mut |p| add_point(&mut out, p));
+ out
+}
+
+fn add_point(out: &mut Option, p: Point) {
+ if p.x.is_finite() && p.y.is_finite() {
+ let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
+ *out = Some(out.map_or(r, |o| union(o, r)));
+ }
+}
+
+fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
+ let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
+ match kind {
+ Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
+ for sp in &path.subpaths {
+ add(sp.start);
+ for seg in &sp.segments {
+ seg.iter().for_each(|p| add(*p));
+ }
+ }
+ }
+ Kind::Image(img) => {
+ corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
+ }
+ Kind::Text(t) => match t.frame {
+ Some(f) => corners(f, t.transform).into_iter().for_each(add),
+ None => add(t.transform.apply(t.anchor)),
+ },
+ Kind::Table(t) => {
+ if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
+ corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
+ }
+ }
+ Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
+ }
+}
+
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec {
diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs
new file mode 100644
index 000000000..170c50ec3
--- /dev/null
+++ b/crates/affinity/src/overlay.rs
@@ -0,0 +1,663 @@
+//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
+//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
+//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
+//!
+//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
+//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
+//! Darker/Lighter Color) use their usual definitions.
+
+use std::borrow::Cow;
+
+use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
+use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
+
+/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
+const MAX_DEPTH: usize = 256;
+
+/// Most pixels an overlay is baked into per image.
+const MAX_PIXELS: usize = 64 << 20;
+
+/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
+/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
+/// and shared content would otherwise multiply it; past it, overlays are left out and said.
+pub const MAX_WORK: u64 = 1 << 29;
+
+#[derive(Clone, Copy)]
+enum Source<'a> {
+ Solid(Color),
+ Gradient(&'a Gradient),
+}
+
+#[derive(Clone, Copy)]
+struct Overlay<'a> {
+ source: Source<'a>,
+ opacity: f64,
+ blend: Blend,
+}
+
+/// `node` with its colour and gradient overlays baked in, and those of everything in it
+/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
+/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
+pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
+ if overlays(&node.effects).is_empty() {
+ return Cow::Borrowed(node);
+ }
+ let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
+ warn("overlay effects beyond the work one import may take (left out)");
+ return Cow::Borrowed(node);
+ };
+ *work = left;
+ let mut out = node.clone();
+ bake_tree(&mut out, work, 0, warn);
+ Cow::Owned(out)
+}
+
+/// The visible colour and gradient overlays among `effects`.
+fn overlays(effects: &[Effect]) -> Vec> {
+ effects
+ .iter()
+ .filter_map(|e| match e {
+ Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
+ Effect::GradientOverlay { gradient, opacity, blend } => {
+ Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
+ }
+ _ => None,
+ })
+ .filter(|o| o.opacity > 0.0)
+ .collect()
+}
+
+fn is_overlay(e: &Effect) -> bool {
+ matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
+}
+
+/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
+/// lies over what its node shows, its children's overlays included. Each overlay pays for the
+/// subtree it is baked into out of `work`; past it, it is left out (and said).
+fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ for c in &mut node.children {
+ bake_tree(c, work, depth + 1, warn);
+ }
+ if !node.effects.iter().any(is_overlay) {
+ return;
+ }
+ let (effects, rest): (Vec, Vec) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
+ node.effects = rest;
+ let overlays = overlays(&effects);
+ if overlays.is_empty() {
+ return;
+ }
+ let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
+ let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
+ let Some(left) = work.checked_sub(cost) else {
+ warn("overlay effects beyond the work one import may take (left out)");
+ return;
+ };
+ *work = left;
+ for o in &overlays {
+ if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
+ warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
+ }
+ bake_node(node, o, 0, warn);
+ }
+}
+
+/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
+fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
+ if depth > MAX_DEPTH {
+ return 0;
+ }
+ let mut cost: u64 = match &node.kind {
+ Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
+ Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
+ Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
+ _ => 1,
+ };
+ for c in &node.children {
+ if cost > limit {
+ break;
+ }
+ cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
+ }
+ cost
+}
+
+fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ match &mut node.kind {
+ Kind::Shape { fills, strokes, .. } => {
+ for f in fills.iter_mut() {
+ *f = paint(f, o, warn);
+ }
+ strokes.iter_mut().for_each(|s| stroke(s, o, warn));
+ }
+ Kind::Artboard { background, strokes, .. } => {
+ for f in background.iter_mut() {
+ *f = paint(f, o, warn);
+ }
+ strokes.iter_mut().for_each(|s| stroke(s, o, warn));
+ }
+ Kind::Text(t) => {
+ for r in &mut t.runs {
+ r.fill = paint(&r.fill, o, warn);
+ if let Some(s) = &mut r.stroke {
+ stroke(s, o, warn);
+ }
+ }
+ }
+ Kind::Table(t) => {
+ for c in &mut t.cells {
+ for r in &mut c.runs {
+ r.fill = paint(&r.fill, o, warn);
+ if let Some(s) = &mut r.stroke {
+ stroke(s, o, warn);
+ }
+ }
+ }
+ for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
+ stroke(s, o, warn);
+ }
+ }
+ Kind::Image(img) => {
+ if !pixels(img, o) {
+ warn("overlay effects on images too large to recolour (left out)");
+ }
+ }
+ Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
+ }
+ for e in &mut node.effects {
+ match e {
+ Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
+ Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
+ Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
+ }
+ }
+ for c in &mut node.children {
+ bake_node(c, o, depth + 1, warn);
+ }
+}
+
+fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
+ s.paint = paint(&s.paint, o, warn);
+}
+
+/// A paint with the overlay over it.
+fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
+ match (p, o.source) {
+ (Paint::None, _) => Paint::None,
+ (Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
+ (Paint::Gradient(g), Source::Solid(s)) => {
+ Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
+ }
+ // Over one colour, the result is the overlay's gradient with each stop laid over it.
+ (Paint::Solid(c), Source::Gradient(g)) => {
+ Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
+ }
+ (Paint::Gradient(base), Source::Gradient(g)) => {
+ if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
+ warn("gradient overlays on gradients (approximated)");
+ }
+ // Exact when the overlay covers it; otherwise over the base gradient's average colour.
+ let avg = average(base);
+ Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
+ }
+ }
+}
+
+impl Source<'_> {
+ /// One colour for the source (a gradient's average) where only one fits.
+ fn at_solid(&self) -> Color {
+ match self {
+ Source::Solid(c) => *c,
+ Source::Gradient(g) => average(g),
+ }
+ }
+}
+
+fn average(g: &Gradient) -> Color {
+ let n = g.stops.len().max(1) as f64;
+ let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
+ for s in &g.stops {
+ let [sr, sg, sb] = rgb(s.color);
+ r += sr;
+ gr += sg;
+ b += sb;
+ a += s.color.alpha();
+ }
+ Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
+}
+
+/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
+fn over(base: Color, source: Color, o: &Overlay) -> Color {
+ let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
+ let alpha = base.alpha();
+ // Normal mixes within the colour model when both share it, so CMYK stays CMYK.
+ if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
+ let mix = |a: f64, b: f64| a + (b - a) * k;
+ match (base, source) {
+ (Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
+ return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
+ }
+ (Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
+ (_, s) if k >= 1.0 => return with_alpha(s, alpha),
+ _ => {}
+ }
+ }
+ let b = rgb(base);
+ let s = rgb(source);
+ let blended = blend(o.blend, b, s);
+ let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
+ Color::Rgb { r, g, b: bl, a: alpha }
+}
+
+fn with_alpha(c: Color, a: f64) -> Color {
+ match c {
+ Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
+ Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
+ Color::Gray { v, .. } => Color::Gray { v, a },
+ Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
+ }
+}
+
+/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
+/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
+fn rgb(c: Color) -> [f64; 3] {
+ let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
+ match c {
+ Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
+ Color::Gray { v, .. } => [f(v); 3],
+ Color::Cmyk { c, m, y, k, .. } => {
+ let w = 1.0 - f(k);
+ [(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
+ }
+ Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
+ }
+}
+
+/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
+fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
+ let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
+ let (l, a, b) = (finite(l), finite(a), finite(b));
+ let fy = (l + 16.0) / 116.0;
+ let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
+ let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
+ // D50 white.
+ let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
+ // XYZ (D50) to linear sRGB, Bradford-adapted.
+ let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
+ let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
+ let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
+ let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
+ [gamma(r), gamma(g), gamma(bl)]
+}
+
+/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
+fn pixels(img: &mut Image, o: &Overlay) -> bool {
+ let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
+ let Some(len) = n.checked_mul(4) else { return false };
+ let mut px = match &img.pixels {
+ Pixels::Rgba8(p) => match p.get(..len) {
+ Some(p) => p.to_vec(),
+ None => return false,
+ },
+ Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
+ Some(p) => p,
+ None => return false,
+ },
+ // An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
+ Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
+ Some(p) if p.len() == n * 5 => p
+ .as_chunks::<5>()
+ .0
+ .iter()
+ .flat_map(|[c, m, y, k, a]| {
+ let w = 255 - u16::from(*k);
+ let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
+ [ch(*c), ch(*m), ch(*y), *a]
+ })
+ .collect(),
+ _ => return false,
+ },
+ };
+ let width = img.width.max(1) as usize;
+ // A solid source is converted once; a gradient is sampled at each pixel's centre.
+ let solid = match o.source {
+ Source::Solid(c) => Some((rgb(c), c.alpha())),
+ Source::Gradient(_) => None,
+ };
+ let to_unit = match o.source {
+ Source::Gradient(g) => match invert(g.transform) {
+ Some(m) => Some((img.transform.then(m), g)),
+ None => return false,
+ },
+ Source::Solid(_) => None,
+ };
+ for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
+ if p[3] == 0 {
+ continue;
+ }
+ let (s, sa) = match (solid, &to_unit) {
+ (Some(s), _) => s,
+ (None, Some((m, g))) => {
+ let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
+ let c = sample(g, q);
+ (rgb(c), c.alpha())
+ }
+ (None, None) => continue,
+ };
+ let k = (o.opacity * sa).clamp(0.0, 1.0);
+ let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
+ let m = blend(o.blend, b, s);
+ for c in 0..3 {
+ p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
+ }
+ }
+ img.pixels = Pixels::Rgba8(px);
+ true
+}
+
+/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
+fn decode(bytes: &[u8], width: u32, height: u32) -> Option> {
+ let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
+ if probe.into_dimensions().ok()? != (width, height) {
+ return None;
+ }
+ let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
+ let mut limits = image::Limits::default();
+ limits.max_image_width = Some(width);
+ limits.max_image_height = Some(height);
+ limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
+ reader.limits(limits);
+ Some(reader.decode().ok()?.into_rgba8().into_raw())
+}
+
+/// The gradient's colour at unit-space point `q`.
+fn sample(g: &Gradient, q: Point) -> Color {
+ let t = match g.kind {
+ GradientKind::Linear => q.x,
+ GradientKind::Radial => q.x.hypot(q.y),
+ GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
+ };
+ let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
+ let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
+ if t <= first.offset {
+ return first.color;
+ }
+ for w in g.stops.windows(2) {
+ let [a, b] = [&w[0], &w[1]];
+ if t <= b.offset {
+ let span = b.offset - a.offset;
+ let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
+ // Affinity's midpoint bias (see `Stop::half_point`).
+ let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
+ let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
+ let (x, y) = (rgb(a.color), rgb(b.color));
+ let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
+ return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
+ }
+ }
+ last.color
+}
+
+fn invert(m: Affine) -> Option {
+ let [a, b, c, d, e, f] = m.0;
+ let det = a * d - b * c;
+ if !(det.is_finite() && det.abs() > 1e-12) {
+ return None;
+ }
+ let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
+ Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
+}
+
+/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
+fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
+ use Blend::*;
+ let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
+ match mode {
+ Normal | PassThrough | Erase => s,
+ Darken => sep(f64::min),
+ Lighten => sep(f64::max),
+ Multiply => sep(|b, s| b * s),
+ Screen => sep(screen),
+ ColorBurn => sep(burn),
+ ColorDodge => sep(dodge),
+ Blend::Overlay => sep(|b, s| hard_light(s, b)),
+ HardLight => sep(hard_light),
+ SoftLight => sep(soft_light),
+ Difference => sep(|b, s| (b - s).abs()),
+ Exclusion => sep(|b, s| b + s - 2.0 * b * s),
+ Add => sep(|b, s| b + s),
+ Subtract => sep(|b, s| b - s),
+ VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
+ LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
+ PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
+ HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
+ Average => sep(|b, s| (b + s) / 2.0),
+ Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
+ Reflect => sep(reflect),
+ Glow => sep(|b, s| reflect(s, b)),
+ DarkerColor => {
+ if lum(s) < lum(b) {
+ s
+ } else {
+ b
+ }
+ }
+ LighterColor => {
+ if lum(s) > lum(b) {
+ s
+ } else {
+ b
+ }
+ }
+ Hue => set_lum(set_sat(s, sat(b)), lum(b)),
+ Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
+ Blend::Color => set_lum(s, lum(b)),
+ Luminosity => set_lum(b, lum(s)),
+ }
+}
+
+fn screen(b: f64, s: f64) -> f64 {
+ b + s - b * s
+}
+
+fn burn(b: f64, s: f64) -> f64 {
+ if b >= 1.0 {
+ 1.0
+ } else if s <= 0.0 {
+ 0.0
+ } else {
+ 1.0 - ((1.0 - b) / s).min(1.0)
+ }
+}
+
+fn dodge(b: f64, s: f64) -> f64 {
+ if b <= 0.0 {
+ 0.0
+ } else if s >= 1.0 {
+ 1.0
+ } else {
+ (b / (1.0 - s)).min(1.0)
+ }
+}
+
+fn hard_light(b: f64, s: f64) -> f64 {
+ if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
+}
+
+fn soft_light(b: f64, s: f64) -> f64 {
+ if s <= 0.5 {
+ b - (1.0 - 2.0 * s) * b * (1.0 - b)
+ } else {
+ let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
+ b + (2.0 * s - 1.0) * (d - b)
+ }
+}
+
+fn reflect(b: f64, s: f64) -> f64 {
+ if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
+}
+
+fn lum(c: [f64; 3]) -> f64 {
+ 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
+}
+
+fn clip_color(c: [f64; 3]) -> [f64; 3] {
+ let l = lum(c);
+ let n = c[0].min(c[1]).min(c[2]);
+ let x = c[0].max(c[1]).max(c[2]);
+ c.map(|v| {
+ let mut v = v;
+ if n < 0.0 && l - n > 1e-12 {
+ v = l + (v - l) * l / (l - n);
+ }
+ if x > 1.0 && x - l > 1e-12 {
+ v = l + (v - l) * (1.0 - l) / (x - l);
+ }
+ v.clamp(0.0, 1.0)
+ })
+}
+
+fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
+ let d = l - lum(c);
+ clip_color(c.map(|v| v + d))
+}
+
+fn sat(c: [f64; 3]) -> f64 {
+ c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
+}
+
+fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
+ let max = c[0].max(c[1]).max(c[2]);
+ let min = c[0].min(c[1]).min(c[2]);
+ if max - min <= 1e-12 {
+ return [0.0; 3];
+ }
+ c.map(|v| (v - min) * s / (max - min))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
+ Node {
+ class: crate::stream::Tag::of(b"ShpN"),
+ name: String::new(),
+ visible: true,
+ locked: false,
+ opacity: 1.0,
+ blend: Blend::Normal,
+ kind: Kind::Shape {
+ path: Default::default(),
+ fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
+ strokes: vec![],
+ even_odd: false,
+ },
+ mask: None,
+ pixel_mask: None,
+ effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
+ children: vec![],
+ }
+ }
+
+ fn fill(n: &Node) -> Color {
+ match &n.kind {
+ Kind::Shape { fills, .. } => match fills.first() {
+ Some(Paint::Solid(c)) => *c,
+ other => panic!("{other:?}"),
+ },
+ other => panic!("{other:?}"),
+ }
+ }
+
+ #[test]
+ fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
+ let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
+ let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
+ assert_eq!(fill(&n), white);
+ assert!(n.effects.is_empty(), "the overlay is baked, not kept");
+ // Half way, mixed in RGB.
+ let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
+ assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
+ }
+
+ #[test]
+ fn blend_modes_follow_their_formulas() {
+ let b = [0.25, 0.5, 1.0];
+ assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
+ assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
+ assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
+ assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
+ // Color keeps the backdrop's luminosity with the source's hue and saturation.
+ let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
+ assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
+ }
+
+ #[test]
+ fn overlays_recolour_pixels_inside_their_alpha() {
+ let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
+ n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
+ let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
+ let Kind::Image(img) = &out.kind else { panic!() };
+ assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
+ }
+
+ #[test]
+ fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
+ let g = Gradient {
+ kind: GradientKind::Linear,
+ stops: vec![
+ Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
+ Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
+ ],
+ transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
+ };
+ let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
+ n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
+ let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
+ let Kind::Shape { fills, .. } = &out.kind else { panic!() };
+ assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
+ }
+
+ #[test]
+ fn a_group_overlay_lies_over_its_childrens_overlays() {
+ let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
+ let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
+ let mut group = solid(red, 0.5, Blend::Normal);
+ group.kind = Kind::Group;
+ group.children = vec![solid(blue, 1.0, Blend::Normal)];
+ // The copy (2 nodes), the child's overlay (1) and the group's (2).
+ let mut work = 5;
+ let out = apply(&group, &mut work, &mut |_| {}).into_owned();
+ assert_eq!(work, 0);
+ let child = out.children.first().unwrap();
+ // Blue first, then half of the group's red over it.
+ assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
+ assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
+ // With no work left for the group's overlay, the child's still applies.
+ let mut warnings = Vec::new();
+ let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
+ assert_eq!(fill(out.children.first().unwrap()), blue);
+ assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
+ }
+
+ #[test]
+ fn overlays_stop_at_the_work_one_import_may_take() {
+ let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
+ n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
+ // 16 pixels and the node, for the copy and for the overlay: 34.
+ let mut work = 40;
+ let mut warnings = Vec::new();
+ assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
+ assert_eq!((work, warnings.len()), (6, 0));
+ assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
+ assert_eq!(work, 6);
+ assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
+ }
+}
diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs
index c4ebff844..9324b0335 100644
--- a/crates/affinity/src/paint.rs
+++ b/crates/affinity/src/paint.rs
@@ -126,6 +126,9 @@ pub struct Stroke {
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
+/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
+const MAX_DASHES: usize = 64;
+
fn floats(v: &Value) -> Option> {
match v {
Value::Floats(f) => Some(f.clone()),
@@ -136,6 +139,14 @@ fn floats(v: &Value) -> Option> {
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option {
+ color_within(r, id, 0)
+}
+
+/// The most spot colours that stand for one another followed.
+const MAX_SPOT_CHAIN: u32 = 8;
+
+/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here.
+fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option> {
@@ -167,7 +178,10 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option {
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
- return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
+ if depth >= MAX_SPOT_CHAIN {
+ return None;
+ }
+ return s.obj(id, b"srgb").and_then(|c| color_within(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
@@ -283,7 +297,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec Option {
+pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option {
let s = r.s;
let paint = descriptor(r, pen, local)?;
if paint == Paint::None {
@@ -295,6 +309,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
+ // Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
+ // stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
+ // owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
+ // against the public corpus's thumbnails.
+ if data.get(10) == Some(&0) {
+ return None;
+ }
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
@@ -312,6 +333,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
+ Some(Value::Array(a)) if a.len() > MAX_DASHES => {
+ r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
+ None
+ }
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs
index 98334717d..d458c08a7 100644
--- a/crates/affinity/src/raster.rs
+++ b/crates/affinity/src/raster.rs
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result {}
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result Result, Error> {
+/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
+fn is_heif(bytes: &[u8]) -> bool {
+ bytes.get(4..8) == Some(b"ftyp".as_slice())
+ && bytes.get(8..12).is_some_and(|brand| {
+ [b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
+ })
+}
+
+/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
+/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
+pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option) -> Result, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result c,
+ // What it masks lies outside it: all hidden, which one black pixel says as well.
+ Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
+ None => (0, 0, width, height),
+ };
+ decode(r, bitmap, crop, world)
+}
+
+/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
+/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
+fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
+ let inv = invert(world)?;
+ let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
+ let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
+ for p in pts {
+ if !(p.x.is_finite() && p.y.is_finite()) {
+ return Some((0, 0, width, height));
+ }
+ (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
+ }
+ let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
+ let (cx0, cy0, cx1, cy1) =
+ (clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
+ (cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
+}
+
+fn invert(m: Affine) -> Option {
+ let [a, b, c, d, e, f] = m.0;
+ let det = a * d - b * c;
+ if !(det.is_finite() && det.abs() > 1e-12) {
+ return None;
+ }
+ let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
+ Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
@@ -99,33 +145,41 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result Result, Error> {
+ decode_within(r, bitmap, crop, world, MAX_PIXELS)
+}
+
+/// [`decode`] with at most `max_pixels` decoded pixels.
+fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result , Error> {
let s = r.s;
let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
- let pixels = u64::from(w) * u64::from(h);
- if pixels > MAX_PIXELS {
- r.warn("pixel layers larger than 64 megapixels");
- return Ok(None);
+ // Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
+ // resolution: every `step`th pixel of every `step`th row.
+ let mut step = 1u32;
+ while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
+ step *= 2;
+ if step > 1 << 12 {
+ r.warn("pixel layers too large to read (left out)");
+ return Ok(None);
+ }
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
- let Some(left) = r.pixels_left.checked_sub(pixels) else {
+ let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
+ let Some(left) = r.budget.pixels.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
- r.pixels_left = left;
+ r.budget.pixels = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
- Some(4) => {
- r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
- (5, 1)
- }
+ Some(4) => (5, 1),
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
} else {
None
};
+ // Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
+ // a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
+ // out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
+ if needs_source && source.is_none() && channels > 1 {
+ let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
+ Some(Value::Array(a)) => a.clone(),
+ _ => Vec::new(),
+ };
+ let alpha = states(channels);
+ let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
+ if blank {
+ r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
+ return Ok(None);
+ }
+ }
+ if step > 1 {
+ // Its stored tiles must fit what is left of the import's extraction budget.
+ let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
+ if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
+ r.warn("pixel layers too large to read (left out)");
+ return Ok(None);
+ }
+ r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
+ }
let mut cache = CachedPixels { tiles: HashMap::new(), source };
for c in 1..=channels {
- planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
+ planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
}
+ let (w, h) = (w.div_ceil(step), h.div_ceil(step));
let n = (w as usize) * (h as usize);
+ let k = f64::from(step);
+ let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
+ if channels == 5 {
+ // Ink amounts and alpha as stored (8 bits), interleaved.
+ let mut cmyka = vec![0u8; n * 5];
+ for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
+ for (c, v) in px.iter_mut().enumerate() {
+ *v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
+ }
+ }
+ return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
+ }
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec, i: usize| -> f64 {
if bps == 2 {
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
};
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec = planes.iter().map(|p| sample(p, i)).collect();
- let (r2, g, b, a) = if channels == 1 {
- (v[0], v[0], v[0], 1.0)
- } else if channels == 5 {
- let k = 1.0 - v[3];
- ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
- } else {
- (v[0], v[1], v[2], v[3])
- };
+ let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) };
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
- let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
@@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
-/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
+/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
+fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec) {
+ let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
+ let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
+ let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
+ let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
+ let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) {
+ Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
+ _ => Vec::new(),
+ };
+ (tw, th, states)
+}
+
+/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
+fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec {
+ let (tw, th, states) = grid(s, bitmap, c, bps);
+ let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
+ let (py0, py1) = (y0 as usize, (y0 + h) as usize);
+ let mut out = Vec::new();
+ let mut next = 0usize;
+ for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
+ if *state != 4 {
+ continue;
+ }
+ let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
+ if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
+ out.push(next);
+ }
+ next += 1;
+ }
+ out
+}
+
+/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
+/// every `step`th row; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
+ step: u32,
cache: &mut CachedPixels,
) -> Result, Error> {
let CachedPixels { tiles: cache, source } = cache;
let s = r.s;
- let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
- let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
- let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
- let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
- let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) {
- Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
- _ => Vec::new(),
- };
+ let (tw, th, states) = grid(s, bitmap, c, bps);
let tiles = s.objs(bitmap, &tag(b"Idx", c));
- let row = (w as usize) * bps;
- let mut out = vec![0u8; row * h as usize];
+ let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
+ let row = at.w.div_ceil(at.step) * bps;
+ let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
@@ -267,7 +379,7 @@ fn plane(
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
- if !cache.contains_key(&name) {
+ let read = |r: &mut Reader| -> Result, Error> {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
@@ -276,10 +388,20 @@ fn plane(
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
+ Ok(data)
+ };
+ // A layer read at a lower resolution is too large to keep its tiles around.
+ if at.step > 1 {
+ let tile = read(r)?;
+ copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
+ continue;
+ }
+ if !cache.contains_key(&name) {
+ let data = read(r)?;
cache.insert(name.clone(), data);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
- copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
+ copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
@@ -289,9 +411,7 @@ fn plane(
5 => {
if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE);
- copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
- pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
- });
+ copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
continue;
}
r.warn("pixel layers drawn from an embedded image (left empty)");
@@ -300,17 +420,38 @@ fn plane(
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
- copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
+ copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
-/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
-fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
- for y in oy.max(py0)..(oy + TILE).min(py1) {
+/// Where a plane's pixels come from: the crop (pixels) and the sampling step.
+struct Crop {
+ bps: usize,
+ x0: usize,
+ y0: usize,
+ w: usize,
+ h: usize,
+ step: usize,
+}
+
+/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
+/// `step`th pixel of every `step`th row.
+fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
+ let row = c.w.div_ceil(c.step) * c.bps;
+ let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
+ for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
+ if !(y - c.y0).is_multiple_of(c.step) {
+ continue;
+ }
+ let out_row = (y - c.y0) / c.step * row;
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
- if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
+ let px = x / c.bps - c.x0;
+ if !px.is_multiple_of(c.step) {
+ continue;
+ }
+ if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
*b = at(x - ox, y - oy);
}
}
@@ -323,6 +464,15 @@ mod tests {
use crate::model::Kind;
use crate::synth::{self, F, Method, tag};
+ #[test]
+ fn heif_originals_are_recognised_by_their_brand() {
+ assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
+ assert!(is_heif(b"\0\0\0\x1cftypavif"));
+ assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
+ assert!(!is_heif(&png()));
+ assert!(!is_heif(b"ftyp"));
+ }
+
fn png() -> Vec {
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
@@ -333,18 +483,29 @@ mod tests {
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
/// not a claim that Affinity itself accepts the synthetic container.
fn fixture(source: &[u8], origin: i32) -> Vec {
+ fixture_in(source, origin, false)
+ }
+
+ /// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
+ fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec {
+ let channels = if cmyk { 5 } else { 4 };
let mut bitmap = vec![
- (tag(b"Frmt"), F::Enum(0, 0)),
+ (tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
(tag(b"BmpW"), F::I32(300)),
(tag(b"BmpH"), F::I32(2)),
(tag(b"LInf"), F::I32(origin)),
(tag(b"TInf"), F::I32(0)),
(tag(b"Bckg"), F::Entry("c/1".into())),
];
- for c in 1..=4 {
+ for c in 1..=channels {
+ let states = match (cmyk, c) {
+ (false, _) => vec![5, 4],
+ (true, 5) => vec![4, 4],
+ (true, _) => vec![5, 5],
+ };
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
- bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
+ bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
bitmap.push((
tag(&super::tag(b"Idx", c)),
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
@@ -369,18 +530,44 @@ mod tests {
blob.extend((source.len() as u32).to_le_bytes());
blob.extend(source);
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
- let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
- synth::container(
- &[
- ("doc.dat", &doc, Method::Zlib),
- ("c/1", &block, Method::Zlib),
- ("d/1", &tiles[0], Method::Zlib),
- ("d/2", &tiles[1], Method::Zlib),
- ("d/3", &tiles[2], Method::Zlib),
- ("d/4", &tiles[3], Method::Zlib),
- ],
- None,
- )
+ let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
+ let names: Vec = (1..=channels).map(|c| format!("d/{c}")).collect();
+ let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
+ for (name, tile) in names.iter().zip(&tiles) {
+ entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
+ }
+ synth::container(&entries, None)
+ }
+
+ #[test]
+ fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
+ let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
+ assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
+ assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
+ }
+
+ #[test]
+ fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
+ let bytes = fixture(&png(), 0);
+ let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
+ let doc = archive.read("doc.dat").unwrap();
+ let s = stream::parse(&doc).unwrap();
+ let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
+ let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
+ // 20 × 2 pixels over a budget of 10: every other pixel of every other row.
+ let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
+ assert_eq!((image.width, image.height), (10, 1));
+ assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
+ let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
+ for x in 0..10 {
+ let sx = 2 * x;
+ let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
+ assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
+ }
+ assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
+ // Within budget, nothing changes.
+ let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
+ assert_eq!((image.width, image.height), (20, 2));
}
#[test]
diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs
index a755747c4..7a3010251 100644
--- a/crates/affinity/src/stream.rs
+++ b/crates/affinity/src/stream.rs
@@ -303,6 +303,10 @@ impl Parser<'_> {
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
+ // Records of no bytes take nothing from the data, whatever their number.
+ if n > 0 && size == 0 {
+ return Err(Error::Malformed("array of records without bytes"));
+ }
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs
index 006f9e7bd..eeb709d34 100644
--- a/crates/affinity/src/tests.rs
+++ b/crates/affinity/src/tests.rs
@@ -1,4 +1,4 @@
-//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
+//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
@@ -261,6 +261,32 @@ mod archive {
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
+ // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's
+ // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than
+ // the format permits for a block. 0.9.1 refuses it.
+ #[test]
+ fn a_zstd_block_larger_than_its_frames_window_is_refused() {
+ const LEN: u32 = 100_000;
+ let plain = vec![0x61u8; LEN as usize];
+ let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None);
+ // A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE
+ // block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3.
+ let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
+ frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap());
+ frame.push(0x61);
+ // Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72).
+ let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
+ let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize;
+ let mut swapped = file[..76].to_vec();
+ swapped.extend(&frame);
+ swapped.extend(&file[76 + old_len..]);
+ let new_fat = fat - old_len + frame.len();
+ swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes());
+ swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes());
+ let mut a = Archive::open(&swapped, Limits::default()).unwrap();
+ assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window");
+ }
+
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs
index 2b632cab3..1a3b6e544 100644
--- a/crates/affinity/src/text.rs
+++ b/crates/affinity/src/text.rs
@@ -2,7 +2,7 @@
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application.
-use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
+use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
use crate::paint::{self, Color, Paint};
use crate::stream::{ObjId, Value};
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
+/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
+pub const CELL_BREAK: char = '\u{1F}';
+
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
- let mut runs = Vec::new();
- let mut align = None;
- let mut total = 0usize;
- let mut placeholders = false;
- let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
- for block in s.objs(story, b"Blok") {
- let Some(glyphs) = s.obj(block, b"Glyp") else {
- r.warn("a text block without readable characters (left out)");
- continue;
- };
- // Bound collection itself, including placeholders, before allocating the character buffer.
- let budget = MAX_CHARS - total;
-
- let mut chars: Vec = match s.str(glyphs, b"Utf8") {
- Some(t) => t.chars().take(budget + 1).collect(),
- None => {
- // Inline objects (page numbers, anchors) each take one index before the segment text.
- let segments = s.objs(glyphs, b"Mixd");
- if segments.is_empty() {
- r.warn("a text block without readable characters (left out)");
- continue;
- }
- let mut v = Vec::new();
- for seg in segments {
- let inline = s.objs(seg, b"Glys").len();
- v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
- placeholders |= inline > 0;
- v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
- if v.len() > budget {
- break;
- }
- }
- v
- }
- };
- let truncated = chars.len() > budget;
- if truncated {
- chars.truncate(budget);
- r.warn("text longer than a million characters (truncated)");
- }
- total += chars.len();
- let mut start = 0usize;
- for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
- let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
- r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
- continue;
- };
- let end = index.min(chars.len());
- if index > chars.len() || end < start {
- r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
- }
- if end <= start {
- continue;
- }
- let text = characters(&chars, start, end);
- start = end;
- push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
- }
- if start < chars.len() {
- let text = characters(&chars, start, chars.len());
- push_run(r, &mut runs, None, text, world, &mut attribute_budget);
- }
- for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
- if let Some(attrs) = s.obj(paragraph, b"Item") {
- let paragraph = paragraph_align(r, attrs);
- match align {
- None => align = Some(paragraph),
- Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
- _ => {}
- }
- }
- }
- if truncated {
- break;
- }
- }
- if placeholders {
- r.warn("text fields such as page numbers (left out of the text)");
- }
- let align = align.unwrap_or(Align::Left);
+ let (runs, align) = story_runs(r, story, world);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
@@ -111,19 +35,256 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
- Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
+ let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
+ Some(Text {
+ runs,
+ align,
+ anchor: Point { x: anchor_x, y: first_baseline },
+ frame: if artistic { None } else { Some(bounds) },
+ transform: world,
+ text_transform,
+ })
+}
+
+/// Rows and columns a table may have.
+const MAX_TABLE_LINES: usize = 4096;
+/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
+const MAX_TABLE_CELLS: usize = 1 << 16;
+
+/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
+pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option {
+ let s = r.s;
+ let story = s.obj(id, b"StSt")?;
+ let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
+ let bounds = |pos: Option| -> Option> {
+ let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
+ let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?;
+ (v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
+ };
+ let columns = bounds(s.obj(tabl, b"CPos"))?;
+ let rows = bounds(s.obj(tabl, b"RPos"))?;
+ let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
+ let count = ncols.checked_mul(nrows)?;
+ if count > MAX_TABLE_CELLS {
+ r.warn("tables with more than 65536 cells (left out)");
+ return None;
+ }
+ let Some(left) = r.budget.cells.checked_sub(count) else {
+ r.warn("tables beyond the cells one document may hold (left out)");
+ return None;
+ };
+ r.budget.cells = left;
+ let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
+ // Formatting that doesn't match the grid would land on the wrong cells (and merging them,
+ // move text between cells): only text then.
+ let cell_objs = if cell_objs.len() == count {
+ cell_objs
+ } else {
+ r.warn("tables whose cells don't match their grid (cell formatting left out)");
+ Vec::new()
+ };
+ let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
+ let mut cells: Vec = (0..count).map(|_| blank()).collect();
+ for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
+ if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
+ cell.inset = inset;
+ }
+ cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
+ Some(1) => VAlign::Center,
+ Some(2) => VAlign::Bottom,
+ _ => VAlign::Top,
+ };
+ // A cell merged into the one on its left; the first column has nothing to its left.
+ cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
+ }
+ // The story holds the text of every cell that isn't merged away, row by row, each ended by a
+ // cell break.
+ let owner_list: Vec = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
+ let mut owners = owner_list.into_iter();
+ let mut target = owners.next();
+ let mut extra = false;
+ let (runs, _) = story_runs(r, story, world);
+ for run in runs {
+ let mut parts = run.text.split(CELL_BREAK).peekable();
+ while let Some(part) = parts.next() {
+ if !part.is_empty() {
+ match target.and_then(|i| cells.get_mut(i)) {
+ Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
+ None => extra = true,
+ }
+ }
+ if parts.peek().is_some() {
+ target = owners.next();
+ }
+ }
+ }
+ if extra {
+ r.warn("table text beyond its last cell (left out)");
+ }
+ let mut edges = |tag: &[u8; 4], expected: usize| -> Vec> {
+ let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
+ if objs.len() != expected {
+ r.warn("tables whose lines don't match their grid (lines left out)");
+ return Vec::new();
+ }
+ objs.into_iter()
+ .map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
+ (Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
+ _ => None,
+ })
+ .collect()
+ };
+ let vertical = edges(b"CEdg", (ncols + 1) * nrows);
+ let horizontal = edges(b"REdg", ncols * (nrows + 1));
+ if let Some(format) = s.obj(tabl, b"TFmt") {
+ let filled =
+ s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
+ if filled {
+ r.warn("table cell fills (left out)");
+ }
+ }
+ let text_transform = text_scale(r, id).then(r.type_base);
+ Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
+}
+
+/// A story's character runs, each with its paragraph's format, and the first paragraph's
+/// alignment. Table cells end with [`CELL_BREAK`].
+pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec, Align) {
+ let s = r.s;
+ let mut runs = Vec::new();
+ let mut first_align = None;
+ let mut total = 0usize;
+ let mut placeholders = false;
+ let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
+ for block in s.objs(story, b"Blok") {
+ let Some(glyphs) = s.obj(block, b"Glyp") else {
+ r.warn("a text block without readable characters (left out)");
+ continue;
+ };
+ // Bound collection itself, including placeholders, before allocating the character buffer:
+ // by the story's limit and by what the document's other stories left.
+ let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
+
+ let mut chars: Vec = match s.str(glyphs, b"Utf8") {
+ Some(t) => t.chars().take(budget + 1).collect(),
+ None => {
+ // Inline objects (page numbers, anchors, cell ends) each take one index before
+ // the segment text.
+ let segments = s.objs(glyphs, b"Mixd");
+ if segments.is_empty() {
+ r.warn("a text block without readable characters (left out)");
+ continue;
+ }
+ let mut v = Vec::new();
+ for seg in segments {
+ for inline in s.objs(seg, b"Glys") {
+ if v.len() > budget {
+ break;
+ }
+ if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
+ v.push(CELL_BREAK);
+ } else {
+ v.push('\u{FFFC}');
+ placeholders = true;
+ }
+ }
+ v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
+ if v.len() > budget {
+ break;
+ }
+ }
+ v
+ }
+ };
+ let truncated = chars.len() > budget;
+ if truncated {
+ chars.truncate(budget);
+ if budget < MAX_CHARS - total {
+ r.warn("text beyond the characters one document may hold (truncated)");
+ } else {
+ r.warn("text longer than a million characters (truncated)");
+ }
+ }
+ total += chars.len();
+ // Paragraph formats by end index; a run without an index covers the rest of the block.
+ let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
+ let mut open: Option = None;
+ for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
+ let Some(attrs) = s.obj(run, b"Item") else { continue };
+ let a = paragraph(r, attrs);
+ first_align.get_or_insert(a.align);
+ match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
+ Some(end) => paragraphs.push((end, a)),
+ None => match open {
+ None => open = Some(a),
+ Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
+ _ => {}
+ },
+ }
+ }
+ // Sorted by end (they are in order in every file), so both lookups below are binary
+ // searches: a block can have as many paragraphs and runs as characters.
+ paragraphs.sort_by_key(|(end, _)| *end);
+ let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
+ let mut cuts: Vec = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
+ cuts.dedup();
+ let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| {
+ // Split at paragraph ends so every run has one paragraph format.
+ let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
+ let mut a = from;
+ for &c in inside.iter().chain(std::iter::once(&to)) {
+ push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
+ a = c;
+ }
+ };
+ let mut start = 0usize;
+ for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
+ let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
+ r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
+ continue;
+ };
+ let end = index.min(chars.len());
+ if index > chars.len() || end < start {
+ r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
+ }
+ if end <= start {
+ continue;
+ }
+ push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
+ start = end;
+ }
+ if start < chars.len() {
+ push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
+ }
+ if truncated {
+ break;
+ }
+ }
+ r.budget.chars = r.budget.chars.saturating_sub(total);
+ if placeholders {
+ r.warn("text fields such as page numbers (left out of the text)");
+ }
+ (runs, first_align.unwrap_or(Align::Left))
}
fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
}
-fn push_run(r: &mut Reader, runs: &mut Vec, attrs: Option, text: String, world: Affine, attribute_budget: &mut Option) {
+fn push_run(
+ r: &mut Reader,
+ runs: &mut Vec,
+ attrs: Option,
+ text: String,
+ world: Affine,
+ attribute_budget: &mut Option,
+ paragraph: Paragraph,
+) {
if text.is_empty() {
return;
}
let run = match attrs {
- Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
+ Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
_ => {
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
if attrs.is_none() {
@@ -137,10 +298,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec, attrs: Option, text:
italic: false,
features: Vec::new(),
all_caps: false,
+ underline: false,
+ strikethrough: false,
size: 12.0 * r.dpi / 72.0,
tracking: 0.0,
+ h_scale: 1.0,
leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
+ stroke: None,
+ paragraph,
}
}
};
@@ -195,13 +361,33 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
}
}
- if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
- {
- r.warn("outlined (stroked) text");
- }
- if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
- r.warn("horizontally scaled text");
+ // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
+ let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
+ (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
+ _ => None,
+ };
+ // Its gradient stops and dashes are owned data too, charged like the fill's.
+ if let Some(st) = &stroke {
+ let stops = match &st.paint {
+ Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::()),
+ _ => 0,
+ };
+ let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::()));
+ if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
+ stroke = None;
+ }
}
+ // Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
+ let mut decoration = |i: usize| match slot(ints, i, int) {
+ Some(1) => true,
+ Some(0) | None => false,
+ Some(_) => {
+ r.warn("text decorations of an unknown kind (left out)");
+ false
+ }
+ };
+ let (underline, strikethrough) = (decoration(0), decoration(1));
+ let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
@@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)");
}
- let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
- let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
+ let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
+ let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) {
@@ -238,10 +424,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
features,
all_caps,
+ underline,
+ strikethrough,
size,
tracking,
+ h_scale,
leading,
fill,
+ stroke,
+ paragraph: Paragraph::default(),
}
}
@@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option, bytes: usize) -
}
}
+/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
+const MAX_FONT_NAME: usize = 256;
+
+fn font_name(r: &mut Reader, budget: &mut Option, value: &str) -> String {
+ if value.len() > MAX_FONT_NAME {
+ r.warn("font names longer than 256 bytes (the default font is used)");
+ return String::new();
+ }
+ metadata_string(r, budget, value)
+}
+
fn metadata_string(r: &mut Reader, budget: &mut Option, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
@@ -303,13 +505,18 @@ fn font_features(r: &mut Reader, attrs: Option, attribute_budget: &mut Op
(features, all_caps)
}
-fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
- // Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
- // fixtures. Their layout is not represented by this importer; retain text and report the gap.
- if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
- r.warn("paragraph indents or spacing (imported with defaults)");
- }
- match slot(r.s.field(p, b"Ints"), 0, int) {
+/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
+/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
+/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
+/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
+/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
+/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
+/// thumbnail of a document whose owner made it earlier.
+fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
+ let doubles = r.s.field(p, b"Doub");
+ let ints = r.s.field(p, b"Ints");
+ let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
+ let align = match slot(ints, 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
@@ -318,5 +525,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
+ };
+ let leading = match slot(ints, 1, int) {
+ Some(2) => Some(at(1)).filter(|l| *l > 0.0),
+ Some(0) | None => None,
+ Some(_) => {
+ r.warn("paragraph leading of an unknown kind (imported as automatic)");
+ None
+ }
+ };
+ let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
+ Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
+}
+
+/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
+/// Neither the frame's own transform nor its groups' scale its type (checked against the
+/// thumbnails of tables and frames inside scaled groups).
+fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
+ match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
+ Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
+ Some(_) => {
+ r.warn("text frames with an invalid text scale (imported unscaled)");
+ Affine::IDENTITY
+ }
+ None => Affine::IDENTITY,
}
}
diff --git a/crates/affinity/tests/it/import_limits.rs b/crates/affinity/tests/it/import_limits.rs
new file mode 100644
index 000000000..088c33912
--- /dev/null
+++ b/crates/affinity/tests/it/import_limits.rs
@@ -0,0 +1,176 @@
+//! Crafted documents can't make the reader allocate or work without bound: tables, shared
+//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
+//! a file links or embeds the same content. Original synthetic documents in the reader's
+//! accepted schema; no Affinity software or files were used.
+
+use crate::text_fixture::*;
+use vectorcraft_affinity::{
+ Document, Error, Limits,
+ model::{Kind, Node},
+ read,
+ synth::{self, F, Method, tag},
+};
+
+type Field = (vectorcraft_affinity::stream::Tag, F);
+
+/// A document of one spread holding `nodes`, plus these other archive entries.
+fn file(nodes: Vec, entries: &[(&str, &[u8])]) -> Vec {
+ let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
+ let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
+ let mut next_id = 1000;
+ for (_, value) in &mut root {
+ shared_objects(value, &mut next_id);
+ }
+ let data = synth::stream(&root);
+ let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
+ all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
+ synth::container(&all, None)
+}
+
+fn open(nodes: Vec) -> Document {
+ read(&file(nodes, &[]), Limits::default()).unwrap()
+}
+
+fn all(nodes: &[Node]) -> Vec<&Node> {
+ let mut out = Vec::new();
+ for n in nodes {
+ out.push(n);
+ out.extend(all(&n.children));
+ }
+ out
+}
+
+fn warned(d: &Document, needle: &str) -> bool {
+ d.warnings.iter().any(|w| w.contains(needle))
+}
+
+fn story(text: &str) -> F {
+ F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
+}
+
+fn artistic(story: F) -> F {
+ F::Obj(
+ tag(b"TxtA"),
+ vec![
+ (tag(b"StSt"), story),
+ (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
+ ],
+ )
+}
+
+/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
+fn grid(cols: usize, rows: usize) -> Vec {
+ let pos = |n: usize| {
+ let v: Vec = (0..=n).map(|i| i as f64).collect();
+ F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
+ };
+ let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
+ vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
+}
+
+fn table(frame: F) -> F {
+ F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
+}
+
+fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
+ all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
+}
+
+#[test]
+fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
+ // 4096 × 4096 is within each dimension's limit but 16.7 million cells.
+ let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
+ let t = tables(&d);
+ assert_eq!(t.len(), 1);
+ assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
+ assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
+}
+
+#[test]
+fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
+ // One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
+ let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
+ let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
+ assert_eq!(tables(&d).len(), 4);
+ assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
+}
+
+#[test]
+fn a_shared_story_is_read_within_the_characters_of_one_document() {
+ // A story of a million characters linked from twenty text layers: 16 million in all.
+ let text = "x".repeat(1 << 20);
+ let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
+ let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
+ let chars: usize = all(&d.spreads[0].nodes)
+ .into_iter()
+ .filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::()) } else { None })
+ .sum();
+ assert_eq!(chars, 16 << 20);
+ assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
+}
+
+#[test]
+fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
+ // 200 000 paragraphs, each with its own run and format.
+ let n = 200_000;
+ let text = "a\u{2029}".repeat(n);
+ let fmt =
+ |end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
+ let blk = F::Obj(
+ tag(b"StBl"),
+ vec![
+ (tag(b"Glyp"), utf8(&text)),
+ (tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
+ (tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
+ ],
+ );
+ let started = std::time::Instant::now();
+ let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
+ let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
+ assert_eq!(t.runs.len(), n);
+ // (About half a second; quadratic matching took over half a minute on the same machine.)
+ assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
+}
+
+/// A shape node of `size` px.
+fn rect(size: f64) -> F {
+ F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
+}
+
+/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
+fn embedded(children: Vec) -> F {
+ let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
+ let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
+ if !children.is_empty() {
+ fields.push((tag(b"Chld"), F::Shared(children)));
+ }
+ F::Obj(tag(b"EmbN"), fields)
+}
+
+fn embedding(inner: &[u8], nodes: Vec) -> Vec {
+ let mut entry = b"EmDc\0\0\0\0".to_vec();
+ entry.extend_from_slice(inner);
+ file(nodes, &[("edc/1", &entry)])
+}
+
+#[test]
+fn an_embedded_documents_content_stays_with_its_node() {
+ // Read before the node's own children, which must not take it.
+ let inner = file(vec![rect(10.0), rect(20.0)], &[]);
+ let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
+ let node = &d.spreads[0].nodes[0];
+ assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
+ assert!(node.children.iter().all(|c| c.children.is_empty()));
+}
+
+#[test]
+fn embedding_one_document_many_times_shares_one_budget() {
+ // 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
+ // a whole read may take (each embedding used to have an allowance of its own).
+ let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
+ let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
+ assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
+ // Embedded fewer times, it opens.
+ let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
+ assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
+}
diff --git a/crates/affinity/tests/it/main.rs b/crates/affinity/tests/it/main.rs
new file mode 100644
index 000000000..84b343019
--- /dev/null
+++ b/crates/affinity/tests/it/main.rs
@@ -0,0 +1,20 @@
+//! Integration tests: one test binary for the crate, one module per area.
+// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+#[path = "../support/corpus.rs"]
+mod corpus;
+#[path = "../support/features.rs"]
+mod features;
+#[cfg(feature = "synth")]
+#[path = "../support/text_fixture.rs"]
+mod text_fixture;
+
+#[cfg(feature = "synth")]
+mod import_limits;
+#[cfg(feature = "synth")]
+mod outline_limits;
+mod raster;
+mod real_files;
+#[cfg(feature = "synth")]
+mod text;
diff --git a/crates/affinity/tests/outline_limits.rs b/crates/affinity/tests/it/outline_limits.rs
similarity index 98%
rename from crates/affinity/tests/outline_limits.rs
rename to crates/affinity/tests/it/outline_limits.rs
index b48eb494b..74151c192 100644
--- a/crates/affinity/tests/outline_limits.rs
+++ b/crates/affinity/tests/it/outline_limits.rs
@@ -1,7 +1,5 @@
//! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's
//! accepted schema, without using Affinity software, third-party parser code or external assets.
-#![cfg(feature = "synth")]
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Document, Limits,
diff --git a/crates/affinity/tests/raster.rs b/crates/affinity/tests/it/raster.rs
similarity index 91%
rename from crates/affinity/tests/raster.rs
rename to crates/affinity/tests/it/raster.rs
index e477b4d26..794b4c4f1 100644
--- a/crates/affinity/tests/raster.rs
+++ b/crates/affinity/tests/it/raster.rs
@@ -1,7 +1,6 @@
//! Pixel retention on current Affinity 3 documents published under MIT by Patchy.
//! Assertions use this reader's archive/stream and the original embedded image bytes;
//! no third-party Affinity parser, source code or application is used as an oracle.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Archive, Limits,
@@ -9,8 +8,7 @@ use vectorcraft_affinity::{
stream,
};
-#[path = "support/corpus.rs"]
-mod corpus;
+use crate::corpus;
fn images(nodes: &[Node], out: &mut Vec) {
for node in nodes {
@@ -103,13 +101,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
}
#[test]
-fn cmyk_and_lab_losses_are_reported() {
+fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() {
+ // CMYK layers keep their inks for the importer to convert through a colour profile.
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (64, 48));
- let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
- assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
- assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
+ let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() };
+ assert_eq!(pixels.len(), 64 * 48 * 5);
+ assert_eq!(crc32fast::hash(pixels), 0x56b1f659);
+ assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings);
+ assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile");
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
assert!(images.is_empty());
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/it/real_files.rs
similarity index 93%
rename from crates/affinity/tests/real_files.rs
rename to crates/affinity/tests/it/real_files.rs
index 66beca842..a1d72bd01 100644
--- a/crates/affinity/tests/real_files.rs
+++ b/crates/affinity/tests/it/real_files.rs
@@ -2,16 +2,13 @@
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
//! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned
//! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use vectorcraft_affinity::{Archive, Limits, stream};
-#[path = "support/corpus.rs"]
-mod corpus;
-#[path = "support/features.rs"]
-mod features;
+use crate::corpus;
+use crate::features;
fn files(dir: &Path, out: &mut Vec) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
@@ -90,7 +87,7 @@ fn public_documents_map_to_the_model() {
#[test]
fn affinity3_feature_retention_matches_pinned_signatures() {
let Some(dir) = corpus::pinned() else { return };
- let expected = include_str!("support/affinity3-signatures.txt");
+ let expected = include_str!("../support/affinity3-signatures.txt");
let mut checked = std::collections::BTreeSet::new();
for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
let (name, signature) = line.split_once('\t').expect("signature row must have a tab");
@@ -108,7 +105,7 @@ fn affinity3_feature_retention_matches_pinned_signatures() {
doc.saved_by
);
}
- let manifest = include_str!("../../../xtask/affinity-corpus.sha256");
+ let manifest = include_str!("../../../../xtask/affinity-corpus.sha256");
let af_names: std::collections::BTreeSet<_> =
manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect();
assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline");
diff --git a/crates/affinity/tests/text.rs b/crates/affinity/tests/it/text.rs
similarity index 79%
rename from crates/affinity/tests/text.rs
rename to crates/affinity/tests/it/text.rs
index 8d6a203e5..35a621f81 100644
--- a/crates/affinity/tests/text.rs
+++ b/crates/affinity/tests/it/text.rs
@@ -1,17 +1,11 @@
//! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance.
//! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3
//! mixed-run fixture. Broad complex typography still needs independent visual validation.
-#![cfg(feature = "synth")]
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-mod support {
- pub mod corpus;
- pub mod text_fixture;
-}
-use support::text_fixture::*;
+use crate::text_fixture::*;
use vectorcraft_affinity::{
Document, Limits,
- model::{Align, Kind, Text},
+ model::{Align, Kind, Paragraph, Text},
paint::{Color, Paint},
synth::{F, tag},
};
@@ -108,7 +102,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() {
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
assert_eq!(content(text(&d)), "one\u{2029}two");
assert_eq!(text(&d).align, Align::Right);
- assert!(warning(&d, "mixed paragraph alignments"));
+ assert!(warning(&d, "mixed paragraph formats"));
}
#[test]
@@ -179,7 +173,7 @@ fn stored_fallback_font_is_used_only_when_it_names_a_font() {
#[test]
fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
- let Some(corpus) = support::corpus::pinned() else { return };
+ let Some(corpus) = crate::corpus::pinned() else { return };
// Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser
// source was read; assertions describe this crate's parsing of its published MIT documents.
let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap();
@@ -194,10 +188,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
assert_eq!(texts.len(), 2);
assert!(texts[0].runs[0].all_caps);
assert_eq!(texts[1].runs[0].features, ["smcp"]);
- for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
+ // Indents and paragraph spacing are read (values in document pixels, as these fixtures set
+ // them in Affinity's paragraph panel).
+ let paragraphs = |name: &str| -> Vec {
let d = load(name);
- assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
- }
+ assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings);
+ d.spreads[0]
+ .nodes
+ .iter()
+ .filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None })
+ .flat_map(|t| t.runs.iter().map(|r| r.paragraph))
+ .collect()
+ };
+ let p = paragraphs("patchy-text-indent.af");
+ assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]);
+ let p = paragraphs("patchy-text-para-spacing.af");
+ assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::>(), [(9.0, 17.0)]);
}
proptest::proptest! {
@@ -247,9 +253,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
assert!(warning(&d, "more than 256 text feature settings"));
}
+#[test]
+fn font_names_longer_than_256_bytes_are_dropped() {
+ // Each paragraph and run carries its font's name: a huge one would be copied with each.
+ let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
+ let r = &text(&d).runs[0];
+ assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
+ assert!(warning(&d, "font names longer than 256 bytes"));
+}
+
#[test]
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
- let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
+ // The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
+ let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
let settings = F::Obj(
tag(b"OtAt"),
@@ -266,11 +282,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
}
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
- runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
- let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
+ // About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
+ runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
+ let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
let t = text(&d);
- assert_eq!(content(t), "x".repeat(1000));
- assert_eq!(t.runs.len(), 1000);
+ assert_eq!(content(t), "x".repeat(4000));
+ assert_eq!(t.runs.len(), 4000);
let metadata: usize = t
.runs
.iter()
@@ -279,7 +296,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
.map(|s| s.len() + std::mem::size_of::())
.sum();
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
- assert_eq!(t.runs[0].family.len(), 16 * 1024);
+ assert_eq!(t.runs[0].family.len(), 256);
assert_eq!(t.runs[0].features.len(), 256);
assert!(t.runs.last().unwrap().family.is_empty());
assert!(t.runs.last().unwrap().features.is_empty());
@@ -320,3 +337,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
assert!(warning(&d, "text attribute budget exceeded"));
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
}
+
+#[test]
+fn shared_gradient_outlines_are_bounded_without_losing_characters() {
+ let color =
+ F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
+ let mut colors = vec![color];
+ colors.extend((1..1024).map(|_| F::Ref(43)));
+ let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
+ let pen = F::Obj(
+ tag(b"FilG"),
+ vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
+ );
+ let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
+ // `Objs` 1 and 2: the outline's paint and line style, shared by every run.
+ let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
+ let mut runs = vec![run(1, Some(attrs))];
+ runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
+ let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
+ let t = text(&d);
+ assert_eq!(content(t), "x".repeat(1000));
+ assert_eq!(t.runs.len(), 1000);
+ let stop_count: usize = t
+ .runs
+ .iter()
+ .filter_map(|r| r.stroke.as_ref())
+ .map(|s| match &s.paint {
+ Paint::Gradient(g) => g.stops.len(),
+ _ => 0,
+ })
+ .sum();
+ assert!(stop_count * std::mem::size_of::() <= 16 << 20);
+ let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
+ assert_eq!(first.stops.len(), 1024);
+ assert!(t.runs.last().unwrap().stroke.is_none());
+ assert!(warning(&d, "text attribute budget exceeded"));
+}
diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt
index fb6c94b29..779a58552 100644
--- a/crates/affinity/tests/support/affinity3-signatures.txt
+++ b/crates/affinity/tests/support/affinity3-signatures.txt
@@ -1,33 +1,33 @@
-affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
-affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
-affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
-affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
-patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
-patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
-patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
-patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
-patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
-patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
-patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
-patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
-patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
-patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
-patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
-patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
-patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
-patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
-patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
-patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
-patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
-patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
-patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
-patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
-patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
-patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
-patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
-patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
-patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
-patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
-patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
-patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
-patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
+affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
+affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
+affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
+affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
+patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
+patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
+patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
+patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
+patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"]
+patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[]
+patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[]
+patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
+patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
+patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
+patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
+patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
+patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
+patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
+patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
+patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
+patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
+patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
+patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
+patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
+patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
+patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
diff --git a/crates/affinity/tests/support/features.rs b/crates/affinity/tests/support/features.rs
index 31d3eb53f..f7024feb6 100644
--- a/crates/affinity/tests/support/features.rs
+++ b/crates/affinity/tests/support/features.rs
@@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String {
}
"texts"
}
+ Kind::Table(t) => {
+ *counts.entry("table_cells").or_default() += t.cells.len();
+ "tables"
+ }
Kind::Image(_) => "images",
+ Kind::MasterInstance { .. } => "master_instances",
Kind::Unsupported => "unsupported",
};
*counts.entry(key).or_default() += 1;
diff --git a/crates/affinity/tests/support/text_fixture.rs b/crates/affinity/tests/support/text_fixture.rs
index d629253d3..574a0ca38 100644
--- a/crates/affinity/tests/support/text_fixture.rs
+++ b/crates/affinity/tests/support/text_fixture.rs
@@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
// fixture objects independent shared IDs without making the fixture descriptions noisy.
-fn shared_objects(value: &mut F, next_id: &mut u32) {
+pub fn shared_objects(value: &mut F, next_id: &mut u32) {
match value {
F::Shared(items) => {
for item in items {
diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs
index b9c85c12e..80d302b66 100644
--- a/crates/brush/src/colorize.rs
+++ b/crates/brush/src/colorize.rs
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
-use vectorcraft_doc::{AppearanceItem, Node};
+use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use crate::Colorization;
@@ -43,7 +43,8 @@ fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
}
/// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so
-/// brush output never recurses. Non-solid stroke paints leave the art's colours alone.
+/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. Type is
+/// recolored like paths: its characters' solid fills and strokes as well as its own.
pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
let key = match (mode, stroke.color()) {
(Colorization::None, _) | (_, None) => None,
@@ -65,15 +66,43 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
}
}
}
+ if let (Some(k), NodeKind::Text(t)) = (&key, &mut n.kind) {
+ for r in &mut t.runs {
+ map_paint(&mut r.style.fill, mode, k);
+ map_paint(&mut r.style.stroke, mode, k);
+ }
+ }
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
walk(Arc::make_mut(c), mode, key);
}
}
+ // A mask's art is drawn too: a brush on its strokes would run this brush again. Its colours
+ // are the mask's values, so they stay.
+ if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
+ strip_brushes(Arc::make_mut(&mut m.art));
+ }
}
walk(n, mode, key);
}
+/// Strip the brushes from every stroke under `n`, its masks' art included.
+fn strip_brushes(n: &mut Node) {
+ for it in &mut n.appearance.items {
+ if let AppearanceItem::Stroke(s) = it {
+ s.brush = None;
+ }
+ }
+ if let Some(ch) = n.children_mut() {
+ for c in ch.iter_mut() {
+ strip_brushes(Arc::make_mut(c));
+ }
+ }
+ if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
+ strip_brushes(Arc::make_mut(&mut m.art));
+ }
+}
+
/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
let amount = amount.clamp(0.0, 1.0);
@@ -88,8 +117,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
- if let Paint::Solid { color: c, .. } = &mut r.style.fill {
- *c = c.lerp(color, amount);
+ for p in [&mut r.style.fill, &mut r.style.stroke] {
+ if let Paint::Solid { color: c, .. } = p {
+ *c = c.lerp(color, amount);
+ }
}
}
}
@@ -119,6 +150,8 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
+/// Type's characters count as well: each run's fill, and its stroke when the stroke is wider
+/// than 0.
pub fn art_colors<'a>(nodes: impl IntoIterator- ) -> Vec
{
fn walk(n: &Node, seen: &mut std::collections::HashMap) {
for it in &n.appearance.items {
@@ -131,6 +164,15 @@ pub fn art_colors<'a>(nodes: impl IntoIterator- ) -> Vec
{
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
+ if let NodeKind::Text(t) = &n.kind {
+ for r in &t.runs {
+ let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
+ for c in [Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color) {
+ let order = seen.len();
+ seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
+ }
+ }
+ }
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs
index a53d20519..1f7af6e60 100644
--- a/crates/brush/src/lib.rs
+++ b/crates/brush/src/lib.rs
@@ -413,6 +413,8 @@ impl Bristle {
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
+ /// The most characters `brush.new` and `brush.options` take for a new bristle brush name.
+ pub const MAX_NAME_CHARS: usize = 31;
}
impl Default for Bristle {
@@ -456,6 +458,31 @@ impl Brush {
}
}
+ /// A brush definition from JSON (a stored library entry or a command's params), its values
+ /// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key`
+ /// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or
+ /// black when they use no solid color.
+ pub fn from_json(mut v: serde_json::Value) -> Result {
+ let keyless = v.get("colorization").is_some_and(|c| {
+ c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null)
+ });
+ if keyless {
+ // Read the art with the colorization field left out.
+ let mut plain = v.clone();
+ if let Some(o) = plain.as_object_mut() {
+ o.remove("colorization");
+ }
+ let b: Brush = serde_json::from_value(plain)?;
+ let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK);
+ if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) {
+ c.insert("key".into(), serde_json::to_value(key)?);
+ }
+ }
+ let mut b: Brush = serde_json::from_value(v)?;
+ b.sanitize();
+ Ok(b)
+ }
+
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
@@ -517,14 +544,9 @@ pub fn library(doc: &Document) -> Vec {
}
/// Parse a stored library value (malformed entries are skipped, values kept within their
-/// ranges — see [`Brush::sanitize`]).
+/// ranges; see [`Brush::from_json`]).
pub fn parse_library(v: &serde_json::Value) -> Vec {
- let parse = |b: &serde_json::Value| {
- let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
- b.sanitize();
- Some(b)
- };
- v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
+ v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
@@ -667,6 +689,10 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) {
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
pub fn node_pieces(doc: &Document, n: &Node) -> Option> {
let bp = node_bezpath(n)?;
+ // The library is parsed for a stroke that names a brush, not for every path.
+ if !n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())) {
+ return None;
+ }
let lib = library(doc);
let mut out = vec![];
let mut any = false;
diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs
index 3dcdbf9bc..8c04e102b 100644
--- a/crates/brush/src/tests.rs
+++ b/crates/brush/src/tests.rs
@@ -308,6 +308,63 @@ fn tints_colorization_uses_stroke_colour() {
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
+/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
+fn type_art(fill: Paint, stroke: Paint) -> Node {
+ use vectorcraft_doc::{CharStyle, TextObject};
+ let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() };
+ Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style))))
+}
+
+/// The characters' fills and strokes of every piece of type in `nodes`.
+fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> {
+ let mut out = vec![];
+ for n in nodes {
+ n.walk(&mut |c| {
+ if let NodeKind::Text(t) = &c.kind {
+ out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())));
+ }
+ });
+ }
+ out
+}
+
+/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths.
+#[test]
+fn scatter_colorization_recolors_type() {
+ let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
+ let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0);
+ let mut path = rect_art(10.0, 4.0);
+ path.appearance.set_fill(red.clone());
+ let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]);
+ let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) };
+ let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
+ assert!(!out.is_empty());
+ let path_fill = out[0].children().unwrap()[0].appearance.fill_paint();
+ assert_ne!(path_fill, red);
+ let paints = run_paints(&out);
+ assert_eq!(paints.len(), out.len());
+ assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}");
+ // Black type takes the stroke color itself.
+ let black = Paint::solid(Color::BLACK);
+ let b = Brush {
+ name: "s".into(),
+ kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }),
+ };
+ let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
+ assert!(!out.is_empty());
+ assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone())));
+}
+
+/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills.
+#[test]
+fn stain_mixes_type_strokes_too() {
+ let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
+ let blue = Color::rgb(0.0, 0.0, 1.0);
+ let mut art = type_art(red.clone(), red);
+ tint_node(&mut art, &blue, 1.0);
+ assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
+}
+
#[test]
fn library_defaults_store_and_find() {
let mut d = Document::new(100.0, 100.0);
@@ -557,11 +614,46 @@ fn art_colors_list_the_most_used_first() {
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
+ // Type counts its characters' fills and drawn strokes.
+ let text = type_art(Paint::solid(red), Paint::solid(red));
+ let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]);
+ assert_eq!(art_colors([&g]), vec![red, blue]);
+ let NodeKind::Text(mut t) = text.kind else { panic!("not type") };
+ t.runs[0].style.stroke_width = 0.0;
+ let text = Node::new(NodeId(0), NodeKind::Text(t));
+ let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]);
+ assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted");
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
+/// A Hue Shift definition without a key color takes the art's most used color, or black when the
+/// art has no solid color; a key that is given is kept.
+#[test]
+fn hue_shift_without_a_key_takes_the_most_used_art_color() {
+ let red = Color::rgb(1.0, 0.0, 0.0);
+ let blue = Color::rgb(0.0, 0.0, 1.0);
+ let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0));
+ let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]);
+ let lib = parse_library(&serde_json::json!([
+ {"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}},
+ {"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}},
+ {"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}},
+ {"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}},
+ ]));
+ let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect();
+ assert_eq!(
+ keys,
+ vec![
+ ("s", Colorization::HueShift { key: blue }),
+ ("a", Colorization::HueShift { key: blue }),
+ ("p", Colorization::HueShift { key: red }),
+ ("n", Colorization::HueShift { key: Color::BLACK }),
+ ]
+ );
+}
+
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
#[test]
@@ -595,3 +687,81 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
}
}
+
+/// Anchors a brushed stroke emitted, which is what the output budget counts.
+fn anchors(nodes: &[Node]) -> usize {
+ nodes
+ .iter()
+ .map(|n| match &n.kind {
+ NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::(),
+ _ => 0,
+ })
+ .sum()
+}
+
+/// A path of `subpaths` long parallel lines, each long enough to want more tiles than the
+/// per-run cap allows.
+fn many_long_subpaths(subpaths: usize) -> BezPath {
+ let mut bp = BezPath::new();
+ for i in 0..subpaths {
+ let y = i as f64 * 50.0;
+ bp.move_to((0.0, y));
+ bp.line_to((300_000.0, y));
+ }
+ bp
+}
+
+/// #3594: the per-factor caps are per track, and a path has as many tracks as it has subpaths,
+/// so they multiply instead of bounding. Measured before the budget: 20,000 nodes and 320,000
+/// anchors for one subpath, and exactly N times that for N, so sixteen gave 5,120,000.
+#[test]
+fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
+ let brush = pattern_brush(PatternFit::Stretch, false);
+ let one = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(1), &stroke(1.0));
+ assert_eq!(one.len(), 20_000, "one track still fills its own per-run cap");
+ let a1 = anchors(&one);
+ assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}");
+
+ // Sixteen tracks would be sixteen times that without a total budget.
+ // Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the
+ // budget must not bite before the per-factor caps have actually multiplied past it.
+ let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));
+ assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");
+ assert_eq!(anchors(&four), 4 * a1);
+
+ // Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.
+ let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));
+ let an = anchors(&many);
+ assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");
+ assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");
+ assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
+}
+
+/// The same for scatter, whose per-track guard is also 20,000 copies.
+#[test]
+fn scatter_output_is_capped_across_subpaths() {
+ let b = Brush {
+ name: "s".into(),
+ kind: BrushKind::Scatter(Scatter { art: rect_art(4.0, 4.0), colorization: Colorization::None, ..Default::default() }),
+ };
+ let few = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(1), &stroke(1.0));
+ let per_track = anchors(&few);
+ assert!(per_track > 0, "one track draws something");
+ // Enough tracks that the per-track guards alone would pass the budget.
+ let tracks = 2_000_000 / per_track + 4;
+ let many = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(tracks), &stroke(1.0));
+ let an = anchors(&many);
+ assert!(an <= 2_000_000, "{tracks} tracks must not exceed the budget: {an}");
+ assert!(an > per_track, "but more is drawn than one track's worth: {an}");
+ assert!(many.len() < tracks * few.len().max(1), "fewer copies than the per-track guards alone would allow");
+}
+
+/// A short, ordinary stroke is untouched by the budget: it is the absurd ones that are cut.
+#[test]
+fn an_ordinary_stroke_is_unaffected_by_the_budget() {
+ let brush = pattern_brush(PatternFit::Stretch, false);
+ let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
+ let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &sq, &stroke(1.0));
+ assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
+ assert!(anchors(&out) < 2_000_000);
+}
diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs
index e02bcf48d..9e651a670 100644
--- a/crates/brush/src/track.rs
+++ b/crates/brush/src/track.rs
@@ -156,6 +156,9 @@ impl Track {
}
}
+/// The most flattened elements of a path kept.
+const MAX_FLATTENED: usize = 4_000_000;
+
/// Flatten `bp` into tracks (one per subpath) with `tol` accuracy.
pub fn tracks(bp: &BezPath, tol: f64) -> Vec {
let mut out = vec![];
@@ -165,8 +168,18 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec