diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 400a81944..058705ce2 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -13,27 +13,27 @@ on: workflow_dispatch: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 +# A newer push to a PR branch cancels its older run. +concurrency: + group: print-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Desktop printing on Windows, through the suite's shared gate. Pull requests restore the cache + # but never save one; a manual run on main saves the cache they restore. windows: - runs-on: windows-2022 - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master - with: - toolchain: stable - components: clippy - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: desktop-print - - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings - - run: cargo test --locked -p vectorcraft - - name: Check pending submission and queue refusal feedback - shell: bash - # A filter that matches nothing still passes, so fail unless some test ran. - run: | - cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee printps.log - grep -Eq 'test result: ok\. [1-9][0-9]* passed' printps.log || { echo "::error::tests::printps matched no tests"; exit 1; } + # 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: + os: '["windows-2022"]' + timeout-minutes: 90 + # As before: printing builds and tests without craft-fonts, nextest or the wasm target. + craft-fonts-ref: "" + tools: "" + targets: "" + # A filter that matches nothing still passes, so the printps run fails unless some test ran. + command: | + cargo clippy --locked -p vectorcraft --all-targets -- -D warnings + cargo test --locked -p vectorcraft + log="$RUNNER_TEMP/printps.log" + cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee "$log" + grep -Eq 'test result: ok\. [1-9][0-9]* passed' "$log" || { echo "::error::tests::printps matched no tests"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1677e538..07b3c6b7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,7 @@ jobs: needs: git-sources permissions: contents: write + packages: 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/AGENTS.md b/AGENTS.md index 1677f01c5..0d33612d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. - - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. @@ -46,7 +46,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **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`. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). 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`). ## Running and looking at the app @@ -59,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). ## Roadmap and progress docs -The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +The progress docs: - [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. - [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). - [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. @@ -72,20 +72,17 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ASSETS.md b/ASSETS.md index 26d463091..aa3bfeda0 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -6,7 +6,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). -**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay. +**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. **Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them. @@ -224,13 +224,16 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-swap.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-swatch-kinds.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-symbol-sprayer.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-touch-type.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-touch-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-transform-panel.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-transparency.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-twirl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-type-area.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-type-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-vertical-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-vertical-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-width-profile.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-width.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-wrinkle.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | @@ -345,6 +348,9 @@ Generated-in-code art is original and has no file to list. This covers the defau | `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | | | `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | | | `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | | +| `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | +| `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | +| `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | ## Assets embedded by the PDF backend @@ -352,20 +358,20 @@ The shared PDF backend keeps the existing standard-font and CMap features. These | Crate / file | Author | Source | Licence | SHA-256 | Use | | --- | --- | --- | --- | --- | --- | -| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | -| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | -| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | +| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | +| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | diff --git a/CLAUDE.md b/CLAUDE.md index 2b6ffcc43..17783d9a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. - - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. @@ -47,7 +47,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **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`. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). 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`). ## Running and looking at the app @@ -60,7 +60,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). ## Roadmap and progress docs -The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +The progress docs: - [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. - [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). - [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. diff --git a/Cargo.lock b/Cargo.lock index 6bf8261a8..137e9a2be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -303,7 +303,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -334,7 +334,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -360,7 +360,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -665,7 +665,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "tracing", ] @@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.5", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -705,6 +705,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -763,9 +774,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -959,17 +970,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "fixed_decimal", "icu_decimal", @@ -992,7 +1003,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "egui", "serde", @@ -1002,7 +1013,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -1013,9 +1024,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree 0.21.2", "tempfile", @@ -1026,7 +1038,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1047,7 +1059,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -1070,10 +1082,21 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-vectorcraft-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137" +dependencies = [ + "flate2", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "egui", "objc2 0.6.5", @@ -1563,7 +1586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1888,7 +1911,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -1940,6 +1963,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl 0.1.12", +] + [[package]] name = "gif" version = "0.14.2" @@ -2173,7 +2206,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "brotli", "hayro-postscript", @@ -2182,7 +2215,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2200,7 +2233,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2218,12 +2251,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "flate2", "hayro-ccitt", @@ -2233,7 +2266,7 @@ dependencies = [ "rustc-hash 2.1.3", "smallvec", "stringprep", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -2609,14 +2642,14 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2831,13 +2864,13 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" dependencies = [ "base64 0.22.1", "bumpalo", "flate2", "float-cmp", - "gif", + "gif 0.14.2", "image-webp", "imagesize 0.14.0", "indexmap", @@ -2852,7 +2885,7 @@ dependencies = [ "tiny-skia-path 0.12.0", "xmp-writer", "yoke", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -3618,7 +3651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3933,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -4140,7 +4173,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4380,12 +4413,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif 0.13.3", + "image-webp", "log", "pico-args", "rgb", "svgtypes 0.15.3", "tiny-skia", "usvg 0.45.1", + "zune-jpeg 0.4.21", ] [[package]] @@ -4489,15 +4525,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4556,7 +4592,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5097,9 +5133,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -5142,7 +5178,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -5415,10 +5451,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", - "windows-sys 0.59.0", + "rustix 1.1.6", + "windows-sys 0.61.2", ] [[package]] @@ -5490,7 +5526,7 @@ dependencies = [ "half", "quick-error 2.0.1", "weezl 0.1.12", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6037,7 +6073,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.18.0" +version = "0.21.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6065,6 +6101,7 @@ dependencies = [ "smithay-clipboard", "socket2", "vectorcraft-engine", + "vectorcraft-render", "vectorcraft-text", "vectorcraft-ui-egui", "wayland-backend", @@ -6081,7 +6118,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.18.0" +version = "0.21.0" dependencies = [ "crc32fast", "flate2", @@ -6092,7 +6129,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.18.0" +version = "0.21.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6104,7 +6141,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.18.0" +version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6120,7 +6157,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.18.0" +version = "0.21.0" dependencies = [ "log", "objc2-core-foundation", @@ -6142,7 +6179,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.18.0" +version = "0.21.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6154,7 +6191,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.18.0" +version = "0.21.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6168,12 +6205,12 @@ dependencies = [ "tiff", "vectorcraft-color", "vectorcraft-geom", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] name = "vectorcraft-effects" -version = "0.18.0" +version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6189,17 +6226,20 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.18.0" +version = "0.21.0" dependencies = [ + "cfb", + "chrono", "craft-docpath", "crc32fast", "flate2", + "hayro-jpeg2000", "image", "kurbo 0.13.1", "log", "proptest", "roxmltree 0.21.2", - "rustix 1.1.5", + "rustix 1.1.6", "ruzstd", "serde", "serde_json", @@ -6228,7 +6268,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.18.0" +version = "0.21.0" dependencies = [ "fax", "flate2", @@ -6252,11 +6292,12 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.18.0" +version = "0.21.0" dependencies = [ + "craft-vectorcraft-format", "flate2", "proptest", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "thiserror 2.0.21", @@ -6267,7 +6308,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.18.0" +version = "0.21.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6279,7 +6320,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.18.0" +version = "0.21.0" dependencies = [ "image", "log", @@ -6298,7 +6339,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.18.0" +version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6312,7 +6353,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.18.0" +version = "0.21.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6324,7 +6365,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.18.0" +version = "0.21.0" dependencies = [ "aes", "chrono", @@ -6356,7 +6397,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.18.0" +version = "0.21.0" dependencies = [ "serde", "serde_json", @@ -6370,10 +6411,10 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.18.0" +version = "0.21.0" dependencies = [ "flate2", - "gif", + "gif 0.14.2", "image", "jpeg-encoder", "log", @@ -6382,6 +6423,7 @@ dependencies = [ "psd", "serde_json", "tiff", + "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", @@ -6399,14 +6441,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.18.0" +version = "0.21.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.18.0" +version = "0.21.0" dependencies = [ "craft-docpath", "flate2", @@ -6416,6 +6458,7 @@ dependencies = [ "resvg", "serde", "serde_json", + "simplecss", "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", @@ -6431,7 +6474,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.18.0" +version = "0.21.0" dependencies = [ "fax", "image", @@ -6449,7 +6492,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.18.0" +version = "0.21.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6470,7 +6513,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.18.0" +version = "0.21.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6478,12 +6521,13 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.18.0" +version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", "serde", "serde_json", + "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", @@ -6494,7 +6538,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.18.0" +version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6506,7 +6550,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.18.0" +version = "0.21.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6517,7 +6561,7 @@ dependencies = [ "egui_kittest", "image", "log", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "toml", @@ -6544,7 +6588,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.18.0" +version = "0.21.0" dependencies = [ "eframe", "egui", @@ -6711,12 +6755,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f" dependencies = [ "leb128fmt", - "wasmparser 0.262.0", + "wasmparser 0.263.0", ] [[package]] @@ -6771,9 +6815,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -6782,9 +6826,9 @@ dependencies = [ [[package]] name = "wast" -version = "262.0.0" +version = "263.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" +checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7" dependencies = [ "bumpalo", "leb128fmt", @@ -6795,9 +6839,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.262.0" +version = "1.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" +checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7" dependencies = [ "wast", ] @@ -6810,7 +6854,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -6823,7 +6867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -6845,7 +6889,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -7183,7 +7227,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7520,7 +7564,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -7579,7 +7623,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -7621,7 +7665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -7675,7 +7719,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.18.0" +version = "0.21.0" dependencies = [ "serde_json", ] @@ -7725,7 +7769,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", @@ -7948,19 +7992,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4b14fbb37..1af59ddfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,23 +4,25 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.18.0" +version = "0.21.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", default-features = false, features = ["interpret", "write"] } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["serde"] } +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } # 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["egui"] } +# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -145,12 +147,12 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 0d0758820..002fa016a 100644 --- a/NOTICE +++ b/NOTICE @@ -72,10 +72,10 @@ Shared PDF backend The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0 and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision -92d392cff132eaf53e13afff77f38b5c11b73aa0. Their upstream project is +f8f8829cc7be65d466d687dae381d1ca60e31242. Their upstream project is https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each crate's upstream licence files. The patch ledger is at -https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/README.craft-reader.md. Assets already bundled through Hayro's default features retain their licences: - Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved. diff --git a/README.md b/README.md index fd45eb9cd..8a3c453c5 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to * the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work (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 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 +On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft can't get a drawing tablet's pen +input on native Wayland yet (#491, #2028). When a pen is attached as VectorCraft starts, the window opens through +XWayland, where the pen works as a mouse and the app reads its pressure (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps +Wayland). For a pen attached later, set the same preference to **X11 (XWayland)** 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 @@ -328,12 +328,21 @@ stand on its own. | | App | What it's for | Code | Learn more | |:-:|---|---|---|---| | | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | -| | **VectorCraft** | **Vector illustration · you are here** | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | +| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index acd0cf77e..6cf80d362 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. + - PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. - PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. @@ -9,7 +11,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-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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates 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 @@ -110,6 +112,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Japanese | `ja` | full | 100% | | German | `de` | full | 98% | | Korean | `ko` | full | 100% | +| Bosnian | `bs` | full (AI-assisted, native review pending) | 100% | | Vietnamese | `vi` | full | 100% | Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Catalan (full; AI-drafted, native review welcome), Czech (catalog complete; render-site gaps reduced; native review pending). @@ -128,6 +131,19 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. +- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). +- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). +- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). +- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). +- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917). +- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged. +- 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged. +- 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged. +- 2026-11-03: Text Tweaker, the first tool of the Type Tools add-on (Object › Plug-ins › Type Tools): one character attribute (size, baseline shift, rotation, horizontal or vertical scale, tracking, leading or stroke weight) goes letter by letter from a start value to an end value along a straight ramp, an ease, a bow, a spike or at random with a seed, set as the Character panel sets it, while the text stays live; a dialog with Preview, one undo step (`addon.type.textTweaker`, #4924). A VectorCraft addition: headline numbers unchanged. +- 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. +- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. +- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 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. @@ -138,6 +154,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 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-22: Pen tilt, bearing and barrel rotation (part of #764): Calligraphic and Scatter brushes take Tilt, Bearing and Rotation beside Pressure, the Paintbrush records them along each stroke, and the app reads a pen's tilt and an Art Pen's barrel rotation from XInput2 on Linux under X11 and XWayland and from AppKit's tablet events on macOS. 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. @@ -185,7 +202,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). Art drawn flush with an artboard (a background a rounding error outside it) moves, copies and scales with the artboard again (#1375, from #1295). A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992). A PDF saved with its editing data and a bleed opens on its artboard with the bleed round it, as a `.ai` does (#1379, follow-up to #1349 and #1345). Dialogs opened from a menu focus their first field every time they open, not only the first time, so typing, Tab and Enter work (#1382, fixes #1367); a dialog whose first field is greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the keyboard focus to that field once it is enabled (#1395). In a `.ai` opened from its editing data, a gradient with stops in the middle draws across its whole shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392) (follow-ups to #1264). `--control` without a valid port (or a bad `VECTORCRAFT_CONTROL_PORT`) stops the desktop app with an error instead of starting without a control server, and `--control=` works too (#1383). Documents with many symbol instances or inline graphics redraw faster and use less memory: the art placed for each instance is kept between frames (#1386). PDF and `.ai` import and placed PDFs keep overprint (`/OP`, `/op`), so overprinting spot colours still overprint after export (#1413, fixes #1388). A `.ai` whose PDF part draws only text opens from that part when its editing data can't be read, instead of being refused (#1414, fixes #1387). In a `.ai` opened from its editing data, a compound path takes the paint of its last path, so its gradient no longer comes in black, and paths grouped inside a compound path stay its paths (#1426, fixes #1410). With anchors selected with Direct Selection, the Scale, Rotate, Reflect and Shear tools, their dialogs and Move transform only those anchors (#1427, fixes #1408). The Move, Rotate, Scale, Reflect and Shear dialogs have a live Preview, and the arrow keys step their angles and percentages by 1 (Shift: 10) (#1428, fixes #1412). A stroke wider than its closed shape (a small ellipse or rectangle with a heavy stroke) paints solid on the canvas, as in exports, instead of showing a hole (#1409, fixes #1396). The bounding box hides while a corner radius is dragged with the Selection tool (#1402, fixes #1229). Pasting a picture works when the clipboard also holds a blank text item beside it (#1404). `document.inspect` reports a compound path's fill, stroke and weight and every node's blend mode when it isn't Normal (#1397, fixes #1389); `object.isolate` with an id that doesn't exist says there is no such object (#1415); the MCP guide covers Spacing Guides and four more preferences, all resources, and the symbol, brush, variable and Image Trace commands (#1418). PDF and `.ai` import keep a stroke opaque and unmasked when the fill under it was faded or masked by its group, and a `.ai` read as its PDF part keeps its RGB numbers (#1434). SVG export writes knockout groups of opaque art without masks (smaller files, no edge seams), and `vectorcraft-cli convert` takes `--decimals` (#1435). Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate and the copying transforms of thousands of objects take a fraction of a second instead of seconds (#1430, part of #1429), and an Alt-drag with the selection and transform tools copies the selection once per drag instead of on every move (#1464, part of #1429). An Affinity text frame too short for its first line (a 64 pt title in a 54 pt frame) is made taller on import, with a note, instead of showing nothing (#1449, fixes #1423). Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444). Text frames, wrap shapes and type paths with coordinates beyond a billion points (from damaged files) hold no text instead of hanging the layout (#1451). Czech: 128 more strings translated (#1461). Plug-ins: a numeric parameter's default stays inside its declared range (#1463). Split Into Grid has the documented options: number, height or width, gutter and total for rows and columns, Add Guides and Preview; several selected paths become one grid painted as the topmost one (#1488, fixes #1462). Grayscale TIFFs with an alpha channel or other extra channels can be placed, relinked and opened, the alpha as the image's transparency (#1489, part of #1466). Selected text in numeric and dialog fields is drawn in white or black instead of blue on the blue highlight (#1490, part of #1465). The Pathfinder panel works on a group selected alone (its members are combined), and a greyed-out button says what it needs (#1493, for #1450). Transform Objects joins Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and in Transform Each (off: only the pattern tiles transform), and Reflect takes an angle for its axis (#1494, fixes #1448 and #1460). A window larger than 8192 px (several monitors, a display-scale change) keeps its surface instead of closing the app (#1495, part of #1459). Commands run through MCP, the control channel and the CLI refuse a parameter key they don't take, naming it and the nearest accepted key (#1496, fixes #1442). A focused number field follows its value when a slider or preview changes it, so the next key no longer puts the old value back (#1497, fixes #1457). Effect › 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (curves are sampled by a tolerance; Rotate takes up to 65,536 outline points; #1481), Extrude & Bevel's Twist runs 0° to 360°, as the public panel reference gives it (#1524), and a twisted extrusion keeps its walls (from about 45° they were dropped; #1525). Text commands and the MCP `add_text` tool say when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, including one just added (#1487, fixes #1458). `graph.setData`'s CSV keeps each value exactly instead of rounding it to two decimals, so applying the returned CSV changes nothing (#1485). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible (#1505, fixes #1486). Sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483). Direct Selection picks the anchor of the selected object where anchors of overlapping objects coincide, also under another object (#1509, fixes #1508). Print-size images (past 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image whose side is past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows, `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as on macOS and Linux (#1502). French: 423 missing strings added and 188 entries reworded to the feature names French users know (#1511). Hungarian: one message uses the informal address like the rest of the interface (#1474). | | 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. | | 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). Large documents: editing a big selection takes time linear in the document (a 25,000-path nudge 3.5 s → 48 ms), live geometry effects are kept across frames, Pathfinder Trim, Merge, Crop and Outline are 9–20× faster with identical output, and undo history holds 41% less (#1173). | -| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | +| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | | 2026-10-07 | v0.4.0; issue fixes; Layers panel rework | @@ -195,12 +212,24 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-01 | Renamed from DrawCraft to VectorCraft | | 2026-09-30 | Repository history begins | -The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard). +The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#earlier-progress-log). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | +| 2026-11-02 | minor | Closed font-family fields accept wheel input for selected text in Character, Properties and Control (#5253); scores and estimates unchanged. | +| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Document Tools add-on's Save a Timestamped Copy (#4926); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for Divide into Dashes (Path Precision Tools add-on, #4950); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for Group Overlapping Objects (Arrange Tools add-on, #4928); headline numbers unchanged. | +| 2026-10-31 | minor | Progress log row for Export for Screens laid out as the reference app's (#4895) and the 2026-10-31 community landings; headline numbers unchanged. | +| 2026-10-31 | minor | Progress log row for the Arrange Tools add-on; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux Flatpak clipboard's core Wayland protocol text fallback; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | @@ -222,7 +251,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. | | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | -| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | +| 2026-10-10 | minor | Method changed, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | | 2026-10-10 | minor | Added the essentials-user readiness score (~75%) | | 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md | | 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index cb9183315..8f39e3b58 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -34,8 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel", # DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, # shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR -# Apache-2.0. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } +# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's +# src/windows_cores.rs, shared, uses to count the cores the process may run on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font # managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bba1976c1..b7334586c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -49,6 +49,10 @@ mod prefs_dir; #[cfg(windows)] #[path = "../../vectorcraft/src/system_fonts.rs"] mod system_fonts; +/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores. +#[cfg(windows)] +#[path = "../../vectorcraft/src/windows_cores.rs"] +mod windows_cores; use serde_json::{Value, json}; use vectorcraft_engine::file_access::{self, AutomationRoots}; @@ -161,6 +165,9 @@ fn help() -> Result<(), String> { } fn main() -> ExitCode { + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before any font lookup: the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 8fe3d23b7..33815fbf3 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -116,7 +116,8 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> { let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() }; let err = |e: vectorcraft_engine::EngineError| e.to_string(); let mut drag = |down: Point| -> Result { - let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 }; + let ev = + |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0, pen: None }; s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?; s.select_tool("directSelection", view).map_err(err)?; let undo = s.doc().map_err(err)?.history.undo.len(); @@ -215,7 +216,14 @@ fn deep(n: usize) -> Result<(), String> { let first = s.doc().map_err(err)?.selection.objects.first().copied(); let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center()); if let Some(down) = at { - let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 }; + let ev = |kind, dx: f64| PointerEvent { + kind, + pos: down + Vec2::new(dx, 0.0), + mods: Mods::default(), + backquote: false, + pressure: 1.0, + pen: None, + }; s.select_tool("selection", view).map_err(err)?; s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?; let mut dx = 0.0; diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs index b7140d7e5..88b85e9d5 100644 --- a/apps/vectorcraft-web/src/pencil.rs +++ b/apps/vectorcraft-web/src/pencil.rs @@ -23,7 +23,7 @@ use std::cell::RefCell; use std::rc::Rc; use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; -use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; +use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed}; use vectorcraft_ui_egui::touch::{self, Source}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast as _; @@ -95,7 +95,7 @@ impl Pointers { /// A pen moved or pressed with `pressure`, with the end it last pressed with. fn pen_sample(&self, pressure: f32) { - self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser })); + self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser, pose: PenPose::default() })); } fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option) { diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 0657c93c8..47d26eb7b 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -54,7 +54,12 @@ pollster = "1" # 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. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the +# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which +# src/caps_lock.rs uses to read whether Caps Lock is on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# The renderer's worker count follows those cores (src/windows_cores.rs). +vectorcraft-render = { workspace = true } [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 @@ -86,8 +91,8 @@ pollster = "1" 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"] } +# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). +x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } # 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/app.rs b/apps/vectorcraft/src/app.rs index 05c756a0c..423fb53fc 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,6 +1,8 @@ #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] use super::gpu; use super::prefs::{save_prefs, save_prefs_soon}; +#[cfg(not(target_os = "macos"))] +use super::single_instance; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; @@ -66,6 +68,9 @@ impl eframe::App for App { // macOS converts the frame from sRGB to the display's profile (#2249). #[cfg(all(target_os = "macos", feature = "wgpu"))] super::mac_color_space::tag(ui); + if let Some(on) = self.host_caps_lock.read_transition() { + self.app.set_caps_lock(on); + } file_access::unconfined(|| self.app.ui(ui)); // Preferences are written when confirmed, not only at exit: a session ended any other way // (the system shutting down, the process ended or crashing) used to lose them (#1747). @@ -105,11 +110,13 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } - // 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. - #[cfg(target_os = "macos")] + // Files from the Finder and the Dock (macOS) or from later launches (#4892) 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() { + #[cfg(target_os = "macos")] let files = open_documents::take(); + #[cfg(not(target_os = "macos"))] + let files = single_instance::take(); if !files.is_empty() { bring_window_forward(ctx); } @@ -147,15 +154,15 @@ 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))] +/// Show the window when files are opened from the Finder or the Dock, or handed over by a later +/// launch (#4892): a minimized window comes back and the app takes focus, as a document-based app +/// does (#3769). pub(crate) 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 +/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { for f in files { diff --git a/apps/vectorcraft/src/caps_lock.rs b/apps/vectorcraft/src/caps_lock.rs new file mode 100644 index 000000000..231a37e03 --- /dev/null +++ b/apps/vectorcraft/src/caps_lock.rs @@ -0,0 +1,216 @@ +//! Read the host's Caps Lock state, which gives the drawing tools the precise crosshair while it is on. + +/// Only host transitions replace a value set by a script through `view.capsLock`. +#[derive(Default)] +pub struct HostCapsLock { + previous: Option, + #[cfg(target_os = "linux")] + x11: Option, +} + +#[cfg(target_os = "linux")] +struct X11CapsLock { + connection: x11rb::rust_connection::RustConnection, + locked: bool, + initial_sequence: u16, +} + +impl HostCapsLock { + pub fn new(window: Option<&winit::window::Window>) -> Self { + #[cfg(target_os = "linux")] + let x11 = window.and_then(x11_connection); + #[cfg(not(target_os = "linux"))] + let _ = window; + Self { + previous: None, + #[cfg(target_os = "linux")] + x11, + } + } + + pub fn read_transition(&mut self) -> Option { + #[cfg(target_os = "linux")] + let current = match self.x11.as_mut() { + Some(state) => match state.poll() { + Some(locked) => Some(locked), + None => { + self.x11 = None; + None + } + }, + None => { + // Native Wayland does not expose the seat's XKB lock bit through winit or egui. + None + } + }; + #[cfg(any(target_os = "macos", windows))] + let current = Some(read()); + // This platform has no lock-state reader. + #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] + let current = None; + self.transition(current) + } + + fn transition(&mut self, reading: Option) -> Option { + reading.and_then(|current| self.observe(current)) + } + + fn observe(&mut self, current: bool) -> Option { + if self.previous.replace(current) == Some(current) { None } else { Some(current) } + } +} + +#[cfg(target_os = "linux")] +fn x11_connection(window: &winit::window::Window) -> Option { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + use x11rb::protocol::xkb; + if !matches!(window.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) { + return None; + } + let (connection, _) = x11rb::connect(None).ok()?; + let supported = x11rb::protocol::xkb::use_extension(&connection, 1, 0).ok()?.reply().ok()?.supported; + if !supported { + return None; + } + // Subscribe before the initial read so changes during setup remain queued. + xkb::select_events( + &connection, + xkb::ID::USE_CORE_KBD.into(), + xkb::EventType::default(), + xkb::EventType::default(), + xkb::MapPart::default(), + xkb::MapPart::default(), + &xkb::SelectEventsAux { + state_notify: Some(xkb::SelectEventsAuxStateNotify { + affect_state: xkb::StatePart::MODIFIER_LOCK, + state_details: xkb::StatePart::MODIFIER_LOCK, + }), + ..Default::default() + }, + ) + .ok()? + .check() + .ok()?; + let (locked, initial_sequence) = read_x11(&connection)?; + Some(X11CapsLock { connection, locked, initial_sequence }) +} + +#[cfg(target_os = "linux")] +impl X11CapsLock { + fn poll(&mut self) -> Option { + use x11rb::connection::Connection as _; + use x11rb::protocol::Event; + while let Some(event) = self.connection.poll_for_event().ok()? { + if let Event::XkbStateNotify(state) = event + // Events queued before the initial GetState reply must not replace that newer snapshot. + && state.sequence.wrapping_sub(self.initial_sequence) < 0x8000 + { + self.locked = lock_modifier_is_set(state.locked_mods); + } + } + Some(self.locked) + } +} + +#[cfg(target_os = "linux")] +fn read_x11(connection: &x11rb::rust_connection::RustConnection) -> Option<(bool, u16)> { + use x11rb::protocol::xkb; + let state = xkb::get_state(connection, xkb::ID::USE_CORE_KBD.into()).ok()?.reply().ok()?; + Some((lock_modifier_is_set(state.locked_mods), state.sequence)) +} + +#[cfg(target_os = "linux")] +fn lock_modifier_is_set(modifiers: x11rb::protocol::xproto::ModMask) -> bool { + use x11rb::protocol::xproto::ModMask; + u16::from(modifiers) & u16::from(ModMask::LOCK) != 0 +} + +#[cfg(target_os = "macos")] +const MAC_CAPS_LOCK: usize = 1 << 16; + +#[cfg(target_os = "macos")] +fn mac_flags_have_caps_lock(flags: usize) -> bool { + flags & MAC_CAPS_LOCK != 0 +} + +#[cfg(target_os = "macos")] +pub fn read() -> bool { + mac_flags_have_caps_lock(objc2_app_kit::NSEvent::modifierFlags_class().bits()) +} + +#[cfg(windows)] +fn windows_state_has_caps_lock(state: i16) -> bool { + state & 1 != 0 +} + +#[cfg(windows)] +#[allow(unsafe_code)] +pub fn read() -> bool { + // GetKeyState reports the toggle bit for the calling UI thread's keyboard state. + use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CAPITAL}; + // SAFETY: `GetKeyState` takes a virtual-key code by value and reads no caller memory. + windows_state_has_caps_lock(unsafe { GetKeyState(i32::from(VK_CAPITAL.0)) }) +} + +// Querying XWayland for a native Wayland window would read a different keyboard. + +#[cfg(test)] +mod tests { + #[test] + fn no_host_reader_preserves_scripted_value_and_next_reading_transitions() { + let mut host = super::HostCapsLock::default(); + let mut session = vectorcraft_engine::Session::new(); + session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap(); + if let Some(on) = host.transition(None) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + assert_eq!(host.transition(Some(false)), Some(false)); + } + + #[cfg(target_os = "linux")] + #[test] + fn no_x11_reader_has_no_transition() { + assert_eq!(super::HostCapsLock::default().read_transition(), None); + } + + #[test] + fn scripted_value_survives_unchanged_host_until_transition() { + let mut host = super::HostCapsLock::default(); + let mut session = vectorcraft_engine::Session::new(); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap(); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + session.execute("view.capsLock", &serde_json::json!({"on": false})).unwrap(); + if let Some(on) = host.observe(true) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(session.caps_lock); + if let Some(on) = host.observe(false) { + session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap(); + } + assert!(!session.caps_lock); + } + + #[cfg(target_os = "macos")] + #[test] + fn mac_modifier_flags_map_caps_lock() { + assert!(!super::mac_flags_have_caps_lock(0)); + assert!(!super::mac_flags_have_caps_lock(1 << 17)); + assert!(super::mac_flags_have_caps_lock(1 << 16)); + assert!(super::mac_flags_have_caps_lock((1 << 16) | (1 << 17))); + } + + #[cfg(windows)] + #[test] + fn windows_key_state_maps_toggle_bit() { + assert!(!super::windows_state_has_caps_lock(0)); + assert!(!super::windows_state_has_caps_lock(i16::MIN)); + assert!(super::windows_state_has_caps_lock(1)); + } +} diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index aafb42b9b..59b30b764 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -20,7 +20,7 @@ use std::io::{Cursor, Read}; use std::path::PathBuf; use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour}; -use vectorcraft_ui_egui::SystemClipboard; +use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard}; /// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where /// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11). @@ -157,6 +157,10 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { None if vectorcraft_engine::file_access::current().is_none() => {} None => return None, } + // A device or pipe may never end: only a regular file is read whole. + if !std::fs::metadata(&path).ok()?.is_file() { + 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)?; @@ -172,7 +176,8 @@ mod win { use clipboard_win::{formats, options::NoClear, raw}; use super::{ - BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats, + BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, + windows_formats, }; /// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the @@ -249,10 +254,16 @@ mod win { // A clipboard manager may have copied our data again as its own. let Some(ours) = self.text.as_deref() else { return false }; let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false }; - read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes()) + read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes()) } fn read(&mut self, mimes: &[&'static str]) -> Option { + self.read_deferred(mimes)?.flavour() + } + + /// A device-independent bitmap is copied off the clipboard here and decoded and encoded + /// as PNG by the work that comes back. + fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { let mut files = vec![]; { let _open = clipboard_win::Clipboard::new_attempts(10).ok()?; @@ -260,7 +271,7 @@ mod win { let _ = raw::get_file_list_path(&mut files); } // The files are read with the clipboard closed again, not keeping other apps waiting. - copied_file(files, mimes).or_else(|| { + copied_file(files, mimes).map(ClipboardRead::Now).or_else(|| { let _open = clipboard_win::Clipboard::new_attempts(10).ok()?; mimes.iter().find_map(|m| read_one(m)) }) @@ -284,16 +295,28 @@ mod win { }) } - /// One flavour, while the clipboard is open. - fn read_one(mime: &'static str) -> Option { + /// A device-independent bitmap as a PNG flavour. + fn dib_png(dib: &[u8]) -> Option { + png_flavour(&dib_image(dib)?) + } + + /// Copy both bitmap formats while the clipboard is open, then decode them in preference order. + pub(super) fn read_dibs(mut get: impl FnMut(u32) -> Option>) -> Option { + let dibs: Vec<_> = DIBS.into_iter().filter_map(&mut get).collect(); + (!dibs.is_empty()).then_some(ClipboardRead::Later(Box::new(move || dibs.iter().find_map(|dib| dib_png(dib))))) + } + + /// One flavour, while the clipboard is open. A device-independent bitmap is copied here, and + /// the work that comes back decodes each captured format in preference order. + fn read_one(mime: &'static str) -> Option { let mut data = vec![]; let mime = match mime { BITMAP if read_registered(PNG, &mut data) => PNG, BITMAP => { - return DIBS.into_iter().find_map(|id| { - data.clear(); - raw::get_vec(id, &mut data).ok()?; - png_flavour(&dib_image(&data)?) + return read_dibs(|id| { + let mut dib = vec![]; + raw::get_vec(id, &mut dib).ok()?; + Some(dib) }); } TEXT => { @@ -309,7 +332,7 @@ mod win { { data.truncate(end); } - (!data.is_empty()).then_some(Flavour { mime, data }) + (!data.is_empty()).then_some(ClipboardRead::Now(Flavour { mime, data })) } } @@ -322,7 +345,7 @@ mod portable { use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; - use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of}; + use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of}; /// What our last write put on the clipboard. enum Ours { @@ -339,6 +362,7 @@ mod portable { } /// What a write of a picture says where the clipboard carries text only. + #[cfg(target_os = "linux")] const TEXT_ONLY: &str = "this clipboard carries text only"; impl Backend { @@ -579,22 +603,27 @@ mod portable { } fn read(&mut self, mimes: &[&'static str]) -> Option { + self.read_deferred(mimes)?.flavour() + } + + /// arboard's image is read here and encoded as PNG by the work that comes back. + fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { let cb = self.cb(Asker::User).ok()?; if let Some(f) = copied_file(cb.file_list(), mimes) { - return Some(f); + return Some(ClipboardRead::Now(f)); } mimes.iter().find_map(|m| { #[cfg(target_os = "macos")] if let Some(f) = super::mac::read(m) { - return Some(f); + return Some(ClipboardRead::Now(f)); } match *m { - TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }), + TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), BITMAP => { let img = cb.get_image().ok()?; - let rgba = - image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?; - png_flavour(&rgba) + let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); + let bytes = img.bytes.into_owned(); + Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?)))) } _ => None, } @@ -1056,6 +1085,28 @@ mod tests { img.pixels().map(|p| p.0).collect() } + #[test] + #[cfg(windows)] + fn dib_fallback_captures_both_formats_before_decoding() { + use clipboard_win::formats::{CF_DIB, CF_DIBV5}; + + let valid = dib(40, 1, 1, 32, 0, &[], &bgra(&[RED])); + let mut captured = Vec::new(); + let read = win::read_dibs(|id| { + captured.push(id); + match id { + CF_DIBV5 => Some(vec![40, 0, 0]), + CF_DIB => Some(valid.clone()), + _ => None, + } + }) + .unwrap(); + assert_eq!(captured, [CF_DIBV5, CF_DIB]); + let flavour = read.flavour().unwrap(); + assert_eq!(flavour.mime, PNG); + assert_eq!(pixels(&decode_png(&flavour.data).unwrap()), [RED]); + } + #[test] fn a_screenshot_dib_pastes_opaque_and_upright() { // CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero. diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 431d40346..00f92405e 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -182,11 +182,14 @@ fn any_hardware(kinds: &[DeviceType]) -> bool { kinds.iter().any(|k| *k != DeviceType::Cpu) } -/// The largest texture side to ask the device for, which bounds the window's surface: the -/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than -/// that keeps its surface (#1459). Never below `default`, which is what was asked for before. -pub fn surface_limit(default: u32, adapter: u32) -> u32 { - default.max(adapter) +/// The limits to ask the device for: what eframe `asked` for, lowered to what the `adapter` offers +/// wherever it offers less, so a GPU short of one of them still gets a device (#2105). The texture +/// side, which bounds the window's surface, is the adapter's own, above eframe's 8192 px when the +/// adapter allows more, so a window larger than that keeps its surface (#1459). +pub fn device_limits(asked: wgpu::Limits, adapter: &wgpu::Limits) -> wgpu::Limits { + let mut limits = asked.or_worse_values_from(adapter); + limits.max_texture_dimension_2d = adapter.max_texture_dimension_2d; + limits } /// One adapter, as [`adapter_order`] sees it. diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 3d13b5157..b1511a232 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -5,11 +5,34 @@ fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { } /// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that -/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing. +/// takes 16384 px textures. #[test] fn the_surface_may_be_as_large_as_the_adapter_allows() { - assert_eq!(surface_limit(8192, 16384), 16384); - assert_eq!(surface_limit(8192, 4096), 8192); + let adapter = wgpu::Limits { max_texture_dimension_2d: 16384, ..wgpu::Limits::default() }; + let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + assert_eq!(device_limits(asked, &adapter).max_texture_dimension_2d, 16384); +} + +/// #2105: a GPU that offers less than eframe asks for on any limit (here a Raspberry Pi 5's V3D 7.1: +/// 15 inter-stage variables on Vulkan, 4096 px textures on OpenGL) gets a device with what it +/// offers, instead of failing and leaving the window to the software rasterizer. A GPU that offers +/// more is asked for no more than before, except for the texture side. +#[test] +fn the_device_asks_for_no_more_than_the_adapter_offers() { + let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + let vulkan = wgpu::Limits { max_inter_stage_shader_variables: 15, max_texture_dimension_2d: 7680, ..wgpu::Limits::default() }; + let limits = device_limits(asked.clone(), &vulkan); + assert_eq!((limits.max_inter_stage_shader_variables, limits.max_texture_dimension_2d), (15, 7680)); + assert!(limits.check_limits(&vulkan), "{limits:?}"); + let gl = wgpu::Limits { max_texture_dimension_2d: 4096, ..wgpu::Limits::downlevel_webgl2_defaults() }; + let webgl2 = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::downlevel_webgl2_defaults() }; + let limits = device_limits(webgl2.clone(), &gl); + assert_eq!(limits.max_texture_dimension_2d, 4096); + assert!(limits.check_limits(&gl), "{limits:?}"); + let ample = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + assert_eq!(device_limits(asked.clone(), &le), asked); + let better = wgpu::Limits { max_inter_stage_shader_variables: 31, max_texture_dimension_2d: 8192, ..wgpu::Limits::default() }; + assert_eq!(device_limits(asked.clone(), &better), asked, "never more than eframe asked for"); } fn names(c: &[Candidate], order: &[usize]) -> Vec { diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index e25332b61..16fe1d469 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -151,6 +151,22 @@ fn reveal_command(path: &str) -> std::process::Command { c } +/// Read a whole file. A device or pipe may never end: only a regular file is read. +fn read_file(path: &str) -> Result, String> { + if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() { + return Err(format!("{path}: not a file")); + } + std::fs::read(path).map_err(|e| e.to_string()) +} + +#[cfg(test)] +#[test] +fn read_file_refuses_what_is_not_a_regular_file() { + assert!(read_file(&std::env::temp_dir().to_string_lossy()).is_err()); + #[cfg(unix)] + assert!(read_file("/dev/zero").is_err()); +} + /// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]). fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string()) @@ -277,7 +293,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Windows reads it from the pen's pointer messages (`windows_stylus`). #[cfg(windows)] stylus: Some(windows_stylus::start()), - read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + read: Some(Box::new(read_file)), write: Some(Box::new(write_file)), exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. @@ -300,6 +316,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { reveal: Some(Box::new(reveal)), // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. open_file: Some(Box::new(open_file)), + // Edit Original of a raster image with Use System Defaults for Edit Original off (#5236). + open_in_photocraft: Some(Box::new(open_in_photocraft)), pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] @@ -334,3 +352,281 @@ fn open_file(path: &str) -> Result<(), String> { c.arg(path); c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) } + +/// Edit Original with Preferences › File Handling › Use System Defaults for Edit Original off +/// (#5236): open the raster image `path` in PhotoCraft, found only where it installs +/// ([`find_photocraft`]); `Ok(false)` when it isn't installed. PhotoCraft gets the path as its only +/// argument and is never started through a shell. +fn open_in_photocraft(path: &str) -> Result { + explorer_safe(path)?; + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from); + let Some(app) = find_photocraft(home.as_deref(), std::env::var_os("PATH").as_deref()) else { return Ok(false) }; + #[cfg(target_os = "macos")] + let mut c = { + let mut c = std::process::Command::new("open"); + c.arg("-a").arg(&app).arg(path); + c + }; + #[cfg(not(target_os = "macos"))] + let mut c = { + let mut c = std::process::Command::new(&app); + c.arg(path); + c + }; + // macOS: `open` returns once Launch Services has the request, so a short wait reports an app + // it can't open (a folder named `.app` that isn't a bundle). The wait is capped so a stalled + // `open` never holds the window: past it, the launch is left to run. Elsewhere the editor + // itself runs: waiting would last until it closes. + #[cfg(target_os = "macos")] + { + const WAIT: std::time::Duration = std::time::Duration::from_secs(2); + let fail = |why: String| format!("can't open {path} in {}: {why}", app.display()); + let mut child = c.stderr(std::process::Stdio::piped()).spawn().map_err(|e| fail(e.to_string()))?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(true), + Ok(Some(status)) => { + let mut why = String::new(); + if let Some(mut err) = child.stderr.take() { + // Best effort: without its message the exit status says why. + let _ = std::io::Read::read_to_string(&mut err, &mut why); + } + let why = why.trim().to_string(); + return Err(fail(if why.is_empty() { status.to_string() } else { why })); + } + Ok(None) if started.elapsed() < WAIT => std::thread::sleep(std::time::Duration::from_millis(20)), + Ok(None) => { + // Reaped off the UI thread once it ends. + std::thread::spawn(move || { + let _ = child.wait(); + }); + return Ok(true); + } + Err(e) => return Err(fail(e.to_string())), + } + } + } + #[cfg(not(target_os = "macos"))] + c.spawn().map(|_| true).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) +} + +/// PhotoCraft where it is installed: the first of [`photocraft_candidates`] that is an application. +pub(crate) fn find_photocraft(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Option { + photocraft_candidates(home, path_var).into_iter().find(|p| check_app(p).is_ok()) +} + +/// Where PhotoCraft may be installed: the Applications folders on macOS; on Windows where its +/// installer puts it ([`windows_candidates`]); else a `photocraft` program on the `PATH`. +pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Vec { + if cfg!(target_os = "macos") { + let mut v = vec![std::path::PathBuf::from("/Applications/PhotoCraft.app")]; + v.extend(home.map(|h| h.join("Applications/PhotoCraft.app"))); + return v; + } + let name = if cfg!(windows) { "photocraft.exe" } else { "photocraft" }; + let on_path = path_var.map(|p| std::env::split_paths(p).map(|dir| dir.join(name)).collect()).unwrap_or_default(); + if cfg!(windows) { + let folders: Vec = [ + std::env::var_os("LOCALAPPDATA").map(|d| std::path::PathBuf::from(d).join("Programs")), + std::env::var_os("ProgramW6432").map(std::path::PathBuf::from), + std::env::var_os("ProgramFiles").map(std::path::PathBuf::from), + ] + .into_iter() + .flatten() + .collect(); + return windows_candidates(registered_photocraft(), &folders, on_path); + } + on_path +} + +/// Windows: PhotoCraft's installer puts it in `ArtCraft\PhotoCraft` under a folder of +/// `program_folders` (for the current user `%LOCALAPPDATA%\Programs`, for everyone Program Files) +/// and registers its path under App Paths, which also covers a folder chosen in setup. So: the +/// `registered` paths first, then those folders, then `on_path`. +pub(crate) fn windows_candidates( + registered: Vec, + program_folders: &[std::path::PathBuf], + on_path: Vec, +) -> Vec { + let mut v = registered; + for f in program_folders { + let p = f.join("ArtCraft").join("PhotoCraft").join("photocraft.exe"); + if !v.contains(&p) { + v.push(p); + } + } + v.extend(on_path); + v +} + +/// The paths PhotoCraft's installer registers under App Paths, for the current user and then for +/// everyone (`reg query`, so no extra dependency; a hidden console window). +#[cfg(windows)] +fn registered_photocraft() -> Vec { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + // reg.exe by its full path, so nothing named `reg` in the app's folder or on PATH runs instead. + let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into()); + ["HKCU", "HKLM"] + .into_iter() + .filter_map(|hive| { + let key = format!(r"{hive}\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe"); + let out = std::process::Command::new(root.join("System32").join("reg.exe")) + .args(["query", &key, "/ve"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + registered_path(&String::from_utf8_lossy(&out.stdout)) + }) + .collect() +} + +#[cfg(not(windows))] +fn registered_photocraft() -> Vec { + Vec::new() +} + +/// The path in `reg query … /ve` output like +/// ` (Default) REG_SZ C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`. +#[cfg_attr(not(any(windows, test)), allow(dead_code))] +pub(crate) fn registered_path(text: &str) -> Option { + text.lines().find_map(|l| l.split_once("REG_SZ")).map(|(_, v)| v.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from) +} + +/// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on +/// Windows, an executable file elsewhere? Anything else (a document, a folder, a script on +/// Windows) found where PhotoCraft installs is not taken for it. +pub(crate) fn check_app(app: &std::path::Path) -> Result<(), String> { + let ext = app.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + let ok = if cfg!(target_os = "macos") { + ext == "app" && app.is_dir() + } else if cfg!(windows) { + ext == "exe" && app.is_file() + } else { + executable_file(app) + }; + if ok { Ok(()) } else { Err(format!("{} isn't an application Edit Original can open files in", app.display())) } +} + +#[cfg(unix)] +fn executable_file(p: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn executable_file(p: &std::path::Path) -> bool { + p.is_file() +} + +#[cfg(test)] +mod open_with_tests { + use super::{check_app, find_photocraft, photocraft_candidates, registered_path, windows_candidates}; + + /// A fresh, empty folder under the system's temporary folder. + fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-open-with-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// #5236: only an application counts as PhotoCraft, never a document, a folder or a missing + /// path. + #[test] + fn only_applications_are_started() { + let d = scratch("check"); + let doc = d.join("notes.txt"); + std::fs::write(&doc, "hi").unwrap(); + assert!(check_app(&doc).is_err(), "a document"); + assert!(check_app(&d).is_err(), "a folder"); + assert!(check_app(&d.join("Missing.app")).is_err(), "a missing path"); + if cfg!(target_os = "macos") { + let app = d.join("Editor.app"); + std::fs::create_dir_all(&app).unwrap(); + assert!(check_app(&app).is_ok(), "an .app bundle"); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + use std::os::unix::fs::PermissionsExt as _; + let tool = d.join("editor"); + std::fs::write(&tool, "#!/bin/sh\n").unwrap(); + assert!(check_app(&tool).is_err(), "not executable yet"); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(check_app(&tool).is_ok(), "an executable file"); + } + let _ = std::fs::remove_dir_all(&d); + } + + /// PhotoCraft is looked for only where it installs. + #[test] + fn photocraft_is_found_where_it_installs() { + let d = scratch("find"); + if cfg!(target_os = "macos") { + let candidates = photocraft_candidates(Some(&d), None); + assert_eq!(candidates.first().map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/Applications/PhotoCraft.app")); + assert!(candidates.contains(&d.join("Applications/PhotoCraft.app")), "{candidates:?}"); + std::fs::create_dir_all(d.join("Applications/PhotoCraft.app")).unwrap(); + assert!(find_photocraft(Some(&d), None).unwrap().ends_with("PhotoCraft.app")); + } else { + let bin = d.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let path_var = std::env::join_paths([&bin]).unwrap(); + // On Windows an installed PhotoCraft (registered, or in a program folder) comes first, so + // there only the `PATH` candidate is checked. + if !cfg!(windows) { + assert!(find_photocraft(None, Some(&path_var)).is_none(), "not installed"); + } + let exe = bin.join(if cfg!(windows) { "photocraft.exe" } else { "photocraft" }); + std::fs::write(&exe, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + if cfg!(windows) { + assert_eq!(photocraft_candidates(None, Some(&path_var)).last(), Some(&exe)); + assert!(check_app(&exe).is_ok()); + } else { + assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe)); + } + } + let _ = std::fs::remove_dir_all(&d); + } + + /// #5327 review: on Windows PhotoCraft is looked for where its installer puts it: the path it + /// registers, then `ArtCraft\PhotoCraft` in the per-user and per-machine program folders, then + /// the `PATH`. + #[test] + fn windows_looks_where_the_photocraft_installer_puts_it() { + use std::path::PathBuf; + let registered = vec![PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe")]; + let folders = [PathBuf::from("C:/Users/me/AppData/Local/Programs"), PathBuf::from("C:/Program Files"), PathBuf::from("C:/Program Files")]; + let on_path = vec![PathBuf::from("C:/bin/photocraft.exe")]; + let exe = |f: &str| PathBuf::from(f).join("ArtCraft").join("PhotoCraft").join("photocraft.exe"); + assert_eq!( + windows_candidates(registered, &folders, on_path), + [ + PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe"), + exe("C:/Users/me/AppData/Local/Programs"), + exe("C:/Program Files"), + PathBuf::from("C:/bin/photocraft.exe"), + ] + ); + } + + /// The registered path is read from `reg query … /ve` output, spaces and quotes included; + /// no value, no path. + #[test] + fn the_registered_path_is_read_from_reg_query() { + let out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\ArtCraft\\PhotoCraft\\photocraft.exe\r\n"; + assert_eq!(registered_path(out), Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe"))); + assert_eq!( + registered_path(" (Standard) REG_SZ \"C:\\A B\\photocraft.exe\""), + Some(std::path::PathBuf::from(r"C:\A B\photocraft.exe")) + ); + assert_eq!(registered_path("ERROR: The system was unable to find the specified registry key or value."), None); + assert_eq!(registered_path(" (Default) REG_SZ "), None); + } +} diff --git a/apps/vectorcraft/src/mac_tablet.rs b/apps/vectorcraft/src/mac_tablet.rs index b624262aa..1d9aabd44 100644 --- a/apps/vectorcraft/src/mac_tablet.rs +++ b/apps/vectorcraft/src/mac_tablet.rs @@ -1,6 +1,7 @@ -//! 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`]). +//! macOS: a drawing tablet's pen pressure (#852), tilt and barrel rotation (#764). winit 0.30 +//! hands a pen to the window there as a mouse, without them, so the app reads them from AppKit +//! itself and writes them 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: @@ -9,9 +10,14 @@ //! 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. +//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. Its `tilt` +//! (−1..1 on each axis, ±[`TILT_DEGREES`] at the ends) and its `rotation` go into the feed as the +//! pen's pose, kept for that move too. AppKit gives a rotation of 0 for a pen that does not read +//! one and a tilt of 0 for an upright pen, so one event cannot tell a pen without tilt or rotation +//! from one held upright and unturned. Each reads as none until the pen reports a value other +//! than 0 after it comes near ([`Pen`]). +//! 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, tilt and rotation 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 @@ -20,12 +26,14 @@ //! 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. +//! The event mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`). The tilt +//! and rotation follow Apple's `NSEvent` documentation, as [`RawEvent::pose`] describes. The mapping +//! works on plain values and is tested on every platform; the monitor reads them. #![cfg_attr(not(target_os = "macos"), allow(dead_code))] -use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; +use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed}; + +use crate::tablet::TILT_DEGREES; /// `NSEventType` values (AppKit headers). mod event_type { @@ -56,6 +64,10 @@ struct RawEvent { subtype: i16, /// `pressure` (tablet point events and mouse events of that subtype only). pressure: f32, + /// `tilt` (x, y), each −1..1 (the same events as `pressure`). + tilt: (f64, f64), + /// `rotation`, in degrees (the same events as `pressure`). + rotation: f32, /// `pointingDeviceType` (proximity events only). device: usize, /// `isEnteringProximity` (proximity events only). @@ -74,6 +86,40 @@ impl RawEvent { fn point(&self) -> bool { self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT) } + + /// The pen's tilt and rotation in [`PenPose`]'s units. + /// + /// AppKit's `tilt` is −1.0..1.0 on each axis and 0.0 on both with the pen perpendicular to the + /// tablet. A negative x is a tilt to the left and a positive x a tilt to the right; a negative y + /// is a tilt to the top and a positive y a tilt to the bottom + /// (). [`PenPose`]'s x is positive + /// to the right and its y positive down the page, the same signs, so each axis keeps its sign. + /// Apple gives the ends of the range as maximum tilt and does not give them in degrees + /// (), so + /// ±1.0 is scaled to ±[`TILT_DEGREES`], the degrees the X11 reader ([`crate::tablet`]) gives the + /// ends of a tilt range that is not in degrees. A value past ±1.0 is clamped. + /// + /// AppKit's `rotation` is in degrees, and a device without rotation reports 0.0 + /// (). Apple's tablet pages give + /// no direction for rotation; the Cocoa Event Handling Guide gives the `rotation` accessor's + /// degrees as counterclockwise for the rotate gesture + /// (), + /// and the pen's rotation is taken the same way. [`PenPose`]'s rotation runs clockwise, so the + /// angle is negated and brought into 0..360. + /// + /// The tilt is left out unless `tilts`, and the rotation unless `turns` ([`Pen`]). + fn pose(&self, tilts: bool, turns: bool) -> PenPose { + let degrees = |v: f64| (v.clamp(-1.0, 1.0) * TILT_DEGREES) as f32; + // `0.0 - r` rather than `-r`: a rotation of 0 gives 0, not −0. + let rotation = turns.then(|| (0.0 - self.rotation).rem_euclid(360.0)); + PenPose { tilt: tilts.then(|| [degrees(self.tilt.0), degrees(self.tilt.1)]), rotation } + } + + /// Whether the event reports a tilt other than 0, and a rotation other than 0. + fn reads(&self) -> (bool, bool) { + let read = |v: f64| v.is_finite() && v != 0.0; + (read(self.tilt.0) || read(self.tilt.1), read(f64::from(self.rotation))) + } } /// The events the monitor reads (`NSEventMask` bits are `1 << type`). @@ -86,7 +132,7 @@ fn event_mask() -> u64 { enum Update { /// Nothing: an event the monitor doesn't map. Keep, - /// The pressure and end of a pen, or `None` for a mouse. + /// The sample of a pen, or `None` for a mouse. Set(Option), /// A pen's sample that comes with a pointer move. Move(PenSample), @@ -102,10 +148,14 @@ impl Update { } } -/// Which end of the pen is near the tablet. +/// Which end of the pen is near the tablet, and whether it has reported a tilt or a rotation other +/// than 0 since it came near: until it has, its tilt or rotation reads as none, since AppKit gives +/// 0 for a pen without one and for one held upright or unturned. #[derive(Clone, Copy, Debug, Default, PartialEq)] struct Pen { eraser: bool, + tilts: bool, + turns: bool, } impl Pen { @@ -113,10 +163,16 @@ impl Pen { 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; + (self.tilts, self.turns) = (false, false); let pen = e.entering && e.device != device::CURSOR; - return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser })); + return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser, ..PenSample::default() })); } - let sample = PenSample { pressure: e.pressure, eraser: self.eraser }; + if e.point() { + let (tilts, turns) = e.reads(); + self.tilts |= tilts; + self.turns |= turns; + } + let sample = PenSample { pressure: e.pressure, eraser: self.eraser, pose: e.pose(self.tilts, self.turns) }; match (e.point(), e.mouse()) { (true, true) => Update::Move(sample), (true, false) => Update::Set(Some(sample)), @@ -187,6 +243,9 @@ mod monitor { } if raw.point() { raw.pressure = e.pressure(); + let tilt = e.tilt(); + raw.tilt = (tilt.x, tilt.y); + raw.rotation = e.rotation(); } if raw.proximity() { raw.device = e.pointingDeviceType().0; @@ -211,8 +270,14 @@ mod tests { RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() } } + /// A sample of the tip of a pen that has reported no tilt or rotation. fn tip(pressure: f32) -> PenSample { - PenSample { pressure, eraser: false } + PenSample { pressure, eraser: false, pose: PenPose::default() } + } + + /// A sample of the pen near the tablet but not yet read: no pose. + fn near(eraser: bool) -> PenSample { + PenSample { pressure: 0.0, eraser, pose: PenPose::default() } } #[test] @@ -240,16 +305,45 @@ mod tests { #[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)))); + let eraser = |pressure| PenSample { pressure, eraser: true, pose: PenPose::default() }; + assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(near(true)))); 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(1, true)), Update::Set(Some(near(false))), "the tip"); + assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(near(false))), "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"); + assert_eq!(p.handle(&e), Update::Set(Some(near(true))), "as a mouse event's subtype"); + } + + /// #764: AppKit's tilt (−1..1, x positive to the right, y positive to the bottom) reaches the + /// feed in degrees with the same signs, and its rotation, taken as counterclockwise, reaches it + /// clockwise. + #[test] + fn a_pens_tilt_and_rotation_go_into_the_feed() { + let mut p = Pen::default(); + let sample = |pressure, pose| PenSample { pressure, eraser: false, pose }; + assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)), "no tilt or rotation reported yet"); + let leaning = RawEvent { tilt: (0.5, -0.25), rotation: 30.0, ..pen(DRAG, 0.5) }; + let pose = PenPose { tilt: Some([30.0, -15.0]), rotation: Some(330.0) }; + assert_eq!(p.handle(&leaning), Update::Move(sample(0.5, pose))); + let past_the_ends = RawEvent { tilt: (-4.0, 1.5), rotation: 270.0, ..pen(event_type::TABLET_POINT, 0.8) }; + let pose = PenPose { tilt: Some([-60.0, 60.0]), rotation: Some(90.0) }; + assert_eq!(p.handle(&past_the_ends), Update::Set(Some(sample(0.8, pose))), "clamped"); + let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) }; + let Update::Move(upright) = p.handle(&pen(DRAG, 0.6)) else { panic!("a pen's drag is a move") }; + assert_eq!(upright, sample(0.6, pose), "once reported, 0 is upright and unturned"); + assert!(upright.pose.rotation.is_some_and(f32::is_sign_positive), "{upright:?}"); + let feed = StylusFeed::default(); + p.handle(&RawEvent { tilt: (f64::NAN, 0.0), rotation: -90.0, ..pen(DRAG, 0.3) }).apply(&feed); + assert_eq!(feed.pose(), Some(PenPose { tilt: None, rotation: Some(90.0) }), "a tilt that is not a number is left out"); + p.handle(&proximity(1, true)); + let tilted = RawEvent { tilt: (0.0, 0.5), ..pen(event_type::TABLET_POINT, 0.8) }; + let pose = PenPose { tilt: Some([0.0, 30.0]), rotation: None }; + assert_eq!(p.handle(&tilted), Update::Set(Some(sample(0.8, pose))), "no rotation reported since the pen came near"); + p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed); + assert_eq!(feed.pose(), None, "a mouse moved"); } #[test] @@ -259,7 +353,8 @@ mod tests { 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))); + let pressures: Vec = feed.take_moves().into_iter().map(|(p, _)| p).collect(); + assert_eq!((pressures, 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"); } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e90977726..980d3826b 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,7 +1,10 @@ //! VectorCraft desktop app. //! //! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] -//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! +//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given +//! (Windows and Linux; `single_instance`). //! //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on //! macOS instead of the macOS menu bar (`mac_menu`). @@ -16,6 +19,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] mod automation_args; +mod caps_lock; mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; @@ -45,11 +49,15 @@ mod open_documents; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(target_os = "macos"))] +mod single_instance; #[cfg(windows)] mod system_fonts; mod tablet; mod window; mod window_frame; +#[cfg(windows)] +mod windows_cores; mod windows_stylus; mod xwayland; @@ -87,6 +95,7 @@ use titlebar::{custom_titlebar, native_options}; struct App { app: VectorcraftApp, + host_caps_lock: caps_lock::HostCapsLock, /// Reported by wgpu when the window's graphics device is lost (a driver reset). graphics_loss: GraphicsLoss, /// The graphics device was lost and the unsaved changes are kept for Data Recovery. @@ -118,7 +127,9 @@ Options: (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 + --new-instance Open the files in a new window even when VectorCraft + already runs (Windows and Linux) + --version Print the version and exit -h, --help Print this help and exit Without a window (converting, scripting, the MCP server), use vectorcraft-cli: @@ -142,6 +153,7 @@ fn main() -> std::process::ExitCode { automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); let mut files = Vec::new(); let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); + let mut new_instance = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { @@ -154,6 +166,7 @@ fn main() -> std::process::ExitCode { } match a.as_str() { "--in-window-menus" => in_window_menus = true, + "--new-instance" => new_instance = true, "--version" => { println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); return std::process::ExitCode::SUCCESS; @@ -183,7 +196,19 @@ fn main() -> std::process::ExitCode { }; // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); + // Files opened from the file manager while VectorCraft runs open in its window (#4892). Before + // the log file is attached: a launch that hands its files over leaves the running app's log be. + #[cfg(not(target_os = "macos"))] + let instance = match single_instance::start(control_port.is_some(), new_instance, &files) { + single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS, + single_instance::Start::Running(primary) => primary, + }; + #[cfg(target_os = "macos")] + let _ = new_instance; vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before the first font scan (the app's start): the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); @@ -265,15 +290,15 @@ fn main() -> std::process::ExitCode { let options = native_options(custom_titlebar); // winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux // Display Server › X11 opens the window through XWayland instead, where file drops work (#1151), - // and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028). + // and the other choices do so when a pen is attached, where the pen draws (#2028, #4967). #[cfg(target_os = "linux")] let options = { let mut options = options; let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) { if xwayland::x11_libraries_found() { - if pref == Some("x11WithPen") { - log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)"); + if pref != Some("x11") { + log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)"); } else { log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)"); } @@ -321,14 +346,15 @@ fn main() -> std::process::ExitCode { // losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another. create.instance_descriptor.backend_options.dx12.shader_compiler = eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc); - // The window's surface may be as large as the adapter allows, not eframe's fixed - // 8192 px: a window stretched over several monitors, or grown by a display scale - // change, otherwise fails to configure its surface and the app closes (#1459). + // The device asks for no more than the adapter offers, so a GPU short of one of + // eframe's limits still gets a device (#2105). The window's surface may be as large as + // the adapter allows, not eframe's fixed 8192 px: a window stretched over several + // monitors, or grown by a display scale change, otherwise fails to configure its + // surface and the app closes (#1459). let device = create.device_descriptor.clone(); create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| { let mut descriptor = device(adapter); - let limit = &mut descriptor.required_limits.max_texture_dimension_2d; - *limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d); + descriptor.required_limits = gpu::device_limits(descriptor.required_limits, &adapter.limits()); descriptor }); } @@ -452,7 +478,10 @@ fn main() -> std::process::ExitCode { } } #[cfg(not(target_os = "macos"))] - let _ = in_window_menus; + { + let _ = in_window_menus; + single_instance::set_ui(&cc.egui_ctx); + } app.services.native_input_hook = native_input_hook(&app); file_access::unconfined(|| open_files(&mut app, files)); // The first frame is due from now (`gpu::watch_first_frame`). @@ -461,6 +490,7 @@ fn main() -> std::process::ExitCode { // The content and the pointer stay aligned on Windows (#1758). let window_frame = window_frame::service(cc, custom_titlebar); Ok(Box::new(App { + host_caps_lock: caps_lock::HostCapsLock::new(winit_window.as_deref()), app, graphics_loss, graphics_lost: false, @@ -473,6 +503,10 @@ fn main() -> std::process::ExitCode { }), ) }); + // The window is gone: later launches start their own app, including the one a graphics + // failure starts again below. + #[cfg(not(target_os = "macos"))] + drop(instance); #[cfg(feature = "wgpu")] let outcome = gpu::finish(outcome, &startup); #[cfg(not(feature = "wgpu"))] diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index b6dd3cc8e..0357829ae 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -96,6 +96,12 @@ fn answer(code: u32, results: &HashMap) -> Result) = v.try_clone().ok()?.try_into().ok()?; Some(label) @@ -103,9 +109,9 @@ fn answer(code: u32, results: &HashMap) -> Result Option { +/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere +/// (`file://host/`) or another scheme. +fn file_uri_path(uri: &str) -> Option> { let rest = uri.strip_prefix("file://")?; let rest = rest.strip_prefix("localhost").unwrap_or(rest); if !rest.starts_with('/') { @@ -126,8 +132,7 @@ fn file_uri_path(uri: &str) -> Option { } } } - // `show_dialog` makes rfd's path a string with `to_string_lossy` too. - Some(String::from_utf8_lossy(&out).into_owned()) + Some(out) } /// `saved` as the answer of the save dialog for `request`: the path, then the first extension of @@ -406,6 +411,7 @@ mod tests { #[test] fn file_uris_become_local_paths() { + let file_uri_path = |uri: &str| super::file_uri_path(uri).map(|b| String::from_utf8(b).unwrap()); assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft")); assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg")); assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf")); @@ -413,6 +419,9 @@ mod tests { assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2")); assert_eq!(file_uri_path("file://host/x.svg"), None); assert_eq!(file_uri_path("sftp://host/x.svg"), None); + // Not UTF-8: the bytes as they are, which `answer` refuses rather than changing them. + assert_eq!(super::file_uri_path("file:///tmp/a%FF.svg"), Some(b"/tmp/a\xff.svg".to_vec())); + assert!(matches!(answer(0, &results("file:///tmp/a%FF.svg", None)), Ok(None))); } #[test] diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index 728fe9ba8..a6daacb83 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -62,7 +62,12 @@ pub(crate) fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) use std::io::Write as _; for n in 1..100u32 { let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") }; - match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + // The preferences hold recent document paths: the copy is owner-only, whatever the umask. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(path.with_file_name(name)) { Ok(mut file) => { if file.write_all(bytes).is_ok() { return; @@ -171,6 +176,11 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else { return mine.clone(); }; + // The current Actions set (`action_set`, an index) is this window's when this window chose + // another one or the other window did not; otherwise it is the other window's. + let use_mine = chose_another_action_set(base, mine) || !chose_another_action_set(base, &out); + // The list the saved current set was chosen from. + let chosen_from = if use_mine { mine.get("action_sets").cloned() } else { out.get("action_sets").cloned() }; let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect(); for key in keys { if PASSING.contains(&key.as_str()) { @@ -180,7 +190,7 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se continue; } let (was, now) = (base.get(key), mine.get(key)); - if was == now { + if was == now || key == "action_set" { continue; } if key == "engine_prefs" @@ -209,9 +219,95 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se } } } + if use_mine { + match mine.get("action_set") { + Some(v) => { + out.insert("action_set".to_string(), v.clone()); + } + None => { + out.remove("action_set"); + } + } + } + keep_current_action_set(&mut out, chosen_from.as_ref()); Value::Object(out) } +/// Point the saved current Actions set (`action_set`) at the same set in the merged +/// `action_sets` when the merge took the index from one window and the list from another +/// ([`find_action_set`]). With no such set, no set is current. +fn keep_current_action_set(out: &mut serde_json::Map, chosen_from: Option<&serde_json::Value>) { + use serde_json::Value; + let Some(i) = out.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()) else { return }; + let merged = out.get("action_sets"); + if merged == chosen_from { + return; + } + match find_action_set(chosen_from, i, merged) { + Some(j) => { + out.insert("action_set".to_string(), Value::from(j)); + } + None => { + out.remove("action_set"); + } + } +} + +/// Was another Actions set made current from `base` to `now`? Recording into the current set, +/// renaming it, deleting it, or deleting a set before it changes the list or the index without +/// choosing another set. +fn chose_another_action_set(base: &serde_json::Map, now: &serde_json::Map) -> bool { + use serde_json::Value; + let index = |v: &serde_json::Map| v.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let (was, sets) = (base.get("action_sets"), now.get("action_sets")); + let (Some(i), Some(k)) = (index(base), index(now)) else { + return match (index(base), index(now)) { + (None, None) => false, + // No set is current: another set was chosen only if the current one is still there. + (Some(i), None) => find_action_set(was, i, sets).is_some(), + _ => true, + }; + }; + if was == sets { + return i != k; + } + if let Some(j) = find_action_set(was, i, sets) { + return j != k; + } + // The set at the same place changed while every other set stayed as it was: it was edited. + let edited_in_place = was + .and_then(Value::as_array) + .zip(sets.and_then(Value::as_array)) + .is_some_and(|(was, sets)| was.len() == sets.len() && was.iter().zip(sets).enumerate().all(|(j, (a, b))| j == i || a == b)); + !(i == k && edited_in_place) +} + +/// Where the Actions set at index `i` of `old` is in `new` (two `action_sets` lists): found by +/// its contents when no other set in either list has the same contents, otherwise by its name when +/// no other set in either list has it. +fn find_action_set(old: Option<&serde_json::Value>, i: usize, new: Option<&serde_json::Value>) -> Option { + use serde_json::Value; + let (old, new) = (old?.as_array()?, new?.as_array()?); + let set = old.get(i)?; + let only = |list: &[Value], same: &dyn Fn(&Value) -> bool| { + let mut found = list.iter().enumerate().filter(|(_, s)| same(s)); + match (found.next(), found.next()) { + (Some((j, _)), None) => Some(j), + _ => None, + } + }; + let same_contents = |s: &Value| s == set; + if only(old, &same_contents).is_some() + && let Some(j) = only(new, &same_contents) + { + return Some(j); + } + let name = set.get("name")?; + let same_name = |s: &Value| s.get("name") == Some(name); + only(old, &same_name)?; + only(new, &same_name) +} + /// The preferences file's contents: the UI state with the engine preferences. #[cfg(test)] pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { @@ -221,6 +317,8 @@ pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { /// [`prefs_bytes`] as JSON. fn prefs_value(app: &VectorcraftApp) -> serde_json::Result { let mut ui = app.ui.clone(); + // An open dialog is never restored, and its fields can hold passwords (PDF import and save). + ui.dialog = None; ui.engine_prefs = app.session.prefs.to_json(); serde_json::to_value(&ui) } @@ -233,6 +331,19 @@ mod tests { /// Two windows open at once (#4625): the one that saves last keeps the other's changes and /// adds its own, instead of putting back the preferences it started from. + #[cfg(unix)] + #[test] + fn a_kept_preferences_copy_is_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("vc-prefs-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + super::keep_prefs_copy(&dir.join("ui.json"), b"{}", "ui.unreadable"); + let mode = std::fs::metadata(dir.join("ui.unreadable.json")).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_window_saves_only_what_it_changed_over_another_windows_save() { let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}}); @@ -274,6 +385,112 @@ mod tests { assert!(!same_lasting(&base, &mine)); } + /// One window deleted the first Actions set and saved; this one made the second set current. + /// The saved index follows that set to its place in the merged list. + #[test] + fn the_current_action_set_follows_its_set_in_the_merged_list() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0}); + let on_disk = json!({"action_sets": [set("B"), set("C")]}); + let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 0})); + // The other window also recorded into B: B is found by its name. + let on_disk = json!({"action_sets": [{"name": "B", "actions": [{"name": "Action 1", "steps": []}]}, set("C")]}); + let merged = merge_changes(&base, &mine, on_disk); + assert_eq!(merged["action_set"], json!(0)); + // The other window deleted B: no set is current. + let on_disk = json!({"action_sets": [set("A"), set("C")]}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]})); + } + + /// This window deleted the first set while the index saved by the other window still stands. + #[test] + fn the_other_windows_current_action_set_follows_its_set() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")]}); + let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2}); + let mine = json!({"action_sets": [set("B"), set("C")]}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1})); + } + + /// The other window replaced or renamed the set this one made current (the saved sets do not + /// tell the two apart): no set is current. + #[test] + fn a_set_in_the_place_of_the_current_set_is_not_made_current() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")]}); + let on_disk = json!({"action_sets": [set("A"), set("D"), set("C")]}); + let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1}); + assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None); + let on_disk = json!({"action_sets": [set("A"), set("C"), set("D")]}); + assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None); + } + + /// This window deleted a set and made the set now at the old index current: its choice is + /// saved, though the index is the same number. + #[test] + fn a_new_current_set_at_the_same_index_is_saved() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1}); + let on_disk = json!({"action_sets": [{"name": "A", "actions": [{"name": "Action 1", "steps": []}]}, set("B"), set("C")], "action_set": 1}); + let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1})); + } + + /// Recording into the current set does not choose it again: the other window's choice stays. + #[test] + fn recording_into_the_current_set_keeps_the_other_windows_choice() { + let set = |name: &str| json!({"name": name, "actions": []}); + let recorded = json!({"name": "A", "actions": [{"name": "Action 1", "steps": []}]}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0}); + let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2}); + let mine = json!({"action_sets": [recorded.clone(), set("B"), set("C")], "action_set": 0}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [recorded, set("B"), set("C")], "action_set": 2})); + } + + /// Renaming the current set does not choose it again: the other window's choice stays. With no + /// other choice, the renamed set stays current. + #[test] + fn renaming_the_current_set_keeps_the_other_windows_choice() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0}); + let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1}); + let mine = json!({"action_sets": [set("A2"), set("B"), set("C")], "action_set": 0}); + assert_eq!(merge_changes(&base, &mine, on_disk)["action_set"], json!(1)); + assert_eq!(merge_changes(&base, &mine, base.clone())["action_set"], json!(0)); + } + + /// Deleting the current set does not override the other window's choice. + #[test] + fn deleting_the_current_set_keeps_the_other_windows_choice() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B")], "action_set": 0}); + let on_disk = json!({"action_sets": [set("A"), set("B")], "action_set": 1}); + let mine = json!({"action_sets": [set("B")]}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B")], "action_set": 0})); + assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B")]})); + } + + /// Deleting a set before the current one moves its index without choosing it again. + #[test] + fn deleting_a_set_before_the_current_one_keeps_it_current() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2}); + let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1}); + assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B"), set("C")], "action_set": 1})); + } + + /// Two sets with the same contents: when the other window deleted the one this window made + /// current, the one left is not made current in its place. + #[test] + fn a_set_with_the_same_contents_does_not_take_the_place_of_the_current_set() { + let set = |name: &str| json!({"name": name, "actions": []}); + let base = json!({"action_sets": [set("A"), set("A"), set("C")]}); + let on_disk = json!({"action_sets": [set("A"), set("C")]}); + let mine = json!({"action_sets": [set("A"), set("A"), set("C")], "action_set": 1}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]})); + } + #[test] fn a_file_that_is_not_an_object_is_replaced() { let base = json!({"workspace": "Essentials"}); diff --git a/apps/vectorcraft/src/profile.rs b/apps/vectorcraft/src/profile.rs index 4030d0aea..8ef035583 100644 --- a/apps/vectorcraft/src/profile.rs +++ b/apps/vectorcraft/src/profile.rs @@ -149,6 +149,15 @@ fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { } } +/// Make folder `dir`, owner-only on Unix. +fn private_dir(dir: &Path) -> std::io::Result<()> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(dir) +} + fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) { let entries = match std::fs::read_dir(from) { Ok(entries) => entries, @@ -165,7 +174,12 @@ fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) { let (src, dst) = (entry.path(), to.join(entry.file_name())); // A symbolic link is copied as what it points to, or skipped when that is a folder. let copied = match entry.file_type() { - Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)), + // The copy keeps the folder's permissions (a private recovery folder stays private): + // owner-only while it fills, then the source's. + Ok(kind) if kind.is_dir() => private_dir(&dst).and_then(|()| { + copy_dir_into(&src, &dst, skip); + std::fs::set_permissions(&dst, std::fs::metadata(&src)?.permissions()) + }), Ok(_) => std::fs::copy(&src, &dst).map(|_| ()), Err(e) => Err(e), }; @@ -387,4 +401,19 @@ mod tests { let _ = std::fs::remove_dir_all(&from); let _ = std::fs::remove_dir_all(&to); } + + #[cfg(unix)] + #[test] + fn a_copied_folder_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let from = temp("copy-mode-from"); + let to = temp("copy-mode-to"); + std::fs::create_dir_all(from.join("recovery")).unwrap(); + std::fs::write(from.join("recovery/copy.vectorcraft"), b"unsaved").unwrap(); + std::fs::set_permissions(from.join("recovery"), std::fs::Permissions::from_mode(0o700)).unwrap(); + copy_dir(&from, &to).unwrap(); + assert_eq!(std::fs::metadata(to.join("recovery")).unwrap().permissions().mode() & 0o777, 0o700); + let _ = std::fs::remove_dir_all(&from); + let _ = std::fs::remove_dir_all(&to); + } } diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs new file mode 100644 index 000000000..b8df2bb5a --- /dev/null +++ b/apps/vectorcraft/src/single_instance.rs @@ -0,0 +1,386 @@ +//! One app per user (#4892): a file opened from the file manager (double-click, Open With, a drop +//! on the executable) while VectorCraft runs opens as a new document tab in the running app, which +//! comes to the front, instead of in a second app. +//! +//! The running app (the primary) listens on a loopback port and records the port and two random +//! tokens in `instance.json` next to the preferences (owner-only on Unix; on Windows the settings +//! folder is the user's own). A later launch with files connects; the primary greets it with +//! `hello`, which only a process that could read the file (or talk to the primary) knows; only then +//! does the launch send `token` and the absolute paths of its files, one JSON line. The primary +//! checks the token, keeps the paths that are existing files, acknowledges and opens them; the +//! launch exits. Anything else (no file, no listener, a wrong greeting, no answer) starts the launch +//! as its own app, which replaces a stale file. Nothing received is ever run. +//! +//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward +//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files +//! opened from Finder to the running app itself (`open_documents`), so this is off there. + +use std::fs::{self, OpenOptions}; +use std::hash::{BuildHasher as _, RandomState}; +use std::io::{self, BufRead as _, BufReader, Read as _, Write as _}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::prefs::{prefs_enabled, prefs_path}; + +/// The endpoint file, next to the preferences. +const FILE_NAME: &str = "instance.json"; +/// The longest message either side reads, and the longest endpoint file. +const MAX_MESSAGE: u64 = 64 * 1024; +/// The most files one launch hands over. +const MAX_FILES: usize = 256; +/// A live primary's kernel accepts at once, even while its window is busy; a stale port on Windows +/// would otherwise retry for about two seconds. +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +/// How long the primary waits on one launch; it serves launches one at a time. +const SERVER_TIMEOUT: Duration = Duration::from_secs(2); +/// How long a launch waits on the primary: longer than a launch served before it can take. +const CLIENT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Paths handed over by later launches that the UI hasn't opened yet. +static PENDING: Mutex> = Mutex::new(Vec::new()); +/// The UI to wake when files arrive (unset until the window exists; the first frame opens them). +static UI: OnceLock = OnceLock::new(); + +/// What this launch does about an app already running. +pub enum Start { + /// The running app took the files: this launch exits. + Forwarded, + /// This launch runs; `Some` while it is the app later launches hand their files to. + Running(Option), +} + +/// Hand `files` to the app already running, or become the app later launches hand theirs to. +/// `automation`: started with `--control`. +pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start { + if automation || !prefs_enabled() { + return Start::Running(None); + } + let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) }; + // With nothing to hand over, the same exchange only asks whether an app is running. + let send: &[String] = if new_instance { &[] } else { files }; + match forward(&path, send) { + Ok(()) if send.is_empty() => Start::Running(None), + Ok(()) => Start::Forwarded, + Err(e) => { + log::debug!("no running VectorCraft to hand files to ({e})"); + Start::Running(listen(&path, queue)) + } + } +} + +/// Wake `ctx` when files arrive from later launches. +pub fn set_ui(ctx: &egui::Context) { + // Set once; a second call would hand over the same context. + let _ = UI.set(ctx.clone()); +} + +/// The paths later launches handed over since the last call. +pub fn take() -> Vec { + std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) +} + +fn queue(files: Vec) { + PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files); + if let Some(ctx) = UI.get() { + ctx.request_repaint(); + } +} + +/// This app is the one recorded in the endpoint file; dropping it removes the file, unless a later +/// app has replaced it since. +pub struct Primary { + path: PathBuf, + token: String, +} + +impl Drop for Primary { + fn drop(&mut self) { + if read_endpoint(&self.path).is_some_and(|e| e.token == self.token) { + // Best effort: a file left behind is stale, and the next launch replaces it. + let _ = fs::remove_file(&self.path); + } + } +} + +struct Endpoint { + port: u16, + token: String, + hello: String, +} + +fn invalid(why: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, why) +} + +fn read_endpoint(path: &Path) -> Option { + let mut text = String::new(); + fs::File::open(path).ok()?.take(MAX_MESSAGE).read_to_string(&mut text).ok()?; + let v: Value = serde_json::from_str(&text).ok()?; + let port = u16::try_from(v.get("port")?.as_u64()?).ok().filter(|p| *p != 0)?; + Some(Endpoint { port, token: v.get("token")?.as_str()?.to_owned(), hello: v.get("hello")?.as_str()?.to_owned() }) +} + +/// Hand `files` (made absolute against this launch's folder) to the app recorded at `path`; `Ok` +/// once it acknowledged them. +fn forward(path: &Path, files: &[String]) -> io::Result<()> { + let endpoint = read_endpoint(path).ok_or_else(|| invalid("no endpoint file"))?; + let files = files.iter().map(|f| std::path::absolute(f).map(|p| p.to_string_lossy().into_owned())).collect::>>()?; + let stream = TcpStream::connect_timeout(&(Ipv4Addr::LOCALHOST, endpoint.port).into(), CONNECT_TIMEOUT)?; + stream.set_read_timeout(Some(CLIENT_TIMEOUT))?; + stream.set_write_timeout(Some(CLIENT_TIMEOUT))?; + // The token and the paths go only to a listener that knows the greeting. + if !same(read_message(&stream)?.get("hello"), &endpoint.hello) { + return Err(invalid("the listener isn't VectorCraft")); + } + write_message(&stream, &json!({ "token": endpoint.token, "files": files }))?; + match read_message(&stream)?.get("ok") { + Some(Value::Bool(true)) => Ok(()), + _ => Err(invalid("refused")), + } +} + +/// Listen for later launches and record this app at `path`; each launch's files go to `deliver`. +/// `None` when that fails (logged): the app then runs on its own, as before. +fn listen(path: &Path, deliver: impl Fn(Vec) + Send + 'static) -> Option { + try_listen(path, deliver).inspect_err(|e| log::warn!("files opened while VectorCraft runs will open in another window: {e}")).ok() +} + +fn try_listen(path: &Path, deliver: impl Fn(Vec) + Send + 'static) -> io::Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let (token, hello) = random_tokens(); + write_endpoint(path, &json!({ "port": port, "token": token, "hello": hello }))?; + // From here on a failure removes the file again. + let primary = Primary { path: path.to_path_buf(), token: token.clone() }; + std::thread::Builder::new().name("open-files-listener".into()).spawn(move || { + for stream in listener.incoming() { + match stream.and_then(|s| receive(&s, &hello, &token)) { + Ok(files) if files.is_empty() => {} + Ok(files) => deliver(files), + Err(e) => { + log::warn!("refused files from another launch: {e}"); + // An accept that keeps failing (out of handles) doesn't spin. + std::thread::sleep(Duration::from_millis(50)); + } + } + } + })?; + Ok(primary) +} + +/// Serve one launch: greet it, check its token, keep the paths that are existing files. +fn receive(stream: &TcpStream, hello: &str, token: &str) -> io::Result> { + stream.set_read_timeout(Some(SERVER_TIMEOUT))?; + stream.set_write_timeout(Some(SERVER_TIMEOUT))?; + write_message(stream, &json!({ "hello": hello }))?; + let message = read_message(stream)?; + if !same(message.get("token"), token) { + return Err(invalid("wrong token")); + } + let files = message.get("files").and_then(Value::as_array).ok_or_else(|| invalid("no file list"))?; + if files.len() > MAX_FILES { + return Err(invalid("too many files")); + } + let files = files + .iter() + .filter_map(Value::as_str) + .filter(|f| { + let p = Path::new(f); + let ok = p.is_absolute() && p.is_file(); + if !ok { + log::warn!("another launch named {f}, which isn't a file"); + } + ok + }) + .map(str::to_owned) + .collect(); + write_message(stream, &json!({ "ok": true }))?; + Ok(files) +} + +/// One JSON line of at most [`MAX_MESSAGE`] bytes. Each side sends one line and waits for the +/// other's, so nothing read past it is lost. +fn read_message(stream: &TcpStream) -> io::Result { + let mut line = Vec::new(); + BufReader::new(stream.take(MAX_MESSAGE)).read_until(b'\n', &mut line)?; + if line.last() != Some(&b'\n') { + return Err(invalid("message too long or cut short")); + } + serde_json::from_slice(&line).map_err(|e| invalid(&e.to_string())) +} + +fn write_message(mut stream: &TcpStream, message: &Value) -> io::Result<()> { + let mut line = serde_json::to_vec(message).map_err(io::Error::other)?; + line.push(b'\n'); + stream.write_all(&line) +} + +/// `given` is the string `expected`, compared in constant time. +fn same(given: Option<&Value>, expected: &str) -> bool { + given + .and_then(Value::as_str) + .is_some_and(|g| g.len() == expected.len() && g.bytes().zip(expected.bytes()).fold(0, |acc, (a, b)| acc | (a ^ b)) == 0) +} + +/// Two unrelated 128-bit hex tokens. `RandomState` is keyed from the system's random numbers, and +/// its keyed hash of distinct values gives unpredictable, independent output (no crate needed). +fn random_tokens() -> (String, String) { + let keyed = RandomState::new(); + let token = |a: u8, b: u8| format!("{:016x}{:016x}", keyed.hash_one(a), keyed.hash_one(b)); + (token(1, 2), token(3, 4)) +} + +/// Replace `path` with `endpoint` at once (a temporary file renamed over it), readable only by the +/// user on Unix. +fn write_endpoint(path: &Path, endpoint: &Value) -> io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension(format!("{}.tmp", std::process::id())); + // A leftover of an earlier app with the same process id; `create_new` sets the mode below. + let _ = fs::remove_file(&tmp); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let bytes = serde_json::to_vec(endpoint).map_err(io::Error::other)?; + let written = options.open(&tmp).and_then(|mut f| f.write_all(&bytes)).and_then(|()| fs::rename(&tmp, path)); + if written.is_err() { + // Best effort: the temporary file is all there is to clean up. + let _ = fs::remove_file(&tmp); + } + written +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + + use super::*; + + /// A fresh folder for one test, with an existing file in it. + fn folder(name: &str) -> (PathBuf, PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vc-single-instance-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a b.svg"); + fs::write(&file, "").unwrap(); + let file = file.to_string_lossy().into_owned(); + (dir.join(FILE_NAME), dir, file) + } + + fn primary(path: &Path) -> (Primary, mpsc::Receiver>) { + let (tx, rx) = mpsc::channel(); + let primary = listen(path, move |files| tx.send(files).unwrap()).unwrap(); + (primary, rx) + } + + /// Connects as a launch would and sends `message` after the greeting; what came back. + fn send_raw(path: &Path, message: &[u8]) -> io::Result { + let endpoint = read_endpoint(path).unwrap(); + let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, endpoint.port)).unwrap(); + stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap(); + assert!(same(read_message(&stream).unwrap().get("hello"), &endpoint.hello)); + (&stream).write_all(message)?; + read_message(&stream) + } + + #[test] + fn a_launch_hands_its_files_to_the_running_app() { + let (path, dir, file) = folder("forward"); + let (primary, rx) = primary(&path); + let missing = dir.join("missing.svg").to_string_lossy().into_owned(); + forward(&path, &[file.clone(), missing]).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file), "the missing file is left out"); + // An empty list only asks whether the app runs: nothing is delivered. + forward(&path, &[]).unwrap(); + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + drop(primary); + assert!(!path.exists(), "the endpoint file goes with the app"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_wrong_token_garbage_and_oversized_messages_are_refused() { + let (path, dir, file) = folder("refused"); + let (_primary, rx) = primary(&path); + let token = read_endpoint(&path).unwrap().token; + let wrong = format!("{}\n", json!({ "token": "0".repeat(token.len()), "files": [file] })); + assert!(send_raw(&path, wrong.as_bytes()).is_err()); + assert!(send_raw(&path, b"not json\n").is_err()); + assert!(send_raw(&path, &vec![b'a'; MAX_MESSAGE as usize + 10]).is_err()); + let many = format!("{}\n", json!({ "token": token, "files": vec![file.clone(); MAX_FILES + 1] })); + assert!(send_raw(&path, many.as_bytes()).is_err()); + let relative = format!("{}\n", json!({ "token": token, "files": ["a b.svg", 7] })); + assert_eq!(send_raw(&path, relative.as_bytes()).unwrap(), json!({ "ok": true })); + assert!(rx.try_recv().is_err(), "nothing was delivered"); + // The listener still serves a launch with the right token. + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_stale_endpoint_file_is_ignored_and_replaced() { + let (path, dir, file) = folder("stale"); + // No listener on the recorded port any more. + let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + // A listener that doesn't know the greeting gets neither the token nor the paths. + let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = impostor.local_addr().unwrap().port(); + write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + let heard = std::thread::spawn(move || { + let (stream, _) = impostor.accept().unwrap(); + write_message(&stream, &json!({ "hello": "x" })).unwrap(); + stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap(); + read_message(&stream).ok() + }); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + assert!(heard.join().unwrap().is_none()); + // Garbage in the file is no endpoint either. + fs::write(&path, b"\xff not json").unwrap(); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + // A new primary replaces the stale file. + let (_primary, rx) = primary(&path); + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_replaced_endpoint_file_stays_when_the_earlier_app_ends() { + let (path, dir, _) = folder("replaced"); + let (first, _rx1) = primary(&path); + let (_second, _rx2) = primary(&path); + let second_token = read_endpoint(&path).unwrap().token; + drop(first); + assert_eq!(read_endpoint(&path).map(|e| e.token), Some(second_token)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn tokens_are_random_and_distinct() { + let (a, b) = random_tokens(); + let (c, _) = random_tokens(); + assert_eq!(a.len(), 32); + assert!(a != b && a != c); + } + + #[cfg(unix)] + #[test] + fn the_endpoint_file_is_the_users_alone() { + use std::os::unix::fs::PermissionsExt as _; + let (path, dir, _) = folder("mode"); + let _primary = primary(&path); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs index f2c1fd725..ed4087e33 100644 --- a/apps/vectorcraft/src/tablet.rs +++ b/apps/vectorcraft/src/tablet.rs @@ -1,17 +1,24 @@ -//! 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. +//! Linux: a drawing tablet's pen pressure, tilt and barrel rotation under X11, XWayland included +//! (#764). winit 0.30 hands a pen to the window there as a mouse, without any of them, so the app +//! reads them from XInput2 itself and writes them 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. +//! unless its XInput type is a touchpad's or a touchscreen's. Its valuators go into the feed: +//! +//! - "Abs Pressure", scaled from the valuator's range to 0..1; +//! - "Abs Tilt X" and "Abs Tilt Y" (a pen that has both), in degrees as they are when the range +//! fits in ±90, otherwise scaled to ±[`TILT_DEGREES`]; +//! - "Abs Rotary Z" (the barrel rotation of an Art Pen), scaled to a turn with the middle of the +//! range at 0°. +//! +//! Any other pointer device moving sets the feed to `None`. Raw events carry only the valuators +//! that changed, so the last value of each axis of each pen is kept; an axis the pen has that no +//! event has carried yet reads 0 (upright, unturned), and an axis it lacks reads nothing. //! //! 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 @@ -22,13 +29,18 @@ //! 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. +//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`). +//! PhotoCraft maps the rotation to 0..360° and reads an axis a pen lacks as 0; here the middle of +//! the rotation's range is 0°, and an axis a pen lacks reads nothing. #![cfg_attr(not(target_os = "linux"), allow(dead_code))] use std::collections::HashMap; -use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; +use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed}; + +/// The tilt of a valuator whose range does not fit in ±90 (so does not give degrees), at the ends +/// of its range. +pub(crate) const TILT_DEGREES: f64 = 60.0; /// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer /// than 16). @@ -41,33 +53,76 @@ const MAX_DEVICES: usize = 256; /// 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. +/// Which pen axis a valuator carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AxisKind { + Pressure, + TiltX, + TiltY, + Rotation, +} + +impl AxisKind { + /// The kind of the valuator labeled `label`, if it is one of a pen's. + fn from_label(label: &str) -> Option { + match label { + "Abs Pressure" => Some(Self::Pressure), + "Abs Tilt X" => Some(Self::TiltX), + "Abs Tilt Y" => Some(Self::TiltY), + "Abs Rotary Z" => Some(Self::Rotation), + _ => None, + } + } + + /// Where [`Pens`] keeps the last value of this axis. + fn slot(self) -> usize { + match self { + Self::Pressure => 0, + Self::TiltX => 1, + Self::TiltY => 2, + Self::Rotation => 3, + } + } +} + +/// A valuator of a pen: what it carries, its number in a raw event's valuator mask, and its range. #[derive(Clone, Copy, Debug, PartialEq)] struct Axis { + kind: AxisKind, 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 { + /// A raw value of this valuator as a pressure of 0..1, a tilt in degrees (−90..90) or a + /// rotation in degrees (−180..180, the middle of the range at 0). A value that is not a number + /// is a mouse's pressure, 1, or a tilt or rotation of 0. + fn map(&self, v: f64) -> f32 { if !v.is_finite() { - return 1.0; + return if self.kind == AxisKind::Pressure { 1.0 } else { 0.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 + let unit = unit.clamp(0.0, 1.0); + match self.kind { + AxisKind::Pressure => unit as f32, + AxisKind::TiltX | AxisKind::TiltY if self.min >= -90.0 && self.max <= 90.0 && span > 0.0 => v.clamp(-90.0, 90.0) as f32, + AxisKind::TiltX | AxisKind::TiltY => ((unit * 2.0 - 1.0) * TILT_DEGREES) as f32, + AxisKind::Rotation => ((unit - 0.5) * 360.0) as f32, + } } } /// A pointer device the X server lists. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, 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 name the X server lists, so that another device under the same id compares unequal. + name: String, + /// The pen's valuators: a pen has a pressure valuator, a mouse, a touchpad or a touchscreen + /// has none (and keeps none). + axes: Vec, /// The eraser end of a pen: its name or its XInput type contains "eraser", in any case. eraser: bool, } @@ -77,14 +132,31 @@ impl Device { /// 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 mut axes: Vec = Vec::new(); + for (number, label, min, max) in valuators { + // The first valuator of each kind. + if let Some(kind) = AxisKind::from_label(&label) + && number < MAX_AXES + && !axes.iter().any(|a| a.kind == kind) + { + axes.push(Axis { kind, number, min, max }); + } + } + if finger || !axes.iter().any(|a| a.kind == AxisKind::Pressure) { + axes.clear(); + } let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser")); - Self { id, pressure, eraser } + Self { id, name: name.to_string(), axes, eraser } + } + + /// The valuator of `kind`, if the device is a pen that has one. + fn axis(&self, kind: AxisKind) -> Option<&Axis> { + self.axes.iter().find(|a| a.kind == kind) + } + + /// A pen: a device with a pressure valuator. + fn is_pen(&self) -> bool { + !self.axes.is_empty() } } @@ -93,36 +165,46 @@ impl Device { 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. + /// The pressure, end, tilt and rotation of a pen, or `None` for another pointer. Set(Option), } -/// The pointer devices and the last pressure of each pen. +/// The pointer devices and the last pressure, tilt and rotation of each pen. #[derive(Debug, Default)] struct Pens { devices: HashMap, - last: HashMap, + last: HashMap; 4]>, } impl Pens { /// Replace the device list (at the start, and when devices come and go or change). + /// A device keeps its last values only while it is listed unchanged: another device under the + /// same id starts without them. 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)); + let devices: HashMap = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect(); + self.last.retain(|id, _| devices.get(id).is_some_and(|d| self.devices.get(id) == Some(d))); + self.devices = devices; } /// 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); + if !device.is_pen() { + return Update::Set(None); + } + let last = self.last.entry(id).or_default(); for (number, v) in valuators { - if number == axis.number { - *last = axis.pressure(v); + for axis in device.axes.iter().filter(|a| a.number == number) { + if let Some(slot) = last.get_mut(axis.kind.slot()) { + *slot = Some(axis.map(v)); + } } } - Update::Set(Some(PenSample { pressure: *last, eraser })) + // An axis the pen has reads 0 until an event carries it; one it lacks reads nothing. + let value = |kind: AxisKind| device.axis(kind).map(|_| last.get(kind.slot()).copied().flatten().unwrap_or(0.0)); + let tilt = value(AxisKind::TiltX).zip(value(AxisKind::TiltY)).map(|(x, y)| [x, y]); + let pose = PenPose { tilt, rotation: value(AxisKind::Rotation) }; + Update::Set(Some(PenSample { pressure: value(AxisKind::Pressure).unwrap_or(0.0), eraser: device.eraser, pose })) } /// A raw event as the X server sends it: its valuator mask and the values of the valuators @@ -149,8 +231,8 @@ 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. +/// Start reading pen pressure, tilt and rotation 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(); @@ -224,7 +306,7 @@ mod x11 { } } - /// The slave pointer devices with their names and pressure valuators (none on any error). + /// The slave pointer devices with their names and pen 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); @@ -265,14 +347,16 @@ mod x11 { 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. + /// The valuators of a Wacom pen as the X server lists them: x and y, pressure from 0 to + /// 65,535, tilt in degrees, and an Art Pen's barrel rotation. 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), + (4, "Abs Tilt Y".to_string(), -64.0, 63.0), + (5, "Abs Rotary Z".to_string(), -900.0, 899.0), ] } @@ -295,19 +379,22 @@ mod tests { #[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); + assert_eq!(pen(9).axis(AxisKind::Pressure), Some(&Axis { kind: AxisKind::Pressure, number: 2, min: 0.0, max: 65535.0 })); + assert_eq!(pen(9).axes.len(), 4, "pressure, tilt and rotation"); + assert!(!mouse(12).is_pen()); 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"); + assert!(!pad.is_pen() && pad.axes.is_empty(), "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"); + assert!(!far.is_pen(), "valuator numbers past the limit are ignored"); + let tilt_only = Device::new(14, "", Some("STYLUS"), [(3, "Abs Tilt X".to_string(), -64.0, 63.0), (4, "Abs Tilt Y".to_string(), -64.0, 63.0)]); + assert!(!tilt_only.is_pen() && tilt_only.axes.is_empty(), "no pressure, no pen"); // 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:?}"); + assert!(Device::new(15, "", kind, pen_valuators()).is_pen(), "{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}"); + assert!(Device::new(16, "", Some(kind), pen_valuators()).axes.is_empty(), "{kind}"); } } @@ -324,7 +411,7 @@ mod tests { ]; for (name, kind) in erasers { let d = Device::new(10, name, kind, pen_valuators()); - assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})"); + assert!(d.eraser && d.is_pen(), "{name} ({kind:?})"); } let tips = [ ("Wacom Intuos Pro M Pen stylus", Some("STYLUS")), @@ -343,13 +430,14 @@ mod tests { 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(); + let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) }; pens.raw_event(&feed, 9, &[0b100], &[32767.5]); - assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false }))); + assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false, pose }))); 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!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true, pose }))); 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))); @@ -361,6 +449,71 @@ mod tests { assert_eq!(feed.eraser(), None); } + /// #764: a pen's tilt goes into the feed in degrees and its barrel rotation as a turn, the + /// middle of the valuator's range at 0°; a pen without those valuators reads neither. + #[test] + fn a_pens_tilt_and_rotation_go_into_the_feed() { + let mut pens = Pens::default(); + let pressure_only = Device::new(10, "", Some("STYLUS"), pen_valuators().into_iter().take(3)); + let one_tilt = Device::new(11, "", Some("STYLUS"), pen_valuators().into_iter().take(4)); + pens.set_devices([pen(9), pressure_only, one_tilt, mouse(12)]); + let feed = StylusFeed::default(); + // Pressure, tilt x, tilt y and rotation: valuators 2 to 5. + pens.raw_event(&feed, 9, &[0b11_1100], &[32767.5, -30.0, 45.0, 0.0]); + let pose = feed.pose().unwrap(); + assert_eq!((feed.held(), pose.tilt), (Some(0.5), Some([-30.0, 45.0]))); + assert!(pose.rotation.is_some_and(|r| r < 0.2), "{pose:?}"); + // A quarter of the range back is a quarter turn back; the tilt stays as it was. + pens.raw_event(&feed, 9, &[0b10_0000], &[-450.0]); + let pose = feed.pose().unwrap(); + assert_eq!(pose.tilt, Some([-30.0, 45.0])); + assert!(pose.rotation.is_some_and(|r| (r - 270.0).abs() < 0.2), "{pose:?}"); + // A pen with no tilt or rotation valuators, or with one tilt valuator only, reads none. + pens.raw_event(&feed, 10, &[0b100], &[65535.0]); + assert_eq!((feed.held(), feed.pose()), (Some(1.0), None)); + pens.raw_event(&feed, 11, &[0b1100], &[65535.0, 20.0]); + assert_eq!(feed.pose(), None); + // The mouse: none. + pens.raw_event(&feed, 12, &[0b1], &[3.0]); + assert_eq!(feed.pose(), None); + // A pen whose tilt and rotation no event has carried yet is upright and unturned. + let mut fresh = Pens::default(); + fresh.set_devices([pen(9)]); + let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) }; + assert_eq!(fresh.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright }))); + } + + /// A device listed again unchanged keeps its last tilt and rotation; another device listed + /// under the same id starts upright and unturned. + #[test] + fn a_device_replaced_under_the_same_id_starts_without_the_old_pose() { + let mut pens = Pens::default(); + pens.set_devices([pen(9)]); + pens.handle(9, [(3, -30.0), (4, 45.0), (5, -450.0)]); + pens.set_devices([pen(9)]); + let Update::Set(Some(PenSample { pose, .. })) = pens.handle(9, [(2, 0.0)]) else { panic!("no pen") }; + assert_eq!(pose.tilt, Some([-30.0, 45.0])); + let other = Device::new(9, "HUION Kamvas Pen Pen (0x1)", Some("STYLUS"), pen_valuators()); + pens.set_devices([other]); + let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) }; + assert_eq!(pens.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright }))); + } + + /// Tilt valuators whose range does not fit in ±90 are scaled to ±60°. + #[test] + fn tilt_ranges_that_are_not_degrees_are_scaled() { + let d = Device::new( + 1, + "", + None, + [(2, "Abs Pressure".into(), 0.0, 1023.0), (3, "Abs Tilt X".into(), 0.0, 127.0), (4, "Abs Tilt Y".into(), -1000.0, 1000.0)], + ); + let mut pens = Pens::default(); + pens.set_devices([d]); + let Update::Set(Some(PenSample { pose, .. })) = pens.handle(1, [(3, 127.0), (4, -1000.0)]) else { panic!("no pen") }; + assert_eq!(pose.tilt, Some([60.0, -60.0])); + } + #[test] fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() { let mut pens = Pens::default(); @@ -389,13 +542,19 @@ mod tests { #[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)]; + let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN), (90.0, -90.0)]; for (min, max) in ranges { let mut pens = Pens::default(); - pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]); + let labels = ["Abs Pressure", "Abs Tilt X", "Abs Tilt Y", "Abs Rotary Z"]; + pens.set_devices([Device::new(1, "", None, (2..).zip(labels).map(|(n, l)| (n, l.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") }; + let Update::Set(Some(PenSample { pressure: p, pose, .. })) = pens.handle(1, (2..6).map(|n| (n, v))) else { + panic!("{min}..{max}: {v} gave no pressure") + }; assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}"); + let tilt = pose.tilt.unwrap_or_default(); + assert!(tilt.iter().all(|t| (-90.0..=90.0).contains(t)), "{min}..{max}: {v} gave {pose:?}"); + assert!(pose.rotation.is_some_and(|r| (-180.0..=180.0).contains(&r)), "{min}..{max}: {v} gave {pose:?}"); } } let mut pens = Pens::default(); diff --git a/apps/vectorcraft/src/windows_cores.rs b/apps/vectorcraft/src/windows_cores.rs new file mode 100644 index 000000000..b35c0876c --- /dev/null +++ b/apps/vectorcraft/src/windows_cores.rs @@ -0,0 +1,40 @@ +//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker +//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor +//! in the machine, also when the process may run on fewer (started with `start /affinity`, or +//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the +//! CLI shares this file. +//! +//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code` +//! allowance. +#![allow(unsafe_code)] + +use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask}; + +/// Tells the renderer how many cores the process may run on. +pub fn install() { + vectorcraft_render::set_usable_cores(usable_cores()); +} + +/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a +/// process with threads in more than one processor group). +fn usable_cores() -> Option { + let (mut process, mut system) = (0usize, 0usize); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers + // point at live `usize`s for the length of the call. + unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?; + (process != 0).then(|| process.count_ones() as usize) +} + +#[cfg(test)] +mod tests { + /// The mask Windows reports for this process gives at least one core and no more than the + /// machine has. (A child process started with `start /affinity` would test a one-core mask + /// end to end, but a process spawned while other tests hold sockets inherits them and resets + /// their connections; `automatic_threads` covers the one-core count.) + #[test] + fn the_affinity_mask_gives_the_cores_the_process_may_use() { + let cores = super::usable_cores().unwrap(); + let all = std::thread::available_parallelism().unwrap().get(); + assert!((1..=all).contains(&cores), "{cores} of {all}"); + } +} diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs index 27cb24438..b18bcddae 100644 --- a/apps/vectorcraft/src/windows_stylus.rs +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -22,7 +22,7 @@ use std::cell::RefCell; use std::sync::OnceLock; use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase}; -use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; +use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed}; /// `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. @@ -114,7 +114,7 @@ fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> _ if in_contact => 1.0, _ => 0.0, }; - PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 } + PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() } } /// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse, @@ -403,7 +403,11 @@ mod tests { fn a_pens_pressure_is_the_one_winit_gives_its_touch() { assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5); assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0); - assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering"); + assert_eq!( + pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), + PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() }, + "hovering" + ); assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully"); assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one"); assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0"); @@ -420,7 +424,7 @@ mod tests { #[test] fn pen_messages_set_the_feed_and_other_pointers_clear_it() { - let pen = PenSample { pressure: 0.0, eraser: true }; + let pen = PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() }; // `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word. let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7; let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") }; diff --git a/apps/vectorcraft/src/xwayland.rs b/apps/vectorcraft/src/xwayland.rs index 509a70056..38b210c05 100644 --- a/apps/vectorcraft/src/xwayland.rs +++ b/apps/vectorcraft/src/xwayland.rs @@ -1,8 +1,10 @@ //! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland //! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop, -//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app -//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11 -//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and +//! #1151). Automatic and X11 (XWayland) When a Pen Is Attached do so only when a pen is attached +//! as the app starts, as PhotoCraft does: winit 0.30 doesn't bind the Wayland tablet protocol, so +//! on native Wayland the pen moves only the compositor's cursor, and XWayland hands the pen to X11 +//! windows as a pointer (#2028, #4967). `VECTORCRAFT_NATIVE_WAYLAND=1` keeps a pen's window on +//! Wayland. Only when XWayland can run the window: an X display is set and //! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland, //! so a missing library never keeps the app from starting. //! @@ -21,16 +23,17 @@ const X11_LIBS: &[&str] = &["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"]; /// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or -/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever -/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for -/// `x11WithPen`, after the session checks. +/// any other choice (`automatic`, `x11WithPen`, none saved) with `pen` finding a pen and +/// `VECTORCRAFT_NATIVE_WAYLAND` unset (or `0`); a Wayland session (winit picks Wayland whenever +/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only after +/// the other checks. pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option, pen: impl FnOnce() -> bool) -> bool { let set = |name: &str| var(name).is_some_and(|v| !v.is_empty()); let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY"); + let native_wayland = var("VECTORCRAFT_NATIVE_WAYLAND").is_some_and(|v| !v.is_empty() && v != "0"); match pref { Some("x11") => session, - Some("x11WithPen") => session && pen(), - _ => false, + _ => session && !native_wayland && pen(), } } @@ -149,27 +152,35 @@ mod tests { let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]); assert!(wanted(Some("x11"), &wayland, no_probe)); assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe)); - assert!(!wanted(Some("automatic"), &wayland, no_probe)); - assert!(!wanted(None, &wayland, no_probe)); - assert!(!wanted(Some("wayland"), &wayland, no_probe)); + assert!(!wanted(Some("automatic"), &wayland, || false)); + assert!(!wanted(None, &wayland, || false)); // An X11 session already opens on X11; without an X display there is no XWayland to use. assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe)); assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe)); assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe)); } - /// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X - /// display when a pen is found, the session's own display server otherwise. The pen check - /// runs only on such a session. + /// Automatic and X11 (XWayland) When a Pen Is Attached (#2028, #4967): XWayland on a Wayland + /// session with an X display when a pen is found, where the pen reaches the window, the + /// session's own display server otherwise. The pen check runs only on such a session, and + /// `VECTORCRAFT_NATIVE_WAYLAND` keeps Wayland. #[test] - fn the_pen_choice_opens_xwayland_only_with_a_pen() { + fn a_pen_opens_xwayland_unless_native_wayland_is_asked_for() { let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]); - assert!(wanted(Some("x11WithPen"), &wayland, || true)); - assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true)); - assert!(!wanted(Some("x11WithPen"), &wayland, || false)); - assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe)); - assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe)); - assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe)); + let native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "1")]); + let not_native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "0")]); + for pref in [Some("automatic"), None, Some("x11WithPen")] { + assert!(wanted(pref, &wayland, || true), "{pref:?}"); + assert!(wanted(pref, env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true), "{pref:?}"); + assert!(!wanted(pref, &wayland, || false), "{pref:?}"); + assert!(!wanted(pref, env(&[("DISPLAY", ":0")]), no_probe), "{pref:?}"); + assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe), "{pref:?}"); + assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe), "{pref:?}"); + assert!(!wanted(pref, &native, no_probe), "{pref:?}"); + assert!(wanted(pref, ¬_native, || true), "{pref:?}"); + } + // The X11 choice opens XWayland with or without a pen; the opt-out is for the pen only. + assert!(wanted(Some("x11"), &native, no_probe)); } /// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 0b8fce921..5f57c94a6 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -16,9 +16,7 @@ head-and-shoulders portrait on a full-bleed field, no frame or roundel, with the **Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112`, clipped. The macOS files add Apple's transparent margin (tile = 824/1024 of the canvas); Windows and Linux use the tile edge to edge. -**Provenance:** the owner's original ArtCraft drawing (2880 px, keyed to the palette), vectorised with -craftrules `assets/logo-options/_tools/vectorize_tile.py`. The drawing is kept in craftrules at -`craftrules/assets/app-icons/vectorcraft/source.png`, not here. `vectorcraft.svg` is the canonical +**Provenance:** the owner's original ArtCraft drawing, vectorised. `vectorcraft.svg` is the canonical artwork. Licence: see `LICENSE.txt`. ## Files diff --git a/assets/icons/dc-touch-type.svg b/assets/icons/dc-touch-type.svg index f5af072c9..2f9a196c8 100644 --- a/assets/icons/dc-touch-type.svg +++ b/assets/icons/dc-touch-type.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type-area.svg b/assets/icons/dc-type-area.svg index f37960dae..538b0ed24 100644 --- a/assets/icons/dc-type-area.svg +++ b/assets/icons/dc-type-area.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type-path.svg b/assets/icons/dc-type-path.svg index 42584412c..a57eceffd 100644 --- a/assets/icons/dc-type-path.svg +++ b/assets/icons/dc-type-path.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type-vertical-area.svg b/assets/icons/dc-type-vertical-area.svg new file mode 100644 index 000000000..b1c530b84 --- /dev/null +++ b/assets/icons/dc-type-vertical-area.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-vertical-path.svg b/assets/icons/dc-type-vertical-path.svg new file mode 100644 index 000000000..5e5955bd8 --- /dev/null +++ b/assets/icons/dc-type-vertical-path.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-vertical.svg b/assets/icons/dc-type-vertical.svg index d72855530..69fc0b5ff 100644 --- a/assets/icons/dc-type-vertical.svg +++ b/assets/icons/dc-type-vertical.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type.svg b/assets/icons/dc-type.svg new file mode 100644 index 000000000..e87a94f67 --- /dev/null +++ b/assets/icons/dc-type.svg @@ -0,0 +1 @@ + diff --git a/contributors/people.toml b/contributors/people.toml index 80ba8da52..db94e2a89 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -183,3 +180,16 @@ real_name = "Ashok Ram" [people.dylanmtaylor] sync_github_name = true + +[people.marshiyar] +display_name = "Arshiya" +real_name = "Arshiya Rahgozar" +sync_github_name = false + +[people.WadahBadawi] +display_name = "Wadah Badawi" +sync_github_name = true + +[people.JonRC] +real_name = "Jonathan Cardoso" +display_name = "Jonathan Cardoso" diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 62e2974d2..fd2d6dc71 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -17,12 +17,13 @@ Affinity files. | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) | | Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths | | Compound shapes (`Comp`, add and subtract) | one even-odd path | -| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) | +| A shape, curve or text with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) | | Vector masks (`AdCh` curves and shapes) | clip group | | 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; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance | +| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight | | Fill layers (`FRst`) | filled rectangles | | 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) | @@ -33,7 +34,7 @@ Affinity files. | 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: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes, +dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold, 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 diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 6b1df9510..f26948e91 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -15,6 +15,13 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; /// A save appends one table; real files have one to a few hundred. const MAX_TABLES: usize = 4096; +/// Allocation records read over the whole table chain. Each save lists every entry again, so a +/// document with a few hundred saves of a few thousand entries stays far below; tables whose +/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work. +const MAX_INDEX_RECORDS: usize = 1 << 22; +/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name). +/// Real names (`doc.dat`, `d/12`) are a few bytes each. +const MAX_INDEX_NAME_BYTES: usize = 16 << 20; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -75,6 +82,9 @@ pub struct Archive<'a> { pub revisions: usize, extracted: usize, limits: Limits, + /// Entries whose extraction failed after their size was charged: asked again, they fail + /// again without being decompressed again. + failed: HashMap, } pub(crate) struct Cursor<'a> { @@ -157,7 +167,15 @@ pub fn header(bytes: &[u8]) -> Result { impl<'a> Archive<'a> { pub fn open(bytes: &'a [u8], limits: Limits) -> Result { + Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES) + } + + /// [`Archive::open`] reading at most `max_records` allocation records and keeping at most + /// `max_name_bytes` bytes of entry names, both over the whole table chain. + pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result { let header = header(bytes)?; + let mut records_left = max_records; + let mut name_bytes_left = max_name_bytes; // Newest record per id: (table date, table index, record). let mut latest: HashMap)> = HashMap::new(); let mut names: HashMap = HashMap::new(); @@ -189,6 +207,7 @@ impl<'a> Archive<'a> { let dirs = c.u16()?; c.take(1)?; for _ in 0..files { + records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; let id = c.u32()?; let flag = c.u8()?; let mut record = None; @@ -224,8 +243,13 @@ impl<'a> Archive<'a> { } if flag == 0 { let len = usize::from(c.u16()?); - let name = String::from_utf8_lossy(c.take(len)?).into_owned(); - names.entry(id).or_insert(name); + let raw = c.take(len)?; + // Only the first name of an id is kept: later ones are not even decoded. + if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) { + let name = String::from_utf8_lossy(raw); + name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?; + slot.insert(name.into_owned()); + } } // Equal dates: the first table in the chain (the newest) wins, as in afread. let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); @@ -234,6 +258,7 @@ impl<'a> Archive<'a> { } } for _ in 0..dirs { + records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; let len = usize::from(c.u16()?); c.take(10)?; c.take(len)?; @@ -241,12 +266,12 @@ impl<'a> Archive<'a> { } let mut entries = HashMap::new(); for (id, (_, _, record)) in latest { - if let (Some(mut e), Some(name)) = (record, names.get(&id)) { - e.name = name.clone(); + if let (Some(mut e), Some(name)) = (record, names.remove(&id)) { + e.name = name; entries.insert(e.name.clone(), e); } } - Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits }) + Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() }) } pub fn entry(&self, name: &str) -> Option<&Entry> { @@ -279,7 +304,10 @@ impl<'a> Archive<'a> { } pub fn read(&mut self, name: &str) -> Result, Error> { - let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); + if let Some(e) = self.failed.get(name) { + return Err(*e); + } + let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?; let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; if size > self.limits.max_entry { return Err(Error::Limit("archive entry too large")); @@ -288,30 +316,41 @@ impl<'a> Archive<'a> { if total > self.limits.max_total { return Err(Error::Limit("document too large")); } - let mut c = Cursor::new(self.bytes, offset(e.offset)?); - if c.take(4)? != b"#Fil" { - return Err(Error::Malformed("archive entry has no data block")); - } - let method = e.compression & 3; - let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; - let data = c.take(offset(stored_len)?)?; - let mut out = match method { - 1 => inflate(data, size)?, - 2 => unzstd(data, size)?, - _ => data.to_vec(), - }; - if out.len() != size { - return Err(Error::Malformed("archive entry size mismatch")); - } - unpredict(&mut out, e.compression)?; - if crc32fast::hash(&out) != e.crc { - return Err(Error::Malformed("archive entry checksum mismatch")); - } + // Charged before the work, and kept when it fails: an entry that fails its checksum + // can't be decompressed over and over for free. self.extracted = total; - Ok(out) + let result = extract(self.bytes, e, size); + if let Err(err) = result { + self.failed.insert(name.to_owned(), err); + } + result } } +/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC. +fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result, Error> { + let mut c = Cursor::new(bytes, offset(e.offset)?); + if c.take(4)? != b"#Fil" { + return Err(Error::Malformed("archive entry has no data block")); + } + let method = e.compression & 3; + let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; + let data = c.take(offset(stored_len)?)?; + let mut out = match method { + 1 => inflate(data, size)?, + 2 => unzstd(data, size)?, + _ => data.to_vec(), + }; + if out.len() != size { + return Err(Error::Malformed("archive entry size mismatch")); + } + unpredict(&mut out, e.compression)?; + if crc32fast::hash(&out) != e.crc { + return Err(Error::Malformed("archive entry checksum mismatch")); + } + Ok(out) +} + /// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught /// without unbounded allocation. fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 8e84e4807..bb1c45746 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -169,6 +169,88 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option Option> { + let s = r.s; + let data = s.obj(crvd, b"Data")?; + let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?; + // Every segment takes at least an anchor record. + if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 { + return None; + } + let curve = subpath(&anchors(records)?, false, None)?; + if curve.segments.len() > MAX_PRESSURE_SEGMENTS { + return None; + } + let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2); + let mut samples = vec![(curve.start.x, curve.start.y)]; + let mut from = curve.start; + for [c1, c2, to] in &curve.segments { + let mut ts: Vec = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect(); + ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten()); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + for t in ts { + let u = 1.0 - t; + let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d; + samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y))); + } + from = *to; + } + let mut last = 0.0f64; + Some( + samples + .into_iter() + .map(|(x, y)| { + last = x.clamp(last, 1.0); + (last, y.clamp(0.0, 1.0)) + }) + .collect(), + ) +} + +/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`, +/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into +/// -1..1 first, so huge ones can't overflow the discriminant. +fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option; 2] { + let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs()); + if !(scale.is_finite() && scale > 0.0) { + return [None, None]; + } + let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale); + let (d0, d1, d2) = (b - a, c - b, d - c); + let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0); + let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t); + if qa.abs() < 1e-12 { + return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None]; + } + let disc = qb * qb - 4.0 * qa * qc; + if disc < 0.0 { + return [None, None]; + } + let root = disc.sqrt(); + [inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))] +} + struct Anchor { p: Point, inh: Option, @@ -260,3 +342,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option> { .collect(), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn turning_points_of_huge_control_values_are_found() { + let small = turning_points(1.0, -3.0, 3.0, 1.0); + let huge = turning_points(1e200, -3e200, 3e200, 1e200); + assert!(small.iter().all(Option::is_some), "{small:?}"); + for (s, h) in small.iter().zip(&huge) { + let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") }; + assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}"); + } + } +} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index f94de75b9..00910baab 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -26,6 +26,9 @@ 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; +/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). +const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; + /// 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. @@ -39,10 +42,18 @@ pub(crate) struct Budget { pub(crate) chars: usize, /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). pub(crate) cells: usize, + /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). + pub(crate) pressure: usize, } impl Budget { - pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; + pub(crate) const FULL: Budget = Budget { + nodes: MAX_NODES, + pixels: MAX_LAYER_PIXELS, + chars: MAX_DOCUMENT_CHARS, + cells: MAX_DOCUMENT_CELLS, + pressure: MAX_DOCUMENT_PRESSURE_POINTS, + }; } /// Read a document: archive, `doc.dat` and the model. diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs index 170c50ec3..80f54f68e 100644 --- a/crates/affinity/src/overlay.rs +++ b/crates/affinity/src/overlay.rs @@ -332,7 +332,7 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool { }; let to_unit = match o.source { Source::Gradient(g) => match invert(g.transform) { - Some(m) => Some((img.transform.then(m), g)), + Some(m) => Some((img.transform.then(m), g, stop_reach(g))), None => return false, }, Source::Solid(_) => None, @@ -343,9 +343,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool { } let (s, sa) = match (solid, &to_unit) { (Some(s), _) => s, - (None, Some((m, g))) => { + (None, Some((m, g, reach))) => { let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); - let c = sample(g, q); + let c = sample(g, reach, q); (rgb(c), c.alpha()) } (None, None) => continue, @@ -376,8 +376,22 @@ fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { Some(reader.decode().ok()?.into_rgba8().into_raw()) } -/// The gradient's colour at unit-space point `q`. -fn sample(g: &Gradient, q: Point) -> Color { +/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`: +/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and +/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not. +fn stop_reach(g: &Gradient) -> Vec { + g.stops + .iter() + .skip(1) + .scan(f64::NEG_INFINITY, |m, s| { + *m = m.max(s.offset); + Some(*m) + }) + .collect() +} + +/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`). +fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color { let t = match g.kind { GradientKind::Linear => q.x, GradientKind::Radial => q.x.hypot(q.y), @@ -388,18 +402,16 @@ fn sample(g: &Gradient, q: Point) -> Color { 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 }; - } + let k = reach.partition_point(|r| *r < t); + if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) { + 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 } diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 1dfdb6575..12102581e 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -121,6 +121,9 @@ pub struct Stroke { pub dash: Option<(Vec, f64)>, /// Painted below the fill instead of above it. pub behind: bool, + /// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in + /// order along the path, `None` for a stroke of the same width all along. + pub profile: Option>, } /// Gradient stops per gradient. @@ -350,10 +353,38 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh } _ => None, }; - if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() { - r.warn("brush and pressure strokes (imported as plain strokes)"); + if s.obj(lsty, b"Brus").is_some() { + r.warn("brush strokes (imported as plain strokes)"); } - Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind }) + // The pressure curve sets the width along the path; one at full width all along is a plain + // stroke. Every curve read counts against the document's points: one at full width counts its + // points, and one that can't be read counts the most a curve may have. Once the points are + // used up the rest are not read. + const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)"; + let ldep = s.obj(ldsc, b"LDeP"); + if ldep.is_some() && r.budget.pressure == 0 { + r.warn(BEYOND); + } + let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) { + Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) { + Some(left) => { + r.budget.pressure = left; + (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points) + } + None => { + r.budget.pressure = 0; + r.warn(BEYOND); + None + } + }, + Some(None) => { + r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS); + r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)"); + None + } + None => None, + }; + Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile }) } #[cfg(test)] diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index c5d1b31e0..a2503539f 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -13,8 +13,7 @@ const MAX_SIDES: i64 = 10_000; /// The last value of a tag: a shape changed from one kind to another keeps a stale first block. fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { - let t = Tag::of(tag); - r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v) + r.s.object(id)?.get_last(Tag::of(tag)) } fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { @@ -51,12 +50,13 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let types = match last(r, shape, b"CTyp") { - Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + // Only the four corners' entries can matter: longer arrays are read no further. + let types: Vec> = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), _ => Vec::new(), }; - let radii = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + let radii: Vec = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), _ => Vec::new(), }; // Corners follow the first one (top left) unless they were unlocked; then each has diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index 7a3010251..9d31f35e7 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -73,11 +73,67 @@ pub struct Object { pub chain: Vec, /// Fields in stream order; positional fields have tag 0. pub fields: Vec<(Tag, Value)>, + /// For objects with many fields: each tag with the positions of its first and last field, + /// sorted by tag, so a lookup isn't a scan of every field (a shared object is looked up + /// again on every visit). Built by [`Object::index`]; empty for small objects. + index: Vec<(Tag, u32, u32)>, } +/// Objects with more fields than this get an [`Object::index`]; fewer are scanned. +const INDEXED_FIELDS: usize = 16; + impl Object { + pub fn new(class: Tag, kind: Kind, chain: Vec, fields: Vec<(Tag, Value)>) -> Self { + let mut o = Self { class, kind, chain, fields, index: Vec::new() }; + o.index(); + o + } + + /// (Re)build the tag index after `fields` changed. + fn index(&mut self) { + self.index.clear(); + if self.fields.len() <= INDEXED_FIELDS { + return; + } + let mut index: Vec<(Tag, u32, u32)> = Vec::new(); + if index.try_reserve_exact(self.fields.len()).is_err() { + return; + } + // Fields beyond u32 positions are impossible: a stream holds at most MAX_VALUES values. + index.extend(self.fields.iter().enumerate().filter_map(|(i, (t, _))| u32::try_from(i).ok().map(|i| (*t, i, i)))); + // Stable: equal tags stay in stream order, so the run of a tag starts at its first field. + index.sort_by_key(|(t, _, _)| *t); + index.dedup_by(|later, first| { + if later.0 == first.0 { + first.2 = later.2; + true + } else { + false + } + }); + index.shrink_to_fit(); + self.index = index; + } + + fn position(&self, tag: Tag, last: bool) -> Option { + if self.index.is_empty() { + let mut fields = self.fields.iter().enumerate(); + let hit = if last { fields.rev().find(|(_, (t, _))| *t == tag) } else { fields.find(|(_, (t, _))| *t == tag) }; + return hit.map(|(i, _)| i); + } + let at = self.index.binary_search_by_key(&tag, |(t, _, _)| *t).ok()?; + let (_, first, end) = self.index.get(at)?; + usize::try_from(if last { *end } else { *first }).ok() + } + + /// The first field tagged `tag`. pub fn get(&self, tag: Tag) -> Option<&Value> { - self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v) + self.fields.get(self.position(tag, false)?).map(|(_, v)| v) + } + + /// The last field tagged `tag` (a shape changed from one kind to another keeps a stale first one). + pub fn get_last(&self, tag: Tag) -> Option<&Value> { + self.fields.get(self.position(tag, true)?).map(|(_, v)| v) } } @@ -124,10 +180,11 @@ pub fn parse(bytes: &[u8]) -> Result { c.u32()?; } let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 }; - let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?; + let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?; let fields = p.fields(true, class)?; if let Some(o) = p.objects.get_mut(root) { o.fields = fields; + o.index(); } Ok(Stream { root, objects: p.objects }) } @@ -252,7 +309,7 @@ impl Parser<'_> { } 0x30 => { let fields = self.fields(false, class)?; - let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?; + let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?; Value::Obj(Some(id)) } 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), @@ -354,7 +411,7 @@ impl Parser<'_> { } }; let fields = self.fields(true, class)?; - let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?; + let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?; Ok(Value::Obj(Some(id))) } @@ -373,7 +430,7 @@ impl Parser<'_> { return Err(Error::Malformed("object defined twice")); } // Register before the fields: an object's own fields may refer back to it. - let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?; + let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?; self.shared.insert(key, id); let mut chain = Vec::new(); let mut fields = Vec::new(); @@ -399,8 +456,36 @@ impl Parser<'_> { let class = chain.first().copied().unwrap_or(Tag(0)); fields.extend(self.fields(true, class)?); if let Some(o) = self.objects.get_mut(id) { - *o = Object { class, kind: Kind::Shared, chain, fields }; + *o = Object::new(class, Kind::Shared, chain, fields); } Ok(Value::Obj(Some(id))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookups_in_a_large_object_find_the_first_and_last_fields_of_a_tag() { + let tag = |i: u32| Tag(0x4100_0000 + i); + let mut fields: Vec<(Tag, Value)> = (0..1000).map(|i| (tag(i % 300), Value::UInt(u64::from(i)))).collect(); + fields.push((tag(7), Value::Str("last".into()))); + let o = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), fields.clone()); + assert!(!o.index.is_empty()); + // The index has one entry per distinct tag, not per field. + assert_eq!(o.index.len(), 300); + for t in 0..300 { + let first = fields.iter().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); + let last = fields.iter().rev().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); + assert_eq!(o.get(tag(t)), first); + assert_eq!(o.get_last(tag(t)), last); + } + assert_eq!(o.get(tag(300)), None); + assert_eq!(o.get_last(tag(7)), Some(&Value::Str("last".into()))); + let small = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), vec![(tag(1), Value::Bool(true)), (tag(1), Value::Bool(false))]); + assert!(small.index.is_empty()); + assert_eq!(small.get(tag(1)), Some(&Value::Bool(true))); + assert_eq!(small.get_last(tag(1)), Some(&Value::Bool(false))); + } +} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index eeb709d34..75b6b7b14 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -261,6 +261,34 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + #[test] + fn an_entry_that_fails_its_checksum_is_charged_once_and_not_decompressed_again() { + let d = doc(); + let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); + let mut broken = file.clone(); + broken[90] ^= 0x55; + let mut a = Archive::open(&broken, Limits::default()).unwrap(); + let first = a.read("doc.dat").unwrap_err(); + // The work was done, so it counts against the import even though it failed. + assert_eq!(a.extracted(), d.len()); + assert_eq!(a.read("doc.dat").unwrap_err(), first); + assert_eq!(a.extracted(), d.len()); + // A budget that only covers one attempt is not exceeded by asking again. + let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len() }).unwrap(); + assert!(a.read("doc.dat").is_err()); + assert_eq!(a.read("doc.dat").unwrap_err(), first); + } + + #[test] + fn the_table_index_has_a_budget_for_records_and_names() { + let d = doc(); + let long = "n".repeat(300); + let file = synth::container(&[("doc.dat", &d, Method::Stored), (&long, b"tile", Method::Stored)], None); + Archive::open_within(&file, Limits::default(), 2, 307).unwrap(); + assert_eq!(Archive::open_within(&file, Limits::default(), 1, 1 << 20).unwrap_err(), Error::Limit("too many archive entries")); + assert_eq!(Archive::open_within(&file, Limits::default(), 2, 306).unwrap_err(), Error::Limit("archive entry names too long")); + } + // 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. diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index 779a58552..13e8f0c53 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -1,7 +1,7 @@ -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)"] +affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;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×)"] +affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;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×)"] +affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;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×)"] +affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;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 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=[] @@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, 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=[] +patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;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/text_fixture.rs b/crates/affinity/tests/support/text_fixture.rs index 574a0ca38..01baa067e 100644 --- a/crates/affinity/tests/support/text_fixture.rs +++ b/crates/affinity/tests/support/text_fixture.rs @@ -98,20 +98,26 @@ pub fn utf8(text: &str) -> F { } pub fn document(blocks: Vec, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec { - let text = F::Obj( - tag(if frame { b"TxtF" } else { b"TxtA" }), - vec![ - (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])), - ( - tag(b"TxtH"), - F::Obj( - tag(if frame { b"FrFr" } else { b"ArFr" }), - vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))], - ), + document_with_children(blocks, frame, dpi, transform, vec![]) +} + +/// [`document`] whose text holds `children` (objects nested inside the text). +pub fn document_with_children(blocks: Vec, frame: bool, dpi: f64, transform: [f64; 6], children: Vec) -> Vec { + let mut fields = vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])), + ( + tag(b"TxtH"), + F::Obj( + tag(if frame { b"FrFr" } else { b"ArFr" }), + vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))], ), - (tag(b"Xfrm"), F::F64s(transform.to_vec())), - ], - ); + ), + (tag(b"Xfrm"), F::F64s(transform.to_vec())), + ]; + if !children.is_empty() { + fields.push((tag(b"Chld"), F::Shared(children))); + } + let text = F::Obj(tag(if frame { b"TxtF" } else { b"TxtA" }), fields); let mut root = vec![ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])), ( diff --git a/crates/brush/src/calli.rs b/crates/brush/src/calli.rs index 7104aa893..0b679497c 100644 --- a/crates/brush/src/calli.rs +++ b/crates/brush/src/calli.rs @@ -5,16 +5,16 @@ //! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the //! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`. //! -//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen -//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each -//! sample takes the nib for the pressure there. +//! A Calligraphic brush with Pressure, Tilt, Bearing or Rotation variation changes its nib along +//! the stroke with what the pen recorded on it (`StrokeLayer::pressure`, `tilt`, `bearing` and +//! `rotation`): the sweep is sampled densely and each sample takes the nib for the pen there. use vectorcraft_color::Paint; -use vectorcraft_doc::{Node, PressureProfile, StrokeLayer}; +use vectorcraft_doc::{Node, StrokeLayer}; use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2}; use crate::track::{Rng, Track, normal, seed_of, tracks}; -use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance}; +use crate::{Bristle, BristleShape, Calligraphic, PenAt, Variation, filled, tolerance}; /// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples /// it takes along a whole subpath. @@ -127,18 +127,16 @@ fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] { [angle, round.clamp(1.0, 100.0), size.max(0.05)] } -/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from -/// the brush's value less its variation (lightest) to the value plus the variation (heaviest), -/// kept within its range; the others are `base`'s. -fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] { +/// The nib's angle, roundness and size where the pen recorded `pen`: each value on a mode that +/// follows the pen goes from the brush's value less its variation (where the pen reads 0 for it) +/// to the value plus the variation (where it reads 1), the angle turning round past ±180° and the +/// others kept within their ranges; the other values are `base`'s. +fn pressured(c: &Calligraphic, base: [f64; 3], pen: PenAt) -> [f64; 3] { let values = [c.angle, c.roundness, c.size]; let mut out = base; for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() { - let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue }; - if mode == Variation::Pressure { - let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0); - *v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) }; - } + let (Some(&value), Some(&var), Some(p)) = (values.get(i), c.variation.get(i), pen.control(mode)) else { continue }; + *v = Calligraphic::moved(i, value, var * (2.0 * p - 1.0)); } let [angle, round, size] = out; [angle, round.clamp(1.0, 100.0), size.max(0.05)] @@ -147,14 +145,13 @@ fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] { pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec { let mut rng = Rng::new(seed_of(bp, name)); let base = randomized(c, &mut rng); - let subs: Vec = if c.uses_pressure() { - let pressure = stroke.pressure.as_ref(); + let subs: Vec = if c.uses_pen() { let nib = |f: f64| { - let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f))); + let [angle, round, size] = pressured(c, base, PenAt::of(stroke, f)); Nib::new(angle, round / 100.0, size * weight) }; - // Flattened for the largest nib the pressure gives. - let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight; + // Flattened for the largest nib the pen gives. + let size = pressured(c, base, PenAt::uniform(0.0))[2].max(pressured(c, base, PenAt::uniform(1.0))[2]) * weight; tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect() } else { let [angle, round, size] = base; diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 9ef049f86..179c0de51 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -138,9 +138,10 @@ pub fn stain(inst: &Node) -> Option<(Color, f32)> { Some((f.paint.color()?, f.opacity)) } -/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]). +/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic +/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]). pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = art.clone(); + let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst); if let Some((c, amount)) = stain(inst) { tint_node(&mut a, &c, amount); } diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index e76e67ba5..093ce531a 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -159,6 +159,24 @@ impl Calligraphic { pub fn uses_pressure(&self) -> bool { self.modes().contains(&Variation::Pressure) } + /// Does the pen (its pressure, tilt, bearing or rotation) change the nib? + pub fn uses_pen(&self) -> bool { + self.modes().iter().any(|m| m.follows_pen()) + } + /// Value `i` (0 angle, 1 roundness, 2 size) moved by `by`: the angle turns round past ±180°, + /// roundness and size stay within their [`Self::RANGES`]. A result that is not a number is + /// `value`, kept within its range. + pub fn moved(i: usize, value: f64, by: f64) -> f64 { + let (lo, hi) = Self::RANGES.get(i).copied().unwrap_or((f64::MIN, f64::MAX)); + let x = value + by; + if !x.is_finite() { + value.clamp(lo, hi) + } else if i == 0 { + vectorcraft_geom::normalize_deg(x) + } else { + x.clamp(lo, hi) + } + } } /// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new @@ -174,16 +192,29 @@ pub enum Variation { /// From the value less the variation at the lightest pen pressure to the value plus the /// variation at the heaviest. Pressure, + /// From the value less the variation with the pen lying flat to the value plus the variation + /// with it upright. + Tilt, + /// The value plus the variation times the pen's bearing over 180°: the bearing is which way the + /// pen leans, counter-clockwise on the page from leaning to the right (0°) round to ±180°. + Bearing, + /// The value plus the variation times the pen barrel's rotation over 180°, counter-clockwise + /// from where the pen reports 0° round to ±180°. + Rotation, } impl Variation { - pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure]; + pub const ALL: [Variation; 6] = + [Variation::Fixed, Variation::Random, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation]; /// The name brush definitions use. pub fn id(self) -> &'static str { match self { Variation::Fixed => "fixed", Variation::Random => "random", Variation::Pressure => "pressure", + Variation::Tilt => "tilt", + Variation::Bearing => "bearing", + Variation::Rotation => "rotation", } } pub fn label(self) -> &'static str { @@ -191,11 +222,63 @@ impl Variation { Variation::Fixed => "Fixed", Variation::Random => "Random", Variation::Pressure => "Pressure", + Variation::Tilt => "Tilt", + Variation::Bearing => "Bearing", + Variation::Rotation => "Rotation", } } pub fn parse(id: &str) -> Option { Self::ALL.into_iter().find(|v| v.id() == id) } + /// Does the value follow something the pen records along a stroke (Pressure, Tilt, Bearing, + /// Rotation)? + pub fn follows_pen(self) -> bool { + matches!(self, Variation::Pressure | Variation::Tilt | Variation::Bearing | Variation::Rotation) + } +} + +/// What the pen recorded at one point of a stroke, each as the 0..1 that [`Variation`]'s pen modes +/// read: 0 gives the value less the variation (a Scatter brush's first value), 1 the value plus it +/// (the second value), and ½ the value itself, as a stroke without that recording does. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PenAt { + /// The pressure, lightest 0. + pressure: f64, + /// 1 upright, 0 lying flat. + tilt: f64, + /// ½ plus the bearing over 360°. + bearing: f64, + /// ½ plus the counter-clockwise barrel rotation over 360°. + rotation: f64, +} + +impl PenAt { + /// Every input at `c`. + pub(crate) fn uniform(c: f64) -> Self { + Self { pressure: c, tilt: c, bearing: c, rotation: c } + } + /// What `stroke` recorded at `f` (the fraction of its path's length). + pub(crate) fn of(stroke: &StrokeLayer, f: f64) -> Self { + let mid = vectorcraft_doc::PressureProfile::MID; + let turn = |p: Option<&vectorcraft_doc::AngleProfile>| 0.5 + p.map_or(0.0, |p| p.at(f)) / 360.0; + Self { + pressure: stroke.pressure.as_ref().map_or(mid, |p| p.at(f)), + tilt: 1.0 - stroke.tilt.as_ref().map_or(mid, |p| p.at(f)), + bearing: turn(stroke.bearing.as_ref()), + rotation: turn(stroke.rotation.as_ref()), + } + } + /// The 0..1 a value on `mode` takes, or `None` for a mode that doesn't follow the pen. + pub(crate) fn control(&self, mode: Variation) -> Option { + let c = match mode { + Variation::Fixed | Variation::Random => return None, + Variation::Pressure => self.pressure, + Variation::Tilt => self.tilt, + Variation::Bearing => self.bearing, + Variation::Rotation => self.rotation, + }; + Some(if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 }) + } } /// Scatter brush: copies of the art along the path. @@ -216,8 +299,10 @@ pub struct Scatter { pub colorization: Colorization, /// How size, spacing, scatter and rotation vary between their two values: Fixed takes the /// first, Random any value between them, Pressure the first at the lightest pen pressure and - /// the second at the heaviest. `None` (brushes saved before there were modes): Random where - /// the two differ, else Fixed — see [`Scatter::modes`]. + /// the second at the heaviest, Tilt the first with the pen lying flat and the second upright, + /// Bearing and Rotation the first at −180° and the second at 180° (half way at 0°). `None` + /// (brushes saved before there were modes): Random where the two differ, else Fixed (see + /// [`Scatter::modes`]). #[serde(skip_serializing_if = "Option::is_none")] pub modes: Option<[Variation; 4]>, } @@ -386,10 +471,22 @@ impl CornerSource { #[serde(default)] pub struct PatternBrush { pub side: Node, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub outer_corner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_corner_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_corner_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inner_corner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inner_corner_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inner_corner_swatch: Option, /// What the outer corners take when there is no `outer_corner` art. Without art or a source, /// they take the `inner_corner` art. #[serde(skip_serializing_if = "Option::is_none")] @@ -400,8 +497,16 @@ pub struct PatternBrush { pub inner_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub end: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_swatch: Option, /// Scale in percent. pub scale: f64, /// Space between tiles in percent of the tile width. @@ -422,12 +527,22 @@ impl Default for PatternBrush { fn default() -> Self { Self { side: defaults::stitch_tile(), + side_frame: None, + side_swatch: None, outer_corner: None, + outer_corner_frame: None, + outer_corner_swatch: None, inner_corner: None, + inner_corner_frame: None, + inner_corner_swatch: None, outer_source: None, inner_source: None, start: None, + start_frame: None, + start_swatch: None, end: None, + end_frame: None, + end_swatch: None, scale: 100.0, spacing: 0.0, fit: PatternFit::Stretch, @@ -500,6 +615,14 @@ fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f6 (within(a, range, fallback), within(b, range, fallback)) } +fn valid_tile_frame(frame: Rect) -> bool { + [frame.x0, frame.y0, frame.x1, frame.y1].into_iter().all(f64::is_finite) + && frame.width().is_finite() + && frame.height().is_finite() + && frame.width() > 1e-9 + && frame.height() > 1e-9 +} + impl Colorization { /// A Hue Shift key colour that isn't a colour (components out of reach) becomes black. fn sanitize(&mut self) { @@ -600,6 +723,11 @@ impl Brush { BrushKind::Pattern(p) => { p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0); p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0); + for frame in [&mut p.side_frame, &mut p.outer_corner_frame, &mut p.inner_corner_frame, &mut p.start_frame, &mut p.end_frame] { + if frame.is_some_and(|f| !valid_tile_frame(f)) { + *frame = None; + } + } p.colorization.sanitize(); } BrushKind::Bristle(b) => { @@ -653,10 +781,9 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String { return base.to_string(); } // At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free. - (2..=lib.len() + 2) - .map(|i| format!("{base} {i}")) - .find(|n| !lib.iter().any(|b| &b.name == n)) - .unwrap_or_else(|| format!("{base} {}", lib.len() + 2)) + // Each is looked up in a set: scanning the library per candidate is quadratic in its size. + let taken: std::collections::HashSet<&str> = lib.iter().map(|b| b.name.as_str()).collect(); + (2..=lib.len() + 2).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n.as_str())).unwrap_or_else(|| format!("{base} {}", lib.len() + 2)) } // ---------- geometry ---------- @@ -676,7 +803,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name), BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name), BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()), - BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name), + BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name), BrushKind::Pattern(p) => warp::pattern(p, bp, w), }; let colorization = brush.kind.colorization(); @@ -721,9 +848,15 @@ fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool { }; // Without recursion, each one looked at once: they may use one another without end. let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def)); + // A brush name the library lacks draws a plain stroke ([`node_pieces`]), which runs no brush. + let mut names: Option> = None; + let mut resolves = |n: &Node| { + let names = names.get_or_insert_with(|| library(doc).into_iter().map(|b| b.name).collect()); + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.as_ref().is_some_and(|b| names.contains(b)))) + }; let mut answer = false; while let Some(n) = stack.pop() { - if has_brush(n) { + if has_brush(n) && resolves(n) { answer = true; break; } @@ -756,7 +889,16 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) { walk(doc, Arc::make_mut(c), known); } } - if n.extras().mask.is_some() + // A mask's art is drawn too. Its root is filtered like any other node: a symbol instance + // there that draws a brushed stroke would run the brush again from inside the mask, so the + // mask goes, as such an instance goes from the pieces and their children. + let rejected_root = matches!( + n.extras().mask.as_ref().map(|m| &m.art.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if draws_brushed(doc, (false, symbol.as_str()), known) + ); + if rejected_root { + n.extras_mut().mask = None; + } else if n.extras().mask.is_some() && let Some(mask) = n.extras_mut().mask.as_mut() { walk(doc, Arc::make_mut(&mut mask.art), known); diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 28bbcda75..7871b61af 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -561,6 +561,43 @@ fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() { assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty"); } +#[test] +fn pattern_without_start_tile_runs_side_to_path_start() { + let path = line(0.0, 0.0, 100.0, 0.0); + let doc = Document::new(1.0, 1.0); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.start = Some(rect_art(20.0, 4.0)); + } + let with_start = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(with_start[0].geometric_bounds().unwrap().width(), 20.0); + if let BrushKind::Pattern(p) = &mut b.kind { + p.start = None; + } + let without_start = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(without_start.len(), 10); + assert!(near(without_start[0].geometric_bounds().unwrap().x0, 0.0, 1e-6)); + assert!(near(without_start[0].geometric_bounds().unwrap().width(), 10.0, 1e-6)); +} + +#[test] +fn pattern_without_end_tile_runs_side_to_path_end() { + let path = line(0.0, 0.0, 100.0, 0.0); + let doc = Document::new(1.0, 1.0); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.end = Some(rect_art(20.0, 4.0)); + } + let with_end = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert!(with_end.iter().any(|n| near(n.geometric_bounds().unwrap().width(), 20.0, 1e-6))); + if let BrushKind::Pattern(p) = &mut b.kind { + p.end = None; + } + let without_end = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(without_end.len(), 10); + assert!(near(without_end.last().unwrap().geometric_bounds().unwrap().x1, 100.0, 1e-6)); +} + #[test] fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() { let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap(); @@ -852,6 +889,163 @@ fn pressure_varies_angle_and_roundness() { }; assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0)); assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0)); + // The angle turns on past 180°: 170 + 40 is 210°, the nib at 30° (not stopped at 180°). + let turned = Calligraphic { angle: 170.0, roundness: 10.0, variation: [40.0, 0.0, 0.0], ..c([Pressure, Fixed, Fixed]) }; + let expected = 2.0 * (25.0 * 0.25 + 0.25 * 0.75f64).sqrt(); + let h = draw(turned, 1.0); + assert!(near(h, expected, 0.05), "{h} vs {expected}"); +} + +/// A stroke that recorded the pen's tilt (`tilt`: 0 upright to 1 flat), bearing and barrel +/// rotation (degrees counter-clockwise), each the same all along it where given. +fn posed(tilt: Option, bearing: Option, rotation: Option) -> StrokeLayer { + let flat = |v: f64| vec![(0.0, v), (1.0, v)]; + StrokeLayer { + tilt: tilt.map(|t| vectorcraft_doc::PressureProfile { points: flat(t) }), + bearing: bearing.map(|b| vectorcraft_doc::AngleProfile { points: flat(b) }), + rotation: rotation.map(|r| vectorcraft_doc::AngleProfile { points: flat(r) }), + ..stroke(1.0) + } +} + +/// #764: with Roundness on Tilt the nib goes from roundness − variation with the pen lying flat to +/// roundness + variation with it upright, along the stroke as the pen leaned. +#[test] +fn tilt_maps_to_roundness_along_the_stroke() { + let c = Calligraphic { + roundness: 50.0, + size: 10.0, + variation: [0.0, 50.0, 0.0], + modes: Some([Variation::Fixed, Variation::Tilt, Variation::Fixed]), + ..Default::default() + }; + let b = Brush { name: "t".into(), kind: BrushKind::Calligraphic(c) }; + let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) }; + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &upright_to_flat); + // Upright: round (half 5); half way: roundness 50 (half 2.5); flat: a hairline. + assert!(near(half_width(&out, 0.5, 1.5), 5.0, 0.1), "{}", half_width(&out, 0.5, 1.5)); + assert!(near(half_width(&out, 49.5, 50.5), 2.5, 0.1), "{}", half_width(&out, 49.5, 50.5)); + assert!(half_width(&out, 98.5, 99.5) < 0.2, "{}", half_width(&out, 98.5, 99.5)); +} + +/// #764: with Angle on Bearing or on Rotation and a variation of 180°, the nib turns with the way +/// the pen leans or with its barrel. +#[test] +fn bearing_and_rotation_turn_the_nib() { + let horizontal = line(0.0, 0.0, 100.0, 0.0); + let height = |mode: Variation, st: &StrokeLayer| { + let c = Calligraphic { + roundness: 10.0, + size: 10.0, + variation: [180.0, 0.0, 0.0], + modes: Some([mode, Variation::Fixed, Variation::Fixed]), + ..Default::default() + }; + let b = Brush { name: "b".into(), kind: BrushKind::Calligraphic(c) }; + bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, st)).height() + }; + // Leaning right (0°): the nib lies along the stroke (1 pt across); leaning up the page (90°) + // or down it (−90°), the nib stands across it (10 pt). + assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(0.0), None)), 1.0, 0.05)); + assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(90.0), None)), 10.0, 0.05)); + assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(-90.0), None)), 10.0, 0.05)); + // The barrel turned a quarter turn stands it across the stroke too. + assert!(near(height(Variation::Rotation, &posed(None, None, Some(0.0))), 1.0, 0.05)); + assert!(near(height(Variation::Rotation, &posed(None, None, Some(90.0))), 10.0, 0.05)); + // Each reads only its own recording. + assert!(near(height(Variation::Rotation, &posed(None, Some(90.0), None)), 1.0, 0.05)); + assert!(near(height(Variation::Bearing, &posed(None, None, Some(90.0))), 1.0, 0.05)); +} + +/// A stroke without the pen's tilt, bearing or rotation (a mouse, a pen without that input, or a +/// path the brush was applied to) draws Tilt, Bearing and Rotation values at the brush's own value. +#[test] +fn pen_modes_without_a_recording_draw_the_value_itself() { + let horizontal = line(0.0, 0.0, 100.0, 0.0); + for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] { + let c = Calligraphic { angle: 90.0, roundness: 50.0, size: 10.0, variation: [90.0, 50.0, 6.0], modes: Some([mode; 3]) }; + let b = Brush { name: "n".into(), kind: BrushKind::Calligraphic(c) }; + let h = bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &stroke(1.0))).height(); + assert!(near(h, 10.0, 0.05), "{mode:?}: {h}"); + let sc = Scatter { + size: (50.0, 300.0), + modes: Some([mode, Variation::Fixed, Variation::Fixed, Variation::Fixed]), + ..scatter(&scatter_brush(100.0)) + }; + let s = Brush { name: "s".into(), kind: BrushKind::Scatter(sc) }; + let heights: Vec = + stroke_pieces(&Document::new(1.0, 1.0), &s, &horizontal, &stroke(1.0)).iter().map(|n| n.geometric_bounds().unwrap().height()).collect(); + assert!(!heights.is_empty() && heights.iter().all(|h| near(*h, 7.0, 1e-6)), "{mode:?}: {heights:?}"); + } +} + +/// A mouse stroke records no pressure, tilt, bearing or rotation. On it, a brush whose values are +/// on Tilt, Bearing or Rotation draws exactly what the same brush on Pressure draws, the output a +/// mouse stroke gave before these modes existed. +#[test] +fn a_mouse_stroke_draws_pen_modes_as_pressure_did() { + let doc = Document::new(1.0, 1.0); + let wavy = { + let mut bp = line(0.0, 0.0, 60.0, 0.0); + bp.curve_to((90.0, 0.0), (90.0, 40.0), (120.0, 40.0)); + bp + }; + let mouse = stroke(1.0); + assert!(mouse.pressure.is_none() && mouse.tilt.is_none() && mouse.bearing.is_none() && mouse.rotation.is_none()); + let calli = |mode: Variation| { + let c = Calligraphic { angle: 30.0, roundness: 40.0, size: 8.0, variation: [60.0, 30.0, 4.0], modes: Some([mode; 3]) }; + stroke_pieces(&doc, &Brush { name: "c".into(), kind: BrushKind::Calligraphic(c) }, &wavy, &mouse) + }; + let scattered = |mode: Variation| { + let sc = Scatter { + size: (50.0, 300.0), + spacing: (80.0, 160.0), + scatter: (-40.0, 40.0), + rotation: (-90.0, 90.0), + modes: Some([mode; 4]), + ..scatter(&scatter_brush(100.0)) + }; + stroke_pieces(&doc, &Brush { name: "s".into(), kind: BrushKind::Scatter(sc) }, &wavy, &mouse) + }; + let (calli_before, scatter_before) = (calli(Variation::Pressure), scattered(Variation::Pressure)); + assert!(!calli_before.is_empty() && !scatter_before.is_empty()); + for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] { + assert_eq!(calli(mode), calli_before, "{mode:?}"); + assert_eq!(scattered(mode), scatter_before, "{mode:?}"); + } +} + +/// The scatter brush of `b`. +fn scatter(b: &Brush) -> Scatter { + let BrushKind::Scatter(sc) = &b.kind else { panic!("not a scatter brush") }; + sc.clone() +} + +/// #764: Scatter values on Tilt go from the first value with the pen lying flat to the second +/// upright; on Bearing, from the first at −180° to the second at 180°. +#[test] +fn scatter_values_follow_tilt_and_bearing() { + let bp = line(0.0, 0.0, 400.0, 0.0); + let boxes = |b: &Brush, st: &StrokeLayer| -> Vec { + stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap()).collect() + }; + // Size on Tilt, 50 % to 300 %: upright at the start (12 pt tall), flat at the end (2 pt). + let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) }; + let h: Vec = boxes(&sized_scatter(50.0, 300.0, Variation::Tilt), &upright_to_flat).iter().map(Rect::height).collect(); + assert!(near(h[0], 12.0, 0.2) && h[h.len() - 1] < 3.0, "{h:?}"); + assert!(h.windows(2).all(|w| w[1] <= w[0] + 1e-9), "shrinks as the pen tips over: {h:?}"); + // Rotation on Bearing, −180° to 180°: the copies turn with the bearing, a quarter turn + // standing the 10 × 4 pt art on end. + let sc = Scatter { + rotation: (-180.0, 180.0), + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Fixed, Variation::Bearing]), + ..scatter(&scatter_brush(100.0)) + }; + let turning = Brush { name: "r".into(), kind: BrushKind::Scatter(sc) }; + for (bearing, (w, h)) in [(0.0, (10.0, 4.0)), (90.0, (4.0, 10.0)), (-90.0, (4.0, 10.0))] { + let b = boxes(&turning, &posed(Some(0.5), Some(bearing), None)); + assert!(!b.is_empty() && b.iter().all(|r| near(r.width(), w, 1e-6) && near(r.height(), h, 1e-6)), "{bearing}: {b:?}"); + } } /// Brushes saved before the variation modes keep their look: a variation means Random, none @@ -883,7 +1077,7 @@ fn brushes_without_modes_vary_randomly_as_before() { stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])) ); // Modes are saved by name. - assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"])); + assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure", "tilt", "bearing", "rotation"])); assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v))); } @@ -1097,7 +1291,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { let mut brushed = rect_art(10.0, 4.0); brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] { - doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) }); + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art.clone()) }); doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)])); } let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); @@ -1107,6 +1301,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { }; let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into()); let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) }; + store(&mut doc, std::slice::from_ref(&b)); let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert!(!out.is_empty()); for piece in &out { @@ -1122,6 +1317,46 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { } } +/// A symbol instance at the root of an opacity mask is filtered like one among the children: when +/// its symbol draws a brushed stroke the mask goes (it would run the brush again from inside the +/// mask, without end), and a mask whose root symbol is plain stays. +#[test] +fn pieces_leave_out_a_mask_whose_root_draws_a_brushed_stroke() { + use vectorcraft_doc::{OpacityMask, Symbol}; + let mut doc = Document::new(100.0, 100.0); + let mut brushed = rect_art(10.0, 4.0); + brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); + // A brush the library lacks draws a plain stroke: its mask stays. + let mut unresolved = rect_art(10.0, 4.0); + unresolved.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("missing".into()), ..stroke(1.0) })); + for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed), ("unresolved", unresolved)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art) }); + } + let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let masked = |symbol: &str| { + let mut n = rect_art(10.0, 4.0); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(instance(symbol), true))); + n + }; + let art = Node::group(NodeId(0), [masked("plain"), masked("brushed"), masked("unresolved")].map(std::sync::Arc::new).into()); + let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) }; + store(&mut doc, std::slice::from_ref(&b)); + let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert!(!out.is_empty()); + for piece in &out { + let roots: Vec> = piece + .children() + .unwrap() + .iter() + .map(|c| match c.extras().mask.as_ref().map(|m| &m.art.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => Some(symbol.as_str()), + _ => None, + }) + .collect(); + assert_eq!(roots, [Some("plain"), None, Some("unresolved")], "the plain and unresolved masks stay and the brushed one goes"); + } +} + /// Anchors a brushed stroke emitted, which is what the output budget counts. fn anchors(nodes: &[Node]) -> usize { nodes diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 99f7e6304..ae0c170f3 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -8,11 +8,11 @@ use std::sync::Arc; use kurbo::ParamCurveNearest; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2}; use crate::track::{Rng, Track, normal, seed_of, tracks}; -use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; +use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, PenAt, Scatter, Variation, tolerance}; /// Longest mapped segment (points in document space). const MAX_SEG: f64 = 1.5; @@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32; /// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than /// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord /// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`. -/// Text, images and symbol instances can't bend and are dropped. +/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask +/// bends with the object it masks. pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option { let mut out = n.clone(); out.appearance.scale_strokes(width_scale); @@ -63,9 +64,21 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max } _ => return None, } + map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale)); Some(out) } +/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where +/// `map` draws nothing, the mask's art becomes an empty group. +fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let id = m.art.id; + m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![]))); + } +} + /// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords /// would, else the chords. Points `f` cannot place (not finite) are left out of the chords. fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData { @@ -234,6 +247,16 @@ fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> (b.width() > 1e-9).then_some((a, b)) } +/// Orient a tile's repeat frame with its art. The frame can be smaller than the art. +fn oriented_tile(art: &Node, frame: Option, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> { + let (node, bounds) = oriented(art, Direction::LeftToRight, flip_along, flip_across)?; + let Some(frame) = frame else { return Some((node, bounds)) }; + let x = if flip_along { (-frame.x1, -frame.x0) } else { (frame.x0, frame.x1) }; + let y = if flip_across { (-frame.y1, -frame.y0) } else { (frame.y0, frame.y1) }; + let frame = Rect::new(x.0, y.0, x.1, y.1); + (frame.width() > 1e-9 && frame.height() > 1e-9).then_some((node, frame)) +} + /// Piecewise-linear map from art x (offset from the art's left edge) to arc length. #[derive(Clone, Copy, Debug)] pub(crate) struct AlongMap { @@ -338,7 +361,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option { /// Everything else matches [`warp_node`] deliberately, so curves are the only difference between /// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every /// path, an empty path or a group left with no children is dropped, and strokes are scaled by -/// `width_scale` at every level rather than by the transform's own factor. +/// `width_scale` at every level rather than by the transform's own factor, the art of linked +/// opacity masks included. fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option { let mut out = prepared(n, width_scale)?; out.transform(a, false); @@ -365,6 +389,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option { } _ => return None, } + map_mask(&mut out, |art| prepared(art, width_scale)); Some(out) } @@ -416,6 +441,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node { if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind { *children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect(); } + map_mask(&mut out, |art| Some(widen_strokes(art, factor))); out } @@ -510,18 +536,19 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre } /// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a -/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value -/// draws, so a copy's random values stay the same whatever the other modes. -fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 { +/// value between them, and a mode that follows the pen the first where `pen` reads 0 for it and +/// the second where it reads 1. Every value draws, so a copy's random values stay the same +/// whatever the other modes. +fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pen: PenAt) -> f64 { let r = rng.range(a, b); - match mode { - Variation::Fixed => a, - Variation::Random => r, - Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0), + match pen.control(mode) { + Some(c) => a + (b - a) * c, + None if mode == Variation::Random => r, + None => a, } } -pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec { +pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec { let Some(b) = sc.art.geometric_bounds() else { return vec![] }; let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3)); let mut rng = Rng::new(seed_of(bp, name)); @@ -532,8 +559,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< break; } let l = t.len(); - // The pen pressure at arc length `at` (half way without a recorded one). - let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 })); + // What the pen recorded at arc length `at` (the values themselves without a recording). + let pen_at = |at: f64| PenAt::of(stroke, if l > 1e-9 { at / l } else { 0.0 }); let mut s: Option = None; let mut guard = 0; loop { @@ -541,8 +568,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< if guard > 20000 { break; } - // Size and spacing place the next copy, so they take the pressure at the last one. - let p = pressure_at(s.unwrap_or(0.0)); + // Size and spacing place the next copy, so they take the pen at the last one. + let p = pen_at(s.unwrap_or(0.0)); let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight; let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0; let step = aw * size * spacing; @@ -554,7 +581,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< break; } s = Some(pos); - let p = pressure_at(pos); + let p = pen_at(pos); let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size; let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians(); let (p, tg) = t.frame(pos); @@ -590,6 +617,9 @@ fn anchors_of(n: &Node) -> usize { if let Some(c) = n.children() { stack.extend(c.iter().map(Arc::as_ref)); } + if let Some(m) = &n.extras().mask { + stack.push(&m.art); + } } total } @@ -781,13 +811,13 @@ fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option { } pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { - let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across); - let Some(side) = orient(&pb.side) else { return vec![] }; + let orient = |n: &Node, frame| oriented_tile(n, frame, pb.flip_along, pb.flip_across); + let Some(side) = orient(&pb.side, pb.side_frame) else { return vec![] }; let k = weight * pb.scale.max(1.0) / 100.0; - let outer = pb.outer_corner.as_ref().and_then(orient); - let inner = pb.inner_corner.as_ref().and_then(orient); - let start = pb.start.as_ref().and_then(orient); - let end = pb.end.as_ref().and_then(orient); + let outer = pb.outer_corner.as_ref().and_then(|n| orient(n, pb.outer_corner_frame)); + let inner = pb.inner_corner.as_ref().and_then(|n| orient(n, pb.inner_corner_frame)); + let start = pb.start.as_ref().and_then(|n| orient(n, pb.start_frame)); + let end = pb.end.as_ref().and_then(|n| orient(n, pb.end_frame)); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; // Each kind of corner takes its own art, else its source, else the other kind's art. @@ -1133,4 +1163,52 @@ mod tests { } } } + + /// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a + /// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put. + #[test] + fn a_linked_opacity_mask_bends_with_the_art() { + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let stroked = |id: u64| { + Node::path( + NodeId(id), + vectorcraft_geom::shapes::ellipse(b), + Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + 2.0, + ), + ) + }; + let masked = |linked: bool| { + let mut n = stroked(0); + let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n + }; + let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let bent_track = tracks(&round, 0.05).remove(0); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((320.0, 0.0)); + let straight_track = tracks(&line, 0.01).remove(0); + let along = AlongMap::linear(b.width(), 100.0, 140.0); + for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] { + let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's"); + + let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was"); + + let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's"); + } + assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art"); + } } diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 4886d9ea5..2ea6dea4f 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -21,5 +21,6 @@ image = { workspace = true } [dev-dependencies] serde_json = { workspace = true } -# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine). -vectorcraft-text = { workspace = true, features = ["bundled-fonts"] } +# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine) +# and in fonts built in code (`vectorcraft_text::test_fonts`). +vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] } diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 1e43c45dc..eb24859f1 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -23,10 +23,19 @@ const PT_PER_MM: f64 = 72.0 / 25.4; const THINNEST: f64 = 0.25; /// The most objects an import makes. const MAX_NODES: usize = 2_000_000; -/// The most block visits (one per insert copy) an import makes, whatever they produce. -const MAX_VISITS: usize = 10_000_000; +/// The most block work an import does, whatever it produces: one per insert copy drawn, and one +/// per item of the block looked at for it (art on hidden layers too). +const MAX_VISITS: usize = if cfg!(test) { 1_000_000 } else { 20_000_000 }; /// The most path elements an import makes (blocks drawn as art are copied for every placement). const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 }; +/// The most dash lengths an import keeps (every dashed path holds its own copy of its pattern). +const MAX_DASH_VALUES: usize = if cfg!(test) { 1_000 } else { 4_000_000 }; +/// The most bytes of text, font names and symbol names an import copies into objects (a block's +/// text and name are copied for every placement). +const MAX_TEXT_BYTES: usize = if cfg!(test) { 64 << 10 } else { 64 << 20 }; +/// The longest font family a text style may name; a longer one is not a font, and is set in the +/// default. +const MAX_FAMILY_BYTES: usize = 256; /// How far from the origin art may lie (points): the canvas. pub(crate) const MAX_EXTENT: f64 = 4.0e6; @@ -34,6 +43,9 @@ const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are const XREFS: &str = "blocks referencing other drawings (external references) are left out"; const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out"; const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out"; +const TOO_MUCH_BLOCK_WORK: &str = "the drawing's blocks take too much work to place: the rest are left out"; +const TOO_MANY_DASHES: &str = "the drawing has more dashed lines than can be kept: the rest are drawn solid"; +const TOO_MUCH_TEXT: &str = "the drawing has more than 64 MB of text and block names: the rest are left out"; /// What a block's art takes from the insert that places it: its colour, lineweight, linetype /// and opacity where the art says "by block", and its layer for art on layer 0. @@ -71,6 +83,9 @@ pub(crate) struct Build<'d, 'a> { visits: usize, /// Path elements made so far. elements: usize, + /// Dash lengths and text bytes kept so far. + dash_values: usize, + text_bytes: usize, warnings: Vec, } @@ -128,6 +143,8 @@ impl<'d, 'a> Build<'d, 'a> { nodes: 0, visits: 0, elements: 0, + dash_values: 0, + text_bytes: 0, warnings: vec![], } } @@ -138,6 +155,20 @@ impl<'d, 'a> Build<'d, 'a> { } } + /// Take `bytes` of text from [`MAX_TEXT_BYTES`]; false (with a warning) when they don't fit. + fn charge_text(&mut self, bytes: usize) -> bool { + match self.text_bytes.checked_add(bytes).filter(|t| *t <= MAX_TEXT_BYTES) { + Some(t) => { + self.text_bytes = t; + true + } + None => { + self.warn(TOO_MUCH_TEXT); + false + } + } + } + /// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own /// unit and at the scale asked for (fitting computes its own). pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec), String> { @@ -469,6 +500,9 @@ impl<'d, 'a> Build<'d, 'a> { super::curve::bounds(&bp)?; let paint = Paint::solid(rgb_color(self.rgb(p, inh))); let scale = map.determinant().abs().sqrt(); + let path = PathData::from_bezpath(&bp); + // A compound path's loops each keep a copy of its appearance, beside its own. + let copies = if path.subpaths.len() > 1 { path.subpaths.len() + 1 } else { 1 }; let appearance = if fill { Appearance::basic(paint, Paint::None, 0.0) } else { @@ -484,15 +518,20 @@ impl<'d, 'a> Build<'d, 'a> { if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut() && let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale) { - s.dash = Some(dash); - if dots { - s.cap = LineCap::Round; + match dash.pattern.len().checked_mul(copies).and_then(|v| self.dash_values.checked_add(v)).filter(|n| *n <= MAX_DASH_VALUES) { + Some(n) => { + self.dash_values = n; + s.dash = Some(dash); + if dots { + s.cap = LineCap::Round; + } + } + None => self.warn(TOO_MANY_DASHES), } } ap }; let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero }; - let path = PathData::from_bezpath(&bp); let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false }; let mut n = if path.subpaths.len() > 1 { // Several loops (a hatch with islands) paint as one compound path. Each loop is an @@ -527,10 +566,15 @@ impl<'d, 'a> Build<'d, 'a> { if !(s > 1e-12 && s.is_finite()) { return None; } - let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned()); + let d = self.d; + let family = t.family.as_deref().or_else(|| d.styles.get(&t.style).map(String::as_str).filter(|f| !f.is_empty())); + let family = family.filter(|f| f.len() <= MAX_FAMILY_BYTES); + if !self.charge_text(t.text.len().saturating_add(family.map_or(0, str::len))) { + return None; + } let default = CharStyle::default(); let style = CharStyle { - font_family: family.unwrap_or(default.font_family.clone()), + font_family: family.map_or_else(|| default.font_family.clone(), str::to_string), size: t.height / CAP_HEIGHT * s, h_scale: t.width * 100.0, leading: t.leading.map(|l| l * s), @@ -558,6 +602,13 @@ impl<'d, 'a> Build<'d, 'a> { for cell in &ins.cells { let node = match &symbol { Some(name) => { + if self.nodes >= MAX_NODES { + self.warn(TOO_MANY); + break; + } + if !self.charge_text(name.len()) { + break; + } let xf = map * *cell * self.symbol_map.inverse(); self.nodes += 1; Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf })) @@ -581,14 +632,19 @@ impl<'d, 'a> Build<'d, 'a> { /// The art of block `name` drawn by `map`, as one object. fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option { - self.visits += 1; - if self.visits > MAX_VISITS { - if self.visits == MAX_VISITS + 1 { - self.warn(TOO_MANY); - } + if self.visits >= MAX_VISITS { + self.warn(TOO_MUCH_BLOCK_WORK); return None; } - let items = self.block(name)?; + let items = self.block(name); + // Each placement looks at every item of the block, drawn or not (art on a hidden layer + // makes nothing, so the node budget doesn't bound it). + self.visits = self.visits.saturating_add(1 + items.as_ref().map_or(0, |i| i.len())); + if self.visits > MAX_VISITS { + self.warn(TOO_MUCH_BLOCK_WORK); + return None; + } + let items = items?; if !self.enter(name, depth) { return None; } @@ -618,12 +674,16 @@ impl<'d, 'a> Build<'d, 'a> { let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base); let map = self.symbol_map * Affine::translate(-base.to_vec2()); let art = self.art(name, map, &key.1, depth); - let made = art.map(|art| { + let made = art.and_then(|art| { let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str()); + // The symbol keeps its name: text, as its instances' copies of it are. + if !self.charge_text(label.len()) { + return None; + } let symbol = unique(label, &mut self.next_suffix, &self.symbol_names); self.symbol_names.insert(symbol.clone()); - self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) }); - symbol + self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) }); + Some(symbol) }); self.symbols.insert(key, made.clone()); made diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index d0dde65c9..d28a48553 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -814,7 +814,7 @@ fn nested_array_inserts_of_nothing_stop_at_the_work_budget() { blocks.extend(block("*C", None)); let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")]; let r = open(&drawing(&[], &[], &blocks, &top), &points()); - assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings); } #[test] @@ -1049,6 +1049,34 @@ fn drawings_the_export_writes_read_back() { } } +#[test] +fn a_lone_segment_reads_back_from_the_line_the_export_writes() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut b = kurbo::BezPath::new(); + b.move_to((20.0, 30.0)); + b.line_to((120.0, 70.0)); + let id = doc.alloc_id(); + let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0); + doc.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap(); + for version in crate::DxfVersion::ALL.into_iter().filter(|v| v.r2000()) { + let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() }; + let out = crate::export(&doc, &opts).unwrap(); + assert!(std::str::from_utf8(&out.bytes).unwrap().contains("\nLINE\n"), "{version:?}"); + let back = open(&out.bytes, &points()); + let art = leaves(&back.document); + assert_eq!(art.len(), 1, "{version:?}"); + let path = art[0].path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1, "{version:?}"); + assert!(!path.subpaths[0].closed, "{version:?}"); + let shift = Affine::translate((0.0, 100.0 - height(&back.document))); + let points = path_points(&art[0]); + assert_eq!(points.len(), 17, "{version:?}: one segment"); + let ends = [shift * points[0], shift * points[16]]; + assert!(ends[0].distance(Point::new(20.0, 30.0)) < 1e-6 && ends[1].distance(Point::new(120.0, 70.0)) < 1e-6, "{version:?}: {ends:?}"); + } +} + #[test] fn closed_control_point_splines_stay_closed_through_export_and_import() { // (explicitly closed, ends where it starts): coincident endpoints alone do not close a path. @@ -1149,3 +1177,173 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() { assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}"); assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings); } + +#[test] +fn dashed_lines_past_the_dash_budget_are_drawn_solid() { + // 600 lines of a two-length linetype: 1,200 dash lengths, past the 1,000 tests import with. + let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")]; + let line = [(0, "LINE"), (6, "DASHED"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]; + let e: Vec<(i32, &str)> = (0..600).flat_map(|_| line).collect(); + let r = open(&drawing(&[], &tables, &[], &e), &points()); + let (mut dashed, mut solid, mut kept) = (0, 0, 0); + r.document.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + match n.appearance.stroke().and_then(|s| s.dash.as_ref()) { + Some(d) => { + dashed += 1; + kept += d.pattern.len(); + } + None => solid += 1, + } + } + }); + assert_eq!(dashed + solid, 600); + assert!(dashed > 0 && solid > 0, "{dashed} dashed, {solid} solid"); + assert!(kept <= 1_000, "{kept} dash lengths kept"); + assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings); +} + +/// The bytes of text in the document's text objects. +fn text_bytes(doc: &Document) -> usize { + let mut bytes = 0; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + bytes += t.runs.iter().map(|r| r.text.len()).sum::(); + } + }); + bytes +} + +#[test] +fn text_copied_into_array_cells_stops_at_the_text_budget() { + // 1,000 characters of text in an anonymous block placed 10 × 10: 100 KB of copies, past the + // 64 KB tests import with. + let long = "x".repeat(1000); + let blocks = + [(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0"), (0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, long.as_str()), (0, "ENDBLK")]; + let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "1"), (45, "1")]; + let r = open(&drawing(&[], &[], &blocks, &insert), &points()); + let bytes = text_bytes(&r.document); + assert!(bytes > 0, "some copies are kept"); + assert!(bytes <= 64 << 10, "{bytes} bytes of text"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} + +#[test] +fn a_text_style_naming_an_overlong_font_family_takes_the_default() { + let family = "F".repeat(10_000); + let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Big"), (3, "x.ttf"), (1001, "ACAD"), (1000, family.as_str()), (0, "ENDTAB")]; + let e = [(0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, "Hi"), (7, "Big")]; + let doc = open(&drawing(&[], &tables, &[], &e), &points()).document; + let mut families = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + assert_eq!(families, [vectorcraft_doc::CharStyle::default().font_family]); +} + +#[test] +fn symbol_instances_of_a_long_block_name_stop_at_the_text_budget() { + // A 1,000-byte block name placed 10 × 10: each instance keeps its own copy of the name. + let name = "N".repeat(1000); + let blocks = [(0, "BLOCK"), (2, name.as_str()), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "0"), (0, "ENDBLK")]; + let insert = [(0, "INSERT"), (2, name.as_str()), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "2"), (45, "2")]; + let r = open(&drawing(&[], &[], &blocks, &insert), &points()); + let mut instances = 0; + r.document.walk(|n| { + if let NodeKind::SymbolInstance { .. } = n.kind { + instances += 1; + } + }); + assert!(instances > 0 && instances < 100, "{instances} instances"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} + +#[test] +fn arrays_of_blocks_of_hidden_art_stop_at_the_work_budget() { + // 500 lines on a hidden layer in an anonymous block placed 50 × 50: they make nothing, but + // each placement looks at all of them (1,250,000 items, past the 1,000,000 tests import with). + let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Off"), (70, "0"), (62, "-1"), (0, "ENDTAB")]; + let mut blocks = vec![(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0")]; + for _ in 0..500 { + blocks.extend([(0, "LINE"), (8, "Off"), (10, "0"), (20, "0"), (11, "1"), (21, "0")]); + } + blocks.push((0, "ENDBLK")); + let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "50"), (71, "50"), (44, "1"), (45, "1")]; + let r = open(&drawing(&[], &tables, &blocks, &insert), &points()); + assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings); +} + +#[test] +fn dashed_compound_paths_charge_every_copy_of_their_pattern() { + // 100 pattern hatches of 9 loops in a two-length linetype: each keeps its dashes on the + // compound path and on each loop, 2,000 dash lengths, past the 1,000 tests import with. + let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")]; + let corners = ["0", "10", "20", "30", "40", "50", "60", "70", "80", "90"]; + let mut hatch = vec![(0, "HATCH"), (6, "DASHED"), (10, "0"), (20, "0"), (30, "0"), (2, "ANSI31"), (70, "0"), (71, "0"), (91, "9")]; + for i in 0..9 { + let (a, b) = (corners[i], corners[i + 1]); + hatch.extend([ + (92, "2"), + (72, "0"), + (73, "1"), + (93, "4"), + (10, a), + (20, "0"), + (10, b), + (20, "0"), + (10, b), + (20, "5"), + (10, a), + (20, "5"), + (97, "0"), + ]); + } + hatch.extend([(75, "0"), (76, "1"), (98, "0")]); + let e: Vec<(i32, &str)> = (0..100).flat_map(|_| hatch.iter().copied()).collect(); + let r = open(&drawing(&[], &tables, &[], &e), &points()); + let (mut compounds, mut kept) = (0, 0); + r.document.walk(|n| { + compounds += usize::from(matches!(n.kind, NodeKind::Compound { .. })); + kept += n.appearance.stroke().and_then(|s| s.dash.as_ref()).map_or(0, |d| d.pattern.len()); + }); + assert_eq!(compounds, 100, "{:?}", r.warnings); + assert!(kept > 0 && kept <= 1_000, "{kept} dash lengths kept"); + assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings); +} + +#[test] +fn symbols_of_a_long_block_name_stop_at_the_text_budget() { + // A 1,000-byte block name inserted in 100 colours its art takes: a symbol per colour, each + // keeping its own copy of the name, past the 64 KB tests import with. + let name = "N".repeat(1000); + let blocks = [ + (0, "BLOCK"), + (2, name.as_str()), + (10, "0"), + (20, "0"), + (0, "LINE"), + (8, "0"), + (62, "0"), + (10, "0"), + (20, "0"), + (11, "1"), + (21, "0"), + (0, "ENDBLK"), + ]; + let colours: Vec = (1..=100).map(|c| c.to_string()).collect(); + let inserts: Vec<(i32, &str)> = + colours.iter().flat_map(|c| [(0, "INSERT"), (2, name.as_str()), (62, c.as_str()), (10, "0"), (20, "0")]).collect(); + let r = open(&drawing(&[], &[], &blocks, &inserts), &points()); + let mut bytes: usize = r.document.symbols.iter().map(|s| s.name.len()).sum(); + r.document.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + bytes += symbol.len(); + } + }); + assert!(!r.document.symbols.is_empty()); + assert!(bytes <= 64 << 10, "{bytes} bytes of symbol names"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} diff --git a/crates/cad/src/import/text.rs b/crates/cad/src/import/text.rs index 137d9cc31..d6d574612 100644 --- a/crates/cad/src/import/text.rs +++ b/crates/cad/src/import/text.rs @@ -62,6 +62,33 @@ fn until_semicolon(chars: &mut std::iter::Peekable>) -> Stri arg } +/// The character a `\U+XXXX` escape writes (`after` is the text just after its backslash), with +/// the escape's length; a UTF-16 surrogate pair written as two escapes is one character. +fn unicode_escape(after: &str) -> Option<(char, usize)> { + let hex = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+"))?.get(..4)?; + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let value = u32::from_str_radix(hex, 16).ok()?; + if (0xD800..=0xDBFF).contains(&value) { + let next = after.get(6..)?; + let low = next.strip_prefix("\\U+").or_else(|| next.strip_prefix("\\u+"))?.get(..4)?; + if !low.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let low = u32::from_str_radix(low, 16).ok()?; + if !(0xDC00..=0xDFFF).contains(&low) { + return None; + } + let scalar = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00); + return char::from_u32(scalar).map(|ch| (ch, 13)); + } + if (0xDC00..=0xDFFF).contains(&value) { + return None; + } + char::from_u32(value).map(|ch| (ch, 6)) +} + /// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page) /// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. In /// multiline text (`mtext`) `\\` is an escaped backslash and is kept as is, so that a `U+` after @@ -77,12 +104,10 @@ fn escapes(s: &str, mtext: bool) -> String { continue; } if c == '\\' - && let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4)) - && hex.bytes().all(|b| b.is_ascii_hexdigit()) - && let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + && let Some((ch, consumed)) = unicode_escape(after) { out.push(ch); - rest = after.get(6..).unwrap_or(""); + rest = after.get(consumed..).unwrap_or(""); continue; } if mtext @@ -161,6 +186,14 @@ mod tests { assert_eq!(plain("\\U+00C9"), "É"); } + #[test] + fn unicode_escape_decodes_surrogate_pairs() { + assert_eq!(plain("\\U+D83D\\U+DE00"), "😀"); + assert_eq!(plain("\\U+D83D"), "\\U+D83D"); + assert_eq!(plain("\\U+DE00"), "\\U+DE00"); + assert_eq!(plain("\\U+D83D\\U+0041"), "\\U+D83DA"); + } + #[test] fn control_codes_and_escapes() { assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under"); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 4afe84e33..3fc4bcb3b 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -233,7 +233,8 @@ impl RasterFormat { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Preserve { /// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or - /// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush + /// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern) + /// become their filled outlines; brush /// strokes become their brush art. #[default] Appearance, @@ -277,7 +278,9 @@ pub struct DxfOptions { pub colors: ColorDepth, pub raster: RasterFormat, pub preserve: Preserve, - /// Alter Paths for Appearance: every stroke is written as the filled area it paints. + /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] + /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have + /// no lineweights) is written as the filled area it paints. pub alter_paths: bool, /// Type is written as glyph outlines (always with [`Preserve::Appearance`]). pub outline_text: bool, diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7ab1d17f8..d7e20654a 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -10,7 +10,7 @@ use kurbo::PathEl; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit}; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use crate::aci::nearest_aci; use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed}; @@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option { Some(CircularSub { centre, radius, samples }) } +/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle +/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two +/// anchors. +struct EllipticalSub { + centre: Point, + u: Vec2, + v: Vec2, +} + +/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two +/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as +/// the unit circle, so the same tolerance and turn checks apply. +fn elliptical_subpath(sub: &Sub) -> Option { + if !sub.closed || sub.segs.len() != 4 { + return None; + } + let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end()); + let centre = a0.midpoint(a2); + let (u, v) = (a0 - centre, a1 - centre); + let det = u.cross(v); + let scale2 = u.hypot2().max(v.hypot2()); + if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 { + return None; + } + let unit = |p: Point| { + let d = p - centre; + Point::new(d.cross(v) / det, u.cross(d) / det) + }; + let mut segs = Vec::with_capacity(4); + for seg in &sub.segs { + let Seg::Cubic(a, b, to) = *seg else { return None }; + segs.push(Seg::Cubic(unit(a), unit(b), unit(to))); + } + let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?; + if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 { + return None; + } + Some(EllipticalSub { centre, u, v }) +} + fn angle_deg(p: Point, centre: Point) -> f64 { (p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0) } @@ -231,7 +271,22 @@ const MAX_ENTITIES: u32 = 10_000_000; /// The largest x or y a path may have to be flattened, and the most points its polygons keep. const MAX_COORD: f64 = 1e9; -const MAX_POINTS: usize = 4_000_000; +const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 4_000_000 }; +/// The most segments flattening one path makes, kept or not (repeats of a point are dropped). +const MAX_FLATTEN_SEGMENTS: usize = 4 * MAX_POINTS; + +const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects for DXF: the instances past that were left out"; + +/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of +/// odd length repeats twice in its linetype. +fn starts_with_pattern(d: &Dash) -> bool { + if !d.is_dashed() || !d.offset.is_finite() { + return true; + } + let period = d.pattern.iter().sum::() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 }; + let phase = d.offset.rem_euclid(period); + phase.min(period - phase) <= period * 1e-9 +} /// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped. fn polygons(bp: &BezPath) -> Vec> { @@ -245,14 +300,19 @@ fn polygons(bp: &BezPath) -> Vec> { } let mut out: Vec> = vec![]; let mut cur: Vec = vec![]; - let mut points = 0usize; - let mut push = |p: Point, cur: &mut Vec| { - if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { - points += 1; + // Points kept, and segments made: once either budget is spent, no further curve is flattened. + let points = std::cell::Cell::new(0usize); + let segments = std::cell::Cell::new(0usize); + let push = |p: Point, cur: &mut Vec| { + segments.set(segments.get() + 1); + if points.get() < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + points.set(points.get() + 1); cur.push(p); } }; - kurbo::flatten(bp.iter(), FLATNESS, |el| match el { + let elements = bp.iter().take_while(|_| points.get() < MAX_POINTS && segments.get() < MAX_FLATTEN_SEGMENTS); + // Each curve is flattened into a bounded number of segments, however large it is. + vectorcraft_geom::flatten(elements, FLATNESS, |el| match el { PathEl::MoveTo(p) => { out.push(std::mem::take(&mut cur)); push(p, &mut cur); @@ -361,6 +421,9 @@ pub(crate) struct Scene<'a> { opacity: f32, /// Symbols and brush art being written inside one another. nest: u32, + /// Objects the symbol instances written held, together + /// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). + symbol_nodes: u64, } impl<'a> Scene<'a> { @@ -390,6 +453,8 @@ impl<'a> Scene<'a> { extents: None, opacity: 1.0, nest: 0, + // Tests spend all but a million of it up front. + symbol_nodes: if cfg!(test) { vectorcraft_doc::MAX_SYMBOL_NODES - 1_000_000 } else { 0 }, } } @@ -491,14 +556,36 @@ impl<'a> Scene<'a> { NodeKind::Text(t) => self.text(n, t), NodeKind::Image(im) => self.image(im), NodeKind::SymbolInstance { symbol, xf } => { - if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + let doc = self.doc; + if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { + // Spent: no more instances are looked at, so what's left costs nothing. + self.warn(SYMBOLS_LEFT_OUT); + } else if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { - let mut art = (*sym.art).clone(); - art.transform(*xf, false); - self.nest += 1; - self.node(&art, true); - self.nest -= 1; + // Symbols holding instances of symbols are written in full for each instance, + // empty or not: every instance's art counts against one budget for the export. + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let count = art.count_with_masks() as u64; + // Counted as `place_symbol_art_unclipped` places it: no clip groups. + let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let placed = doc.place_symbol_art_unclipped(symbol, &art, *xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }); + match placed { + Some(Some(art)) => { + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + Some(None) => {} + None => { + self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES; + self.warn(SYMBOLS_LEFT_OUT); + } + } } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. @@ -612,8 +699,7 @@ impl<'a> Scene<'a> { /// The DXF lineweight nearest to `width` points (scaled with the drawing when asked). fn lineweight(&mut self, width: f64) -> i32 { - let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; - let hundredths = width * 2540.0 / 72.0 * scale; + let hundredths = self.hundredths(width); let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1]; if hundredths > f64::from(max) + 5.0 { self.warn(WIDE_LINES); @@ -621,6 +707,21 @@ impl<'a> Scene<'a> { LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max) } + /// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with + /// the drawing when asked). + fn hundredths(&self, width: f64) -> f64 { + let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; + width * 2540.0 / 72.0 * scale + } + + /// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines + /// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up + /// to 2.11 mm, to the hundredth. + fn line_keeps_width(&self, width: f64) -> bool { + let v = self.o.version; + !v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1])) + } + /// A linetype for dash pattern `d` (one per distinct pattern). fn linetype(&mut self, d: &Dash) -> String { let mut lengths: Vec = d.pattern.iter().map(|v| v * self.k).collect(); @@ -673,7 +774,11 @@ impl<'a> Scene<'a> { } let Some(rgb) = self.paint_rgb(&st.paint) else { return }; let plain = stroke::is_plain(st); - if self.o.alter_paths || (appearance && !plain) { + // A linetype has no phase: dashes that start part way into their pattern need outlines. + let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern); + // Alter Paths for Appearance outlines a stroke only when a line can't draw it. + let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width)); + if altered || (appearance && !lined) { let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st); let style = self.style(rgb, st.opacity, None, None); self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true); @@ -740,9 +845,9 @@ impl<'a> Scene<'a> { self.e.int(98, 0); } - /// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened), - /// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives - /// polylines as a width. + /// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for + /// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones. + /// `width`: the stroke weight, which DXF before 2000 gives polylines as a width. fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { let v = self.o.version; let width = (!v.r2000() && width > 0.0).then_some(width * self.k); @@ -755,11 +860,17 @@ impl<'a> Scene<'a> { if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { pts.pop(); } - self.polyline(&pts, sub.closed, style, width); + // A LINE has no width, so a stroke before 2000 keeps its polyline width. + match (sub.closed, width, pts.as_slice()) { + (false, None, &[a, b]) => self.line(a, b, style), + _ => self.polyline(&pts, sub.closed, style, width), + } } else if (v.handles() || width.is_none()) && self.circular(&sub, style) { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a // stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain // its width. Non-circular curves continue through the existing fallback. + } else if v.handles() && self.elliptical(&sub, style) { + // R13 and later write an ellipse as a native ELLIPSE. } else if v.handles() { self.spline(&sub, style); } else { @@ -778,6 +889,16 @@ impl<'a> Scene<'a> { } } + /// A line from `a` to `b` (document space). + fn line(&mut self, a: Point, b: Point, style: &EntityStyle) { + let (a, b) = (self.pt(a), self.pt(b)); + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("LINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbLine"); + self.e.xyz(10, a.x, a.y); + self.e.xyz(11, b.x, b.y); + } + /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a /// vertex entity per point. fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option) { @@ -787,7 +908,8 @@ impl<'a> Scene<'a> { let pts: Vec = pts.iter().map(|p| self.pt(*p)).collect(); let layer = self.layer.clone(); let h = self.handle(); - let flags = i64::from(closed); + // Bit 128: a linetype runs on through the vertices instead of restarting at each one. + let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 }; if self.o.version.lwpolyline() { self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline"); self.e.int(90, pts.len() as i64); @@ -887,6 +1009,53 @@ impl<'a> Scene<'a> { true } + /// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center, + /// the end of its major axis relative to the center, and the minor to major ratio. Returns + /// false when the curve is not an ellipse, leaving the caller to write it as a spline. + fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool { + let Some(last) = sub.segs.last().map(|s| s.end()) else { return false }; + if !sub.closed || last.distance(sub.start) > 1e-9 { + return false; + } + let Some(ellipse) = elliptical_subpath(sub) else { return false }; + let centre = self.to_dxf * ellipse.centre; + let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre; + let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre; + // The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T. + let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y); + let mean = (a + d) / 2.0; + let spread = ((a - d) / 2.0).hypot(b); + let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt()); + let angle = 0.5 * (2.0 * b).atan2(a - d); + let axis = Vec2::new(major * angle.cos(), major * angle.sin()); + let ratio = (minor / major).min(1.0); + // A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more. + if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 { + return false; + } + + // The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and + // hypot(u.y, v.y) about the center. + let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v); + let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y)); + for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] { + self.pt(p); + } + + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse"); + self.e.xyz(10, centre.x, centre.y); + self.e.xyz(11, axis.x, axis.y); + self.e.normal(); + // The ratio and the end parameter are written in full: rounded to six decimals, a thin + // ellipse's minor axis would change size, and a whole turn would read back as an open curve. + self.e.raw(40, &ratio.to_string()); + self.e.num(41, 0.0); + self.e.raw(42, &std::f64::consts::TAU.to_string()); + true + } + /// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it /// passes through every anchor (straight segments become straight cubics). fn spline(&mut self, sub: &Sub, style: &EntityStyle) { @@ -929,7 +1098,8 @@ impl<'a> Scene<'a> { } /// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text - /// entity per run of each line. + /// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its + /// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text(&mut self, n: &Node, t: &TextObject) { let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. }); if outline { @@ -941,6 +1111,8 @@ impl<'a> Scene<'a> { if !n.appearance.items.is_empty() { self.warn(TYPE_ITEMS); } + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); + let t = &*resolved; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let plain = t.plain_text(); let xf = self.to_dxf * t.xf; @@ -1127,3 +1299,47 @@ fn first_solid(n: &Node) -> Option { }); own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn flattening_a_path_of_huge_curves_stops_at_its_budget() { + // Each curve, a hundred million points across, flattens to thousands of segments at + // FLATNESS; a small square after them is past the budget and left out. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 0..20_000 { + let y = if i % 2 == 0 { 1e8 } else { -1e8 }; + bp.curve_to((1e8, y), (-1e8, y), (0.0, 0.0)); + } + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.close_path(); + let polys = polygons(&bp); + let points: usize = polys.iter().map(Vec::len).sum(); + assert!(points <= MAX_POINTS, "{points} points"); + assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); + } + + #[test] + fn flattening_a_path_of_repeated_points_stops_at_its_segment_budget() { + // Repeats of a point are dropped, so these keep one point but still count as segments: + // the segment budget, not the point budget, leaves out the square after them. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for _ in 0..MAX_FLATTEN_SEGMENTS { + bp.line_to((0.0, 0.0)); + } + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.close_path(); + let polys = polygons(&bp); + let points: usize = polys.iter().map(Vec::len).sum(); + assert!(points < MAX_POINTS, "{points} points"); + assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); + } +} diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 30a8d1a81..e4984c333 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit}; -use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes}; use crate::*; @@ -125,6 +125,71 @@ fn every_version_declares_its_acadver_and_is_well_formed() { assert_eq!(DxfVersion::from_id("2019"), None); } +/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table +/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes +/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section: +/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C). +#[test] +fn r2000_and_later_files_have_the_structure_cad_apps_require() { + use std::collections::HashSet; + // Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in + // the first and the UCS type in the second). + let layout_codes: HashSet = [0, 5, 102, 330, 360, 100] + .into_iter() + .chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333]) + .chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346]) + .collect(); + let d = shapes_doc(); + for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) { + let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap(); + let p = pairs(&out.bytes); + for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] { + assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section"); + } + let tables = section(&p, "TABLES"); + let names = |k: &str| -> Vec { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() }; + for (k, want) in [ + ("LTYPE", "ByBlock"), + ("LTYPE", "ByLayer"), + ("LTYPE", "Continuous"), + ("LAYER", "0"), + ("STYLE", "Standard"), + ("APPID", "ACAD"), + ("DIMSTYLE", "Standard"), + ("BLOCK_RECORD", "*Model_Space"), + ("BLOCK_RECORD", "*Paper_Space"), + ] { + assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}"); + } + // Every object after the header has a handle below $HANDSEED, and every reference to + // another object (owner 330, soft/hard pointers 340/350/390) names one of them. + let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..]; + let handles: HashSet = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect(); + let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap(); + assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed"); + for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) { + let h = u32::from_str_radix(value, 16).unwrap(); + assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}"); + } + let objects = section(&p, "OBJECTS"); + let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect(); + assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1"); + for l in &layouts { + let bad: Vec = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect(); + assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}"); + // The paper image origin is 148 (x) and 149 (y). + assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin"); + // Flag 1024 marks the model space layout, and only it. + let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str()); + let flags: i64 = get(l, 70).unwrap().parse().unwrap(); + assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}"); + } + // The plot style placeholder carries only the common object codes. + let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder"); + assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}"); + } +} + #[test] fn r12_viewport_has_no_duplicate_group_codes() { let d = shapes_doc(); @@ -208,10 +273,168 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}"); assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}"); } +} - // An actual ellipse is not close enough to a circle and remains a spline. - let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]); +/// The path `p` with `xf` applied. +fn transformed(p: &PathData, xf: Affine) -> PathData { + PathData::from_bezpath(&(xf * p.to_bezpath())) +} + +fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +#[test] +fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() { + let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)); + let d = doc_with(|d| { + let stroke = || Paint::solid(Color::BLACK); + vec![ + // 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5. + path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0), + // A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3. + path( + d, + transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))), + Paint::None, + stroke(), + 1.0, + ), + // The first ellipse turned 30° about its center, filled: the outline is the ellipse too. + path( + d, + transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))), + Paint::solid(Color::BLACK), + Paint::None, + 0.0, + ), + ] + }); + for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) { + let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(e.iter().map(|x| kind(x)).collect::>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}"); + let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42)); + let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]); + assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}"); + assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn"); + let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]); + assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]); + // Turned 30° counterclockwise on screen, so clockwise with DXF's y up. + let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]); + let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]); + assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}"); + } + // R12 has no ELLIPSE: the curves are flattened polylines. + let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap()); + assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}"); + // A thin ellipse keeps its ratio in full (six decimals would write 0.000002). + let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let e = entities(&export(&thin, &opts(&thin)).unwrap()); + assert_eq!(kind(&e[0]), "ELLIPSE"); + assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]); +} + +#[test] +fn exported_ellipses_read_back_as_the_same_ellipse() { + let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0)); + let d = doc_with(|d| { + vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["ELLIPSE"]); + let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }; + let back = import(&out.bytes, &o).unwrap().document; + let mut paths = vec![]; + back.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + paths.push(path.to_bezpath()); + } + }); + assert_eq!(paths.len(), 1); + assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed"); + // Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered. + let bp = &paths[0]; + let found = bp.bounding_box().center(); + let inverse = xf.inverse(); + let original = Point::new(60.0, 40.0); + for seg in bp.segments() { + for i in 0..=16 { + let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2(); + let q = inverse * p; + let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0); + assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})"); + } + } +} + +#[test] +fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() { + // A rounded square: four cubics through the ellipse's anchors with longer handles. + let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8); + let mut squircle = vectorcraft_geom::BezPath::new(); + squircle.move_to(Point::new(c.x + r, c.y)); + squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r)); + squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y)); + squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r)); + squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y)); + squircle.close_path(); + // An egg: one half wider than the other. + let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath(); + let mut els: Vec = egg.elements().to_vec(); + if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) { + a.x -= 10.0; + b.x -= 10.0; + } + let d = doc_with(|d| { + vec![ + path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0), + path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0), + // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). + path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0), + ] + }); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]); +} + +#[test] +fn a_lone_straight_segment_is_a_line() { + let segment = |closed: bool, extra: Option| { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(Point::new(20.0, 30.0)); + b.line_to(Point::new(120.0, 70.0)); + if let Some(p) = extra { + b.line_to(p); + } + if closed { + b.close_path(); + } + PathData::from_bezpath(&b) + }; + let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) { + let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap()); + assert_eq!(e.iter().map(|x| kind(x)).collect::>(), ["LINE"], "{version:?}"); + assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}"); + assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom"); + assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}"); + } + + // Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight + // before 2000 keeps its polyline width. + let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap()); + assert_eq!(r12(&filled), ["LINE"]); + assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]); + let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap()); + assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1"))); + + // A closed segment and two segments stay polylines. + for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] { + let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]); + } } #[test] @@ -453,14 +676,44 @@ fn dashes_become_linetypes_and_opacity_transparency() { #[test] fn alter_paths_writes_strokes_as_filled_outlines() { - let d = shapes_doc(); - let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); - // Each fill and stroke: its outlines, then its hatch. let fill = ["LWPOLYLINE", "HATCH"]; let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"]; + // Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc. + let mut d = shapes_doc(); + let c = Point::new(150.0, 95.0); + let k = 20.0 * shapes::KAPPA; + let mut arc = vectorcraft_geom::BezPath::new(); + arc.move_to(Point::new(c.x + 20.0, c.y)); + arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0)); + let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, arc).unwrap(); + let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + // R12 polylines carry any width, so its strokes stay lines too. + let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap(); + assert!(!kinds(&r12).iter().any(|k| k == "HATCH")); + assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2"))); + // R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch. + let d = shapes_doc(); + let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat()); // The rectangle's stroke: an outer and an inner boundary. assert_eq!(get(&entities(&out)[4], 91), Some("2")); + // A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width. + let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]); + let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap(); + assert_eq!(kinds(&out), rect_stroke); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let lines = export(&wide, &opts(&wide)).unwrap(); + assert_eq!(kinds(&lines), ["LWPOLYLINE"]); + assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm"))); + // To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line. + for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] { + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]); + assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt"); + } // Preserve Appearance outlines only the strokes a line can't draw. let mut d = shapes_doc(); let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); @@ -471,6 +724,65 @@ fn alter_paths_writes_strokes_as_filled_outlines() { let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]); assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment"))); + // With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line. + let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat()); + assert!(altered.warnings.is_empty(), "{:?}", altered.warnings); +} + +#[test] +fn dashes_that_start_part_way_into_their_pattern_are_outlined() { + let dashed = |offset: f64| { + doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false }); + } + vec![n] + }) + }; + // A linetype starts at the start of its pattern: an offset of a whole period (the odd + // pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch. + for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] { + let d = dashed(offset); + let k = kinds(&export(&d, &opts(&d)).unwrap()); + if outlined { + assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}"); + } else { + assert_eq!(k, ["LWPOLYLINE"], "offset {offset}"); + } + let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()); + assert_eq!(altered, k, "offset {offset}, Alter Paths"); + } + // Maximize Editability keeps the line and its linetype. + let d = dashed(4.0); + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&editable), ["LWPOLYLINE"]); + assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1")); +} + +#[test] +fn dashed_polylines_continue_their_pattern_around_corners() { + let rect = |dash: Option| { + doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = dash.clone(); + } + vec![n] + }) + }; + let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string)); + // Group 70 bit 128 makes the linetype run on through the vertices instead of restarting + // at each one; a solid polyline keeps only its closed bit. + let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false })); + for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] { + assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129")); + } + let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap(); + assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129"))); + let solid = rect(None); + assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1")); } fn text_doc(text: &str, size: f64) -> Document { @@ -594,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() { assert_eq!(text(DxfVersion::R2018), "Größe"); } +/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt +/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14. +#[test] +fn a_font_height_reference_is_written_as_the_size_drawn() { + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut d = text_doc("Plan A", 14.0); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.font_family = HEIGHT_FAMILY.into(); + t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + } + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&editable); + assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40)); +} + #[test] fn images_link_png_or_jpeg_files() { let mut png = vec![]; @@ -674,7 +1003,26 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { // while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it. let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]); let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let o = DxfOptions { crop: true, ..opts(&d) }; assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } + +#[test] +fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { + // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty + // instances, which write no entities, past the million objects tests export with. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "L5".into(), xf: Affine::IDENTITY })]); + let empty = Node::group(d.alloc_id(), vec![]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "L0".into(), art: Arc::new(empty) }); + for level in 1..=5 { + let below = format!("L{}", level - 1); + let children = + (0..100).map(|_| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: below.clone(), xf: Affine::IDENTITY }))).collect(); + let art = Node::group(d.alloc_id(), children); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(art) }); + } + let out = export(&d, &opts(&d)).unwrap(); + assert!(entities(&out).is_empty()); + assert!(out.warnings.iter().any(|w| w.contains("symbols placed inside symbols")), "{:?}", out.warnings); +} diff --git a/crates/cad/src/writer.rs b/crates/cad/src/writer.rs index 665f8576f..6ba7e5a35 100644 --- a/crates/cad/src/writer.rs +++ b/crates/cad/src/writer.rs @@ -564,7 +564,10 @@ impl Drawing { c.handle(350, fixed::PLOT_NORMAL); c.raw(100, "AcDbDictionaryWithDefault"); c.handle(340, fixed::PLOT_NORMAL); - c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder"); + // A placeholder has no data and no subclass marker of its own. + c.raw(0, "ACDBPLACEHOLDER"); + c.handle(5, fixed::PLOT_NORMAL); + c.handle(330, fixed::PLOT_STYLES); self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD); self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD); } @@ -618,14 +621,23 @@ impl Drawing { c.num(141, 0.0); c.num(142, 1.0); c.num(143, 1.0); - c.int(70, 688); + // Plot flags; 1024 marks the model space layout. + c.int(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 }); c.int(72, 1); c.int(73, 0); c.int(74, 5); c.raw(7, ""); c.int(75, 16); + if self.version.true_color() { + // Shade plot mode (as displayed), resolution level (normal) and custom DPI. + c.int(76, 0); + c.int(77, 2); + c.int(78, 300); + } c.num(147, 1.0); - c.xy(148, 0.0, 0.0); + // The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158. + c.num(148, 0.0); + c.num(149, 0.0); c.raw(100, "AcDbLayout"); c.raw(1, name); c.int(70, 1); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 8201b8b97..0b09062b8 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -106,6 +106,12 @@ impl IccProfile { if !localized_text_fits(bytes) { return Err(CmsError::BadProfile(format!("its localized names take more than {} KB", MAX_LOCALIZED_TEXT >> 10))); } + if let Some(tag) = repeated_tag(bytes) { + return Err(CmsError::BadProfile(format!( + "its tag table lists the `{}` tag more than once", + String::from_utf8_lossy(&tag.to_be_bytes()) + ))); + } let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? }) } @@ -149,11 +155,20 @@ impl IccProfile { /// Device values (RGB / CMYK / Gray, 0..1) → sRGB. pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> { + let mut src = [0.0f32; 4]; + let channels = self.channels(); + src.get_mut(..channels)?.iter_mut().zip(v.iter()).for_each(|(dst, value)| *dst = *value); + self.to_srgb_into(src.get(..channels)?, intent) + } + + /// Convert an exact-width device pixel using the cached transform and stack storage. + pub fn to_srgb_into(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> { + if v.len() != self.channels() { + return None; + } let xf = self.to_xf(intent)?; - let mut src = v.to_vec(); - src.resize(self.channels(), 0.0); let mut dst = [0.0f32; 3]; - xf.transform(&src, &mut dst).ok()?; + xf.transform(v, &mut dst).ok()?; Some(dst.map(|x| x.clamp(0.0, 1.0))) } @@ -408,6 +423,23 @@ pub(super) fn gray_profile(name: &str) -> ColorProfile { labelled(p, name) } +/// The first tag signature the tag table of ICC `bytes` lists twice, which the ICC +/// specification forbids. moxcms parses every entry of the table, so a profile listing one +/// description, copyright or table tag thousands of times, each pointing at the same large data, +/// would decode and copy that data once per entry. A table it can't read is left to moxcms. +pub(crate) fn repeated_tag(bytes: &[u8]) -> Option { + let be32 = |at: usize| bytes.get(at..at.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]])); + let tags = be32(128)? as usize; + let mut seen = std::collections::HashSet::new(); + for i in 0..tags { + let sig = be32(i.checked_mul(12)?.checked_add(132)?)?; + if !seen.insert(sig) { + return Some(sig); + } + } + None +} + /// Most bytes of UTF-16 the localized names (`mluc` tags) of a profile may take together. const MAX_LOCALIZED_TEXT: usize = 1 << 20; diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 46e44cd76..1d312c09c 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -181,6 +181,32 @@ fn aliased_localized_names_are_refused() { assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10))); } +/// A tag table that lists one tag many times, every entry pointing at the same long text, is +/// refused before moxcms decodes the text once per entry; distinct tags pass. +#[test] +fn repeated_tags_are_refused() { + let profile = |sigs: &[&[u8; 4]], text: usize| { + let mut b = vec![0u8; 128]; + b.extend((sigs.len() as u32).to_be_bytes()); + let tag_at = 128 + 4 + 12 * sigs.len(); + let size = 8 + text; + for sig in sigs { + b.extend(*sig); + b.extend((tag_at as u32).to_be_bytes()); + b.extend((size as u32).to_be_bytes()); + } + b.extend(b"text"); + b.extend([0; 4]); + b.extend(vec![b'a'; text]); + b + }; + assert_eq!(super::icc::repeated_tag(&profile(&[b"desc", b"cprt", b"dmdd"], 64)), None); + let many: Vec<&[u8; 4]> = std::iter::repeat_n(b"desc", 4096).collect(); + let bytes = profile(&many, 256 << 10); + assert_eq!(super::icc::repeated_tag(&bytes), Some(u32::from_be_bytes(*b"desc"))); + assert!(matches!(super::icc::IccProfile::from_bytes(None, &bytes), Err(super::CmsError::BadProfile(_)))); +} + /// Reloading an RGB profile under the name it had (a new file for "My RGB") changes what CMYK /// converts to in it: the direct CMYK → RGB transform isn't kept from the old file. #[test] diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 528d56033..652db3340 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -78,6 +78,14 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; /// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is /// halved to keep within [`PERCEPTUAL_TOLERANCE`]. const PERCEPTUAL_DEPTH: u32 = 10; +/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within +/// 1/65536 of a tint. +const TINT_STEPS: u32 = 16; +/// The least OKLab lightness by which an ink at 100% must differ from paper white for +/// [`Gradient::ink_tints`] to tell its tints apart by lightness. +const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0; +/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for. +const MAX_INK_TINTS: usize = 4096; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly /// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved @@ -100,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: } } +/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a` +/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½). +fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec { + let mut vs = Vec::new(); + perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs); + vs.push(0.5); + perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs); + vs +} + +/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint +/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it. +fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 { + let m = a.midpoint.clamp(0.01, 0.99); + let u = if v == 0.5 { + a.midpoint + } else if v < 0.5 { + 2.0 * v * m + } else { + m + (v - 0.5) * 2.0 * (1.0 - m) + }; + a.offset + (b.offset - a.offset) * u +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientStop { /// 0..=1 along the gradient. @@ -143,6 +175,40 @@ fn between(a: &GradientStop, b: &GradientStop, t: f32, mix: &impl Fn(&Color, &Co (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v) } +/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset among stops `1..=k + 1`. +/// [`Gradient::sample_with`] picks the first pair whose end offset `t` doesn't pass; that is the +/// first pair whose reach `t` doesn't pass, and the reaches never decrease, so the pair is found by +/// binary search whether or not the stops are in order. +struct StopReach(Vec); + +impl StopReach { + fn new(stops: &[GradientStop]) -> Self { + // `f32::max` skips a NaN offset, which `t <= offset` never chooses either. + let reach = stops.iter().skip(1).scan(f32::NEG_INFINITY, |m, s| { + *m = m.max(s.offset); + Some(*m) + }); + Self(reach.collect()) + } + + /// What [`Gradient::sample_with`] gives at `t` for `stops`, the stops this was built from. + fn sample_with(&self, stops: &[GradientStop], t: f32, mix: &impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) { + let Some(first) = stops.first() else { return (Color::BLACK, 1.0) }; + if t <= first.offset { + return (first.color, first.opacity); + } + let last = || stops.last().map_or((Color::BLACK, 1.0), |l| (l.color, l.opacity)); + if t.is_nan() { + return last(); + } + let k = self.0.partition_point(|r| *r < t); + match (stops.get(k), stops.get(k + 1)) { + (Some(a), Some(b)) => between(a, b, t, mix), + _ => last(), + } + } +} + fn one() -> f32 { 1.0 } @@ -179,6 +245,12 @@ impl Gradient { pub fn sample(&self, t: f32) -> (Color, f32) { self.sample_with(t, |a, b, v| self.method.mix(a, b, v)) } + /// [`Self::sample`] for many points in no order (a gradient drawn pixel by pixel): the stops + /// are prepared once, so each sample costs a binary search rather than a scan of the stops. + pub fn sampler(&self) -> impl Fn(f32) -> (Color, f32) + '_ { + let reach = StopReach::new(&self.stops); + move |t| reach.sample_with(&self.stops, t, &|a, b, v| self.method.mix(a, b, v)) + } /// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it /// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual /// gradient's as [`Self::sample`] mixes them. @@ -213,9 +285,11 @@ impl Gradient { /// the one before it stopped, so the work is the samples plus the stops, not their product. pub fn sample_increasing_with(&self, ts: impl IntoIterator, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> { let stops = &self.stops; - // The shared index assumes ordered stops; others (a file's, unsorted) sample one by one. + // The shared cursor assumes ordered stops; others (a file's, unsorted) look each sample up + // in a [`StopReach`], so they cost a binary search each rather than a scan of the stops. if stops.windows(2).any(|w| w[1].offset < w[0].offset) { - return ts.into_iter().map(|t| self.sample_with(t, &mix)).collect(); + let reach = StopReach::new(stops); + return ts.into_iter().map(|t| reach.sample_with(stops, t, &mix)).collect(); } let mut j = 0; ts.into_iter() @@ -245,8 +319,16 @@ impl Gradient { /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` /// on the gradient's own stops and a Perceptual gradient's other in-between stops). pub fn expanded(&self) -> impl Iterator)> + '_ { + self.expanded_with_stops().map(|(t, c, o, mid, _)| (t, c, o, mid)) + } + /// [`Self::expanded`], each of the gradient's own stops with its index in [`Self::stops`] + /// (`None` on the stops in between). + fn expanded_with_stops(&self) -> impl Iterator, Option)> + '_ { // Direct interpolation matches `sample` only for strictly increasing offsets. let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset); + // Otherwise (repeated or unsorted offsets) each midpoint is looked up as `sample` would + // find it, but by binary search: a scan of all the stops per midpoint cost their square. + let reach = if increasing || !self.method.is_classic() { None } else { Some(StopReach::new(&self.stops)) }; self.stops.iter().enumerate().flat_map(move |(i, s)| { let next = self.stops.get(i + 1); let off_centre = (s.midpoint - 0.5).abs() > 1e-3; @@ -256,34 +338,104 @@ impl Gradient { // Mixed from the two stops it lies between, as `sample` would: searching all the // stops again for each midpoint made many stops cost their square. let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v); - let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) }; - (t, c, o, Some(s.midpoint)) + let (c, o) = match &reach { + Some(reach) => reach.sample_with(&self.stops, t, &mix), + None => between(s, n, t, &mix), + }; + (t, c, o, Some(s.midpoint), None) }); // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| { - let mut vs = Vec::new(); - perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs); - vs.push(0.5); - perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs); - let m = s.midpoint.clamp(0.01, 0.99); - vs.into_iter().map(move |v| { + perceptual_mixes(s, n).into_iter().map(move |v| { let mid = v == 0.5; - let u = if mid { - s.midpoint - } else if v < 0.5 { - 2.0 * v * m - } else { - m + (v - 0.5) * 2.0 * (1.0 - m) - }; - let t = s.offset + (n.offset - s.offset) * u; + let t = curve_offset(s, n, v); let o = s.opacity + (n.opacity - s.opacity) * v; - (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint)) + (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint), None) }) }); - std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten()) + std::iter::once((s.offset, s.color, s.opacity, None, Some(i))).chain(classic).chain(perceptual.into_iter().flatten()) }) } + /// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as + /// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab + /// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings, except at + /// the gradient's own stops, which take their tints from `stop_tints` (one per stop, in + /// order) where it has one. Mixed linearly between them, the tints follow a Perceptual + /// gradient's curve. `None` when the ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of + /// paper white's (a white or nearly white ink), so lightness does not tell its tints apart, + /// and when the curve has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + pub fn ink_tints(&self, ink: &Color, stop_tints: &[f32]) -> Option> { + use crate::libraries::srgb_to_oklab; + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN { + return None; + } + let curve: Vec<_> = self.expanded_with_stops().take(MAX_INK_TINTS + 1).collect(); + if curve.len() > MAX_INK_TINTS { + return None; + } + let tint = |c: &Color| { + let want = lightness(c); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..TINT_STEPS { + let mid = (lo + hi) / 2.0; + if lightness(&ink.tinted(mid)) > want { + lo = mid; + } else { + hi = mid; + } + } + (lo + hi) / 2.0 + }; + Some(curve.into_iter().map(|(t, c, o, _, i)| (t, i.and_then(|i| stop_tints.get(i).copied()).unwrap_or_else(|| tint(&c)), o)).collect()) + } + /// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so + /// [`Self::ink_tints`] finds tints for it? + pub fn fits_ink_tints(&self) -> bool { + self.expanded().nth(MAX_INK_TINTS).is_none() + } + /// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks: + /// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop + /// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops). + /// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab + /// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix + /// position itself when the two colors' lightness differs by less than + /// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than + /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + pub fn ink_mixes(&self) -> Option> { + use crate::libraries::srgb_to_oklab; + if self.method.is_classic() || !self.fits_ink_tints() { + return None; + } + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + let mut out = Vec::new(); + for (i, s) in self.stops.iter().enumerate() { + out.push((s.offset, i, 0.0, s.opacity)); + let Some(n) = self.stops.get(i + 1) else { continue }; + let (l0, l1) = (lightness(&s.color), lightness(&n.color)); + for v in perceptual_mixes(s, n) { + let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN { + v + } else { + let want = lightness(&self.method.mix(&s.color, &n.color, v)); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..TINT_STEPS { + let mid = (lo + hi) / 2.0; + // The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one. + if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 { + lo = mid; + } else { + hi = mid; + } + } + (lo + hi) / 2.0 + }; + out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v)); + } + } + Some(out) + } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each /// segment's midpoint mirrored with it. pub fn reverse(&mut self) { diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 19e9f44fc..1e75feb33 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -23,6 +23,64 @@ fn increasing_samples_match_single_samples() { assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); } +/// The prepared sampler gives what sampling each position alone does, out-of-order stops included. +#[test] +fn the_sampler_matches_single_samples() { + let stop = |offset: f32, hex: &str| GradientStop::new(offset, Color::from_hex(hex).unwrap()); + let g = Gradient::new( + GradientKind::Linear, + vec![stop(0.1, "#ff0000"), stop(0.4, "#00ff00"), stop(0.4, "#0000ff"), stop(0.2, "#ffff00"), stop(0.9, "#000000")], + ); + let sample = g.sampler(); + for k in (0..=40).rev() { + let t = k as f32 / 40.0; + assert_eq!(sample(t), g.sample(t), "{t}"); + } +} + +/// Looking stops up by their reach finds what scanning them does, for any order of offsets, +/// repeated, out-of-order and NaN ones included, and for positions outside and between them. +#[test] +fn stop_reach_matches_scanning_the_stops() { + let colors = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#000000"]; + let offsets: [&[f32]; 5] = [ + &[0.1, 0.4, 0.4, 0.2, 0.9, 0.5], + &[0.5, 0.5, 0.5, 0.5], + &[0.9, 0.1, 0.8, 0.2, 0.7, 0.3], + &[0.2, f32::NAN, 0.6, 0.1, f32::NAN, 1.0], + &[f32::NAN, 0.3, 0.3, 0.8], + ]; + for offsets in offsets { + let stops: Vec = offsets + .iter() + .zip(colors.iter().cycle()) + .map(|(&o, hex)| GradientStop { midpoint: 0.3, ..GradientStop::new(o, Color::from_hex(hex).unwrap()) }) + .collect(); + let g = Gradient::new(GradientKind::Linear, stops); + let reach = StopReach::new(&g.stops); + for t in (-4..=44).map(|k| k as f32 / 40.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]) { + // Compared as printed: a NaN offset mixes NaN colours, which are equal only so. + let (got, want) = (reach.sample_with(&g.stops, t, &Color::lerp_keep_model), g.sample_with(t, Color::lerp_keep_model)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "{offsets:?} at {t}"); + } + } +} + +/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as +/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one +/// offset used to scan them all for each midpoint). +#[test] +fn expanded_with_repeated_offsets_samples_midpoints() { + let stop = |offset: f32, hex: &str| GradientStop { midpoint: 0.25, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let g = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000"), stop(0.5, "#00ff00"), stop(0.5, "#0000ff"), stop(1.0, "#000000")]); + for (t, c, o, mid) in g.expanded().filter(|e| e.3.is_some()) { + assert_eq!(mid, Some(0.25)); + assert_eq!((c, o), g.sample(t), "midpoint at {t}"); + } + let many = Gradient::new(GradientKind::Linear, (0..200_000).map(|i| stop(if i == 0 { 0.0 } else { 0.5 }, "#808080")).collect()); + assert_eq!(many.expanded().count(), 2 * 200_000 - 1); +} + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); @@ -455,3 +513,75 @@ fn perceptual_gradients_draw_as_pixels() { assert!(GradientPaint::new(radial).draws_as_pixels(fill)); } } + +/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on +/// the curve has the lightness of the color mixed there, so the tints between the stops are not +/// a straight line. +#[test] +fn ink_tints_follow_the_perceptual_curve() { + use crate::libraries::srgb_to_oklab; + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]); + g.method = GradientMethod::Perceptual; + assert!(g.fits_ink_tints()); + let tints = g.ink_tints(&ink, &[]).expect("a magenta ink's tints differ in lightness"); + assert!(tints.len() > 2, "the curve's stops: {tints:?}"); + let (first, last) = (tints[0], tints[tints.len() - 1]); + assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}"); + // The stops' own tints, when given, are kept exactly; the tints in between are the same. + let own = g.ink_tints(&ink, &[0.8, 0.0]).expect("tints"); + assert!(own.len() == tints.len() && own[0].1 == 0.8 && own[own.len() - 1].1 == 0.0, "{own:?}"); + assert!(own[1..own.len() - 1] == tints[1..tints.len() - 1], "{own:?}"); + assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}"); + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + for (t, tint, _) in &tints { + let want = lightness(&g.sample(*t).0); + assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}"); + } + let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint"); + assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}"); + // A white ink's tints all have paper white's lightness: no tints by lightness. + assert_eq!(g.ink_tints(&Color::WHITE, &[]), None); + // More stops on the curve than MAX_INK_TINTS: no tints. + let n = MAX_INK_TINTS + 1; + g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect(); + assert_eq!(g.ink_tints(&ink, &[]), None); + assert!(!g.fits_ink_tints()); +} + +/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their +/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of +/// the color on the curve there. +#[test] +fn ink_mixes_follow_the_perceptual_curve() { + use crate::libraries::srgb_to_oklab; + let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0)); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]); + g.stops[0].midpoint = 0.3; + assert_eq!(g.ink_mixes(), None, "a Classic gradient"); + g.method = GradientMethod::Perceptual; + let mixes = g.ink_mixes().expect("a Perceptual gradient's curve"); + let curve = g.expanded_stops(); + assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve"); + assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops"); + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) { + assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}"); + if *i == 0 && *t > 0.0 { + let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w); + assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}"); + } + } + let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint"); + assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}"); + assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}"); + // Two colors of one lightness: the weights are the mix positions. + g.stops[1].color = magenta; + let mixes = g.ink_mixes().expect("the curve"); + let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint"); + assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}"); + // More stops on the curve than MAX_INK_TINTS: no mixes. + let n = MAX_INK_TINTS + 1; + g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect(); + assert_eq!(g.ink_mixes(), None); +} diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index e88f767a5..5117f16a3 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -244,6 +244,12 @@ pub fn is_tint(c: Color, base: Color) -> bool { a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01) } +/// Whether `a` and `b` carry the same ink in `a`'s model, within [`is_tint`]'s tolerance. +pub fn same_ink(a: Color, b: Color) -> bool { + let (x, y) = (ink(a, a.model()), ink(b, a.model())); + x.iter().zip(y).all(|(p, q)| (p - q).abs() <= 0.01) +} + /// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab. pub fn shaded(c: Color, f: f32) -> Color { let f = f.clamp(0.0, 1.0); @@ -255,6 +261,77 @@ pub fn shaded(c: Color, f: f32) -> Color { } } +/// Recolor Artwork's Global Adjust: the same change to every color, by four amounts from -1 to 1 +/// (-100% to +100%), where 0 leaves a color as it is. The steps run in this order: +/// +/// 1. Saturation multiplies HSB saturation by 1 + `saturation` (-1 gives a gray, 1 doubles it). +/// 2. Brightness moves HSB brightness that share of the way to its top (above 0) or to black +/// (below 0). +/// 3. Temperature adds 0.2 × `temperature` to display red and takes as much from blue (warmer +/// above 0, cooler below). +/// 4. Luminosity moves Lab lightness that share of the way to 100 (above 0) or to 0 (below 0), +/// with Lab a and b, and so the Lab hue, kept. +/// +/// The result is in the model of the color it changes, with its components clamped to their +/// ranges. An amount that is not finite counts as 0. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GlobalAdjust { + pub saturation: f32, + pub brightness: f32, + pub temperature: f32, + pub luminosity: f32, +} + +impl GlobalAdjust { + /// The four amounts, in order, each finite and within -1..=1. + fn amounts(&self) -> [f32; 4] { + [self.saturation, self.brightness, self.temperature, self.luminosity].map(|v| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 }) + } + + /// Whether every color stays as it is (all four amounts 0). + pub fn is_identity(&self) -> bool { + self.amounts().iter().all(|v| *v == 0.0) + } + + /// `c` with the adjustment, in `c`'s model; `c` itself when [`GlobalAdjust::is_identity`]. + pub fn apply(&self, c: Color) -> Color { + let [sat, bri, temp, lum] = self.amounts(); + if [sat, bri, temp, lum].iter().all(|v| *v == 0.0) { + return c; + } + // `v` moved the share `a` of the way to `top` (a > 0) or to 0 (a < 0). + let toward = |v: f32, a: f32, top: f32| if a >= 0.0 { v + (top - v) * a } else { v * (1.0 + a) }; + let mut out = c; + if sat != 0.0 || bri != 0.0 { + let [h, s, v] = out.to_hsb(); + out = Color::from_hsb(h, (s * (1.0 + sat)).clamp(0.0, 1.0), toward(v.clamp(0.0, 1.0), bri, 1.0)); + } + if temp != 0.0 { + let [r, g, b] = out.to_rgb(); + out = Color::rgb((r + 0.2 * temp).clamp(0.0, 1.0), g.clamp(0.0, 1.0), (b - 0.2 * temp).clamp(0.0, 1.0)); + } + if lum != 0.0 { + let Lab { l, a, b } = match out { + Color::Lab { l, a, b } => Lab::new(l, a, b), + _ => out.to_lab(), + }; + out = Color::lab(toward(l.clamp(0.0, 100.0), lum, 100.0), a, b); + } + clamped(keep_model(c, out)) + } +} + +/// `c` with its components in their ranges: RGB, CMYK and Gray 0..1, Lab lightness 0..100. +fn clamped(c: Color) -> Color { + let u = |v: f32| v.clamp(0.0, 1.0); + match c { + Color::Rgb { r, g, b } => Color::rgb(u(r), u(g), u(b)), + Color::Cmyk { c, m, y, k } => Color::cmyk(u(c), u(m), u(y), u(k)), + Color::Gray { k } => Color::gray(u(k)), + Color::Lab { l, a, b } => Color::lab(l.clamp(0.0, 100.0), a, b), + } +} + /// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`. #[derive(Clone, Copy, Debug)] pub struct RowMap { @@ -356,10 +433,11 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec> { .collect(); let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6); let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); - let n = n.max(1); if pts.is_empty() { return vec![]; } + // No more rows than colours: each colour seeds one at most. + let n = n.clamp(1, pts.len()); // Seeds: the heaviest colour, then each time the one farthest from every seed so far. let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best }); let mut centres = vec![pts[first]]; @@ -370,6 +448,8 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec> { break; } centres.push(pts[i]); + // Picked: a colour whose distances aren't finite (an infinite coordinate) stays farthest. + near[i] = 0.0; for (j, p) in pts.iter().enumerate() { near[j] = near[j].min(d2(p, &pts[i])); } @@ -593,6 +673,12 @@ mod tests { assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. })); } + #[test] + fn clustering_seeds_each_colour_once_even_unbounded() { + let colors = [Color::lab(f32::INFINITY, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + assert!(cluster(&colors, &[], usize::MAX).len() <= colors.len()); + } + #[test] fn clustering_reduces_to_n_rows_of_similar_colours() { let colors = @@ -615,6 +701,77 @@ mod tests { assert!(row.iter().all(|c| kept.map(*c).model() == c.model())); } + fn adjust(saturation: f32, brightness: f32, temperature: f32, luminosity: f32) -> GlobalAdjust { + GlobalAdjust { saturation, brightness, temperature, luminosity } + } + + #[test] + fn global_adjust_at_zero_leaves_every_color_as_it_is() { + let zero = GlobalAdjust::default(); + assert!(zero.is_identity()); + assert!(adjust(f32::NAN, 0.0, f32::INFINITY, 0.0).is_identity(), "amounts that aren't finite count as 0"); + for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.1234, 0.5, 0.0, 0.07), Color::gray(0.4), Color::lab(55.0, 60.5, -40.25)] { + assert_eq!(zero.apply(c), c); + } + assert!(!adjust(0.0, 0.0, 0.0, 0.01).is_identity()); + } + + #[test] + fn global_adjust_saturation_and_brightness_reach_their_ends() { + let c = Color::rgb(0.8, 0.4, 0.2); + let [h, s, v] = c.to_hsb(); + let gray = adjust(-1.0, 0.0, 0.0, 0.0).apply(c).to_hsb(); + assert!(gray[1].abs() < 1e-4 && (gray[2] - v).abs() < 1e-4, "-100% saturation gives a gray as bright: {gray:?}"); + let more = adjust(0.5, 0.0, 0.0, 0.0).apply(c).to_hsb(); + assert!((more[0] - h).abs() < 0.5 && more[1] > s && (more[1] - (s * 1.5).min(1.0)).abs() < 1e-3, "{more:?}"); + assert!(adjust(-1.0, 0.0, 0.0, 0.0).apply(Color::rgb(0.5, 0.5, 0.5)) == Color::rgb(0.5, 0.5, 0.5), "a gray stays gray"); + assert!(adjust(0.0, -1.0, 0.0, 0.0).apply(c).to_hsb()[2] < 1e-4, "-100% brightness is black"); + assert!((adjust(0.0, 1.0, 0.0, 0.0).apply(c).to_hsb()[2] - 1.0).abs() < 1e-4, "+100% brightness is full brightness"); + let half = adjust(0.0, 0.5, 0.0, 0.0).apply(c).to_hsb(); + assert!((half[2] - (v + (1.0 - v) * 0.5)).abs() < 1e-3 && (half[1] - s).abs() < 1e-3, "{half:?}"); + } + + #[test] + fn global_adjust_temperature_warms_and_cools() { + let c = Color::rgb(0.5, 0.5, 0.5); + let warm = adjust(0.0, 0.0, 1.0, 0.0).apply(c).to_rgb(); + let cool = adjust(0.0, 0.0, -1.0, 0.0).apply(c).to_rgb(); + assert!(warm[0] > warm[1] && warm[1] > warm[2], "warmer: more red, less blue {warm:?}"); + assert!(cool[2] > cool[1] && cool[1] > cool[0], "cooler: more blue, less red {cool:?}"); + let edge = adjust(0.0, 0.0, 1.0, 0.0).apply(Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(edge, Color::rgb(1.0, 0.0, 0.0), "clamped at the ends of the channels"); + } + + #[test] + fn global_adjust_luminosity_keeps_the_hue() { + let lab = Color::lab(50.0, 30.0, -20.0); + assert_eq!(adjust(0.0, 0.0, 0.0, 0.5).apply(lab), Color::lab(75.0, 30.0, -20.0), "a Lab color keeps a and b exactly"); + assert_eq!(adjust(0.0, 0.0, 0.0, -0.5).apply(lab), Color::lab(25.0, 30.0, -20.0)); + let c = Color::rgb(0.6, 0.35, 0.3); + let (before, after) = (c.to_lab(), adjust(0.0, 0.0, 0.0, 0.2).apply(c).to_lab()); + assert!(after.l > before.l, "lighter: {after:?}"); + assert!((after.hue() - before.hue()).abs() < 2.0, "hue kept: {} vs {}", after.hue(), before.hue()); + let darkest = adjust(0.0, 0.0, 0.0, -1.0).apply(c).to_lab(); + assert!(darkest.l < before.l * 0.5, "-100% is far darker: {darkest:?}"); + assert_eq!(adjust(0.0, 0.0, 0.0, -1.0).apply(Color::gray(0.4)), Color::gray(1.0), "-100% makes a gray black"); + } + + #[test] + fn global_adjust_keeps_the_model_and_the_ranges() { + let all = adjust(1.0, 1.0, 1.0, 1.0); + for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.2, 0.9, 0.1, 0.3), Color::gray(0.4), Color::lab(55.0, 60.0, -40.0)] { + let out = all.apply(c); + assert_eq!(out.model(), c.model(), "{c:?} → {out:?}"); + let comps: Vec = match out { + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Gray { k } => vec![k], + Color::Lab { l, .. } => vec![l / 100.0], + }; + assert!(comps.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{out:?}"); + } + } + #[test] fn palette_snaps_to_the_nearest_colour() { let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]); diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 4ac758ffa..81b9aaaf5 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -10,10 +10,12 @@ mod stroke_style; pub(crate) use layers::HEAD_REACH; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; -pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; +pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. +// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each. +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum AppearanceItem { @@ -126,11 +128,15 @@ impl Appearance { pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> { self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) } - /// This appearance for other art: the pen pressure its strokes were drawn with stays with them. + /// This appearance for other art: the pen pressure, tilt, bearing and rotation its strokes + /// were drawn with stay with them. pub fn without_pressure(mut self) -> Self { for it in &mut self.items { if let AppearanceItem::Stroke(s) = it { s.pressure = None; + s.tilt = None; + s.bearing = None; + s.rotation = None; } } self @@ -504,6 +510,27 @@ impl Appearance { self.set_contents_at(others_below + usize::from(now_below)); Some(to) } + /// Move items `from` (ascending paint-order indices, no repeats) as one block, in their order, + /// so the lowest lands at `to` (clamped); returns it. The contents stay between the same unmoved + /// items; a block landing right at them keeps its lowest item's side. None (and nothing changed) + /// when an index is missing. + pub fn move_items(&mut self, from: &[usize], to: usize) -> Option { + let (&first, _) = from.split_first()?; + if from.iter().any(|i| *i >= self.items.len()) { + return None; + } + let below = first < self.contents_at(); + // From the top down, so the lower indices stay valid; then back in order. + let mut block: Vec = from.iter().rev().filter_map(|&i| self.remove_item(i)).collect(); + block.reverse(); + let others_below = self.contents_at(); + let to = to.min(self.items.len()); + let m = block.len(); + self.items.splice(to..to, block); + let now_below = if below { to <= others_below } else { to < others_below }; + self.set_contents_at(others_below + if now_below { m } else { 0 }); + Some(to) + } } /// The most fills, strokes or effects a stack may have to be matched against another's diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index dd5c41325..880f3490d 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -1,4 +1,4 @@ -use super::profile::{PressureProfile, WidthProfile}; +use super::profile::{AngleProfile, PressureProfile, WidthProfile}; use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; use super::{BlendMode, Deserialize, Paint, Serialize}; @@ -93,6 +93,21 @@ pub struct StrokeLayer { /// art leave it behind ([`Appearance::without_pressure`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub pressure: Option, + /// How far the pen leaned from upright along the stroke (Paintbrush with a tablet that reads + /// tilt): 0 upright to 1 lying flat (90°). Brushes with Tilt variation vary with it. Like the + /// pressure, it stays with this path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tilt: Option, + /// Which way the pen leaned along the stroke, in degrees counter-clockwise on the page from the + /// x axis. Brushes with Bearing variation vary with it. Like the pressure, it stays with this + /// path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bearing: Option, + /// How far the pen's barrel was turned along the stroke, in degrees counter-clockwise (a pen + /// that reads barrel rotation). Brushes with Rotation variation vary with it. Like the + /// pressure, it stays with this path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rotation: Option, } /// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant @@ -167,6 +182,9 @@ impl StrokeLayer { overprint: false, gradient_mode: StrokeGradientMode::Within, pressure: None, + tilt: None, + bearing: None, + rotation: None, } } /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index ccae9666b..1c3334f0d 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use super::{Deserialize, Serialize}; /// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), @@ -53,6 +55,15 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; + /// The widest the stroke gets, as a multiple of its weight: the largest total of the left and + /// right factors at any of the points (between them the profile is straight), 1 for a profile + /// without points. + pub fn widest(&self) -> f64 { + if self.points.is_empty() { + return 1.0; + } + self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max) + } /// The built-in profile with this id. pub fn preset(id: &str) -> Option { Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() }) @@ -92,7 +103,8 @@ impl WidthProfile { /// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1 /// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s -/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it. +/// are. The pen's tilt is kept the same way ([`StrokeLayer::tilt`]). Calligraphic and Scatter +/// brushes with Pressure or Tilt variation read them; everything else ignores them. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct PressureProfile { pub points: Vec<(f64, f64)>, @@ -109,18 +121,7 @@ impl PressureProfile { /// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any. pub fn at(&self, t: f64) -> f64 { - let p = &self.points; - let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID }; - // (Positions go forward; a profile read from a file is searched as if they do, in log time.) - let v = if t <= first.0 { - first.1 - } else { - let i = p.partition_point(|q| q.0 < t); - match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { - (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0), - _ => last.1, - } - }; + let v = interpolated(&self.points, t).unwrap_or(Self::MID); if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID } } @@ -130,44 +131,21 @@ impl PressureProfile { pub fn from_samples(samples: impl IntoIterator) -> Option { let pts: Vec<(f64, f64)> = samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect(); - if pts.is_empty() { - return None; - } - // Simplifying is quadratic at worst: a huge stroke is thinned evenly first. - let mut pts = thin(pts, 16 * Self::MAX_POINTS); - // Positions only ever go forward. - let mut prev = 0.0; - for p in &mut pts { - p.0 = p.0.max(prev); - prev = p.0; - } - let keep = simplify_1d(&pts, Self::TOLERANCE); - let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS); - let round = |v: f64| (v * 1e4).round() / 1e4; - Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() }) + simplified(pts, Self::TOLERANCE).map(|points| Self { points }) } /// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its /// end by a stroke `added_len` long (`added`). None when neither has pressure. pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option { - if old.is_none() && added.is_none() { - return None; + match extended_points(old.map(|p| p.points.as_slice()), old_len, added.map(|p| p.points.as_slice()), added_len, Self::MID)? { + Cow::Borrowed(points) => Some(Self { points: points.to_vec() }), + Cow::Owned(points) => Self::from_samples(points), } - let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0)); - let total = old_len + added_len; - if !total.is_finite() || total <= 1e-9 { - return old.or(added).cloned(); - } - let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] }; - let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat)); - let o = old_len / total; - let old_pts = old.points.iter().map(|&(t, p)| (t * o, p)); - Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p)))) } /// The pressure of the path reversed. pub fn reversed(&self) -> Self { - Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() } + Self { points: reversed_points(&self.points) } } /// A light–heavy–light stroke, for previews of pressure-sensitive brushes. @@ -176,6 +154,142 @@ impl PressureProfile { } } +/// A pen angle recorded along a stroke (the Paintbrush with a tablet that reads it): (position 0..1 +/// along the path, degrees) samples in order, positions measured as a [`PressureProfile`]'s are. +/// Each sample is joined to the one before it the shorter way round, so the degrees can run past +/// ±180° and the angle read between two samples turns through the smaller angle between them. +/// Calligraphic and Scatter brushes with Bearing or Rotation variation read it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AngleProfile { + pub points: Vec<(f64, f64)>, +} + +impl AngleProfile { + /// How far (in degrees) simplifying may move the recorded curve. + const TOLERANCE: f64 = 1.0; + + /// The angle at `t` (0..1), linear between samples, in degrees above −180 up to 180; 0 + /// without any samples. + pub fn at(&self, t: f64) -> f64 { + let v = interpolated(&self.points, t).unwrap_or(0.0); + if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 } + } + + /// A profile from (position, degrees) samples in order along the stroke: non-finite ones are + /// dropped, each sample is joined to the one before it the shorter way round, and the curve is + /// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample. + pub fn from_samples(samples: impl IntoIterator) -> Option { + let mut prev: Option = None; + let pts: Vec<(f64, f64)> = samples + .into_iter() + .filter(|(t, a)| t.is_finite() && a.is_finite()) + .map(|(t, a)| { + let a = prev.map_or(vectorcraft_geom::normalize_deg(a), |p| p + vectorcraft_geom::normalize_deg(a - p)); + prev = Some(a); + (t.clamp(0.0, 1.0), a) + }) + .collect(); + simplified(pts, Self::TOLERANCE).map(|points| Self { points }) + } + + /// The angle of a path `old_len` long (`old`; None: drawn without it, 0°) continued at its end + /// by a stroke `added_len` long (`added`). None when neither has one. Each part keeps the turns + /// it recorded, more than half a turn between two samples included; the added part is moved by + /// whole turns so that it starts the shorter way round from where the old part ends. + pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option { + let end = old.map_or(Some(0.0), |o| o.points.last().map(|p| p.1)); + let added = added.map(|a| { + let shift = match (end, a.points.first()) { + (Some(end), Some(&(_, start))) => end + vectorcraft_geom::normalize_deg(start - end) - start, + _ => 0.0, + }; + Self { points: a.points.iter().map(|&(t, v)| (t, v + shift)).collect() } + }); + match extended_points(old.map(|p| p.points.as_slice()), old_len, added.as_ref().map(|p| p.points.as_slice()), added_len, 0.0)? { + Cow::Borrowed(points) => Some(Self { points: points.to_vec() }), + Cow::Owned(points) => { + let pts = points.into_iter().filter(|(t, a)| t.is_finite() && a.is_finite()).map(|(t, a)| (t.clamp(0.0, 1.0), a)).collect(); + simplified(pts, Self::TOLERANCE).map(|points| Self { points }) + } + } + } + + /// The angle along the path reversed (the angles stay as they were on the page). + pub fn reversed(&self) -> Self { + Self { points: reversed_points(&self.points) } + } + + /// One turn through every angle along the stroke, for previews of brushes that vary with one. + pub fn sample() -> Self { + Self { points: vec![(0.0, -179.0), (1.0, 179.0)] } + } +} + +/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any. +fn interpolated(p: &[(f64, f64)], t: f64) -> Option { + let (first, last) = (p.first()?, p.last()?); + if t <= first.0 { + return Some(first.1); + } + // (Positions go forward; a profile read from a file is searched as if they do, in log time.) + let i = p.partition_point(|q| q.0 < t); + Some(match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { + (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0), + _ => last.1, + }) +} + +/// Samples (position, value) with positions in 0..1 and finite values, simplified to within `tol` +/// of the value and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample. +fn simplified(pts: Vec<(f64, f64)>, tol: f64) -> Option> { + if pts.is_empty() { + return None; + } + // Simplifying is quadratic at worst: a huge stroke is thinned evenly first. + let mut pts = thin(pts, 16 * PressureProfile::MAX_POINTS); + // Positions only ever go forward. + let mut prev = 0.0; + for p in &mut pts { + p.0 = p.0.max(prev); + prev = p.0; + } + let keep = simplify_1d(&pts, tol); + let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), PressureProfile::MAX_POINTS); + let round = |v: f64| (v * 1e4).round() / 1e4; + Some(pts.into_iter().map(|(t, p)| (round(t), round(p))).collect()) +} + +/// The samples of a path `old_len` long (`old`) continued at its end by a stroke `added_len` long +/// (`added`), in order along the whole path, a side without samples (`None`) flat at `none`. +/// `None` when neither side has samples; the side that has them, as they are, when the two +/// lengths add up to no length. +fn extended_points<'a>( + old: Option<&'a [(f64, f64)]>, + old_len: f64, + added: Option<&'a [(f64, f64)]>, + added_len: f64, + none: f64, +) -> Option> { + if old.is_none() && added.is_none() { + return None; + } + let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0)); + let total = old_len + added_len; + if !total.is_finite() || total <= 1e-9 { + return old.or(added).map(Cow::Borrowed); + } + let flat = [(0.0, none), (1.0, none)]; + let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat)); + let o = old_len / total; + let old_pts = old.iter().map(|&(t, p)| (t * o, p)); + Some(Cow::Owned(old_pts.chain(added.iter().map(|&(u, p)| (o + u * (1.0 - o), p))).collect())) +} + +/// Samples (position, value) of a path, for the path reversed. +fn reversed_points(points: &[(f64, f64)]) -> Vec<(f64, f64)> { + points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() +} + /// At most `max` of `pts`, evenly spread (the ends kept). fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> { let n = pts.len(); diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 3e3d610f4..089309f34 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -125,11 +125,50 @@ fn pressure_profile_extends_and_reverses() { assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]); } +/// #764: a pen's bearing or barrel rotation is kept the shorter way round between samples, so +/// 170° then −170° turns through 180°, not through 0°. +#[test] +fn angle_profiles_join_samples_the_shorter_way_round() { + let across = AngleProfile::from_samples([(0.0, 170.0), (1.0, -170.0)]).unwrap(); + assert_eq!(across.points, vec![(0.0, 170.0), (1.0, 190.0)]); + assert_eq!(across.at(0.5), 180.0); + assert!((across.at(0.75) + 175.0).abs() < 1e-9, "{}", across.at(0.75)); + // Turns past a whole turn keep counting, and read back within −180..180°. + let spin = AngleProfile::from_samples((0..=40).map(|i| (f64::from(i) / 40.0, f64::from(i * 30) - 180.0))).unwrap(); + assert!((spin.points.last().unwrap().1 - 1380.0).abs() < 1e-9, "{spin:?}"); + assert!((spin.at(1.0) + 60.0).abs() < 1e-9 && spin.points.len() == 2, "{spin:?}"); + // Junk is dropped; nothing at all is no profile, and no profile reads 0°. + let junk = AngleProfile::from_samples([(f64::NAN, 5.0), (0.5, f64::INFINITY), (0.2, 400.0)]).unwrap(); + assert_eq!(junk.points, vec![(0.2, 40.0)]); + assert!(AngleProfile::from_samples([(0.5, f64::NAN)]).is_none()); + assert_eq!(AngleProfile::default().at(0.3), 0.0); + let noisy = AngleProfile::from_samples((0..1_000_000).map(|i| (f64::from(i) / 1e6, f64::from(i % 2) * 90.0))).unwrap(); + assert!(noisy.points.len() <= PressureProfile::MAX_POINTS); + // Continued: the old part, then the new one; a side without an angle is 0°. Reversed, the + // angles stay what they were. + let old = AngleProfile { points: vec![(0.0, 90.0), (1.0, 90.0)] }; + let joined = AngleProfile::extended(Some(&old), 30.0, None, 10.0).unwrap(); + assert!((joined.at(0.5) - 90.0).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}"); + assert!(AngleProfile::extended(None, 10.0, None, 10.0).is_none()); + assert_eq!(AngleProfile::extended(Some(&old), 0.0, None, 0.0), Some(old.clone())); + assert_eq!(across.reversed().points, vec![(0.0, 190.0), (1.0, 170.0)]); + // A part that turned more than half a turn between two of its samples keeps that turn when + // the path is continued, and so does the new part. + let quarter_turns = AngleProfile::from_samples([(0.0, 0.0), (1.0 / 3.0, 90.0), (2.0 / 3.0, 180.0), (1.0, 270.0)]).unwrap(); + assert_eq!(quarter_turns.points, vec![(0.0, 0.0), (1.0, 270.0)]); + let added = AngleProfile { points: vec![(0.0, -80.0), (1.0, 280.0)] }; + let joined = AngleProfile::extended(Some(&quarter_turns), 10.0, Some(&added), 10.0).unwrap(); + assert!((joined.at(0.25) - 135.0).abs() < 1e-9, "{joined:?}"); + assert!((joined.at(0.75) - 100.0).abs() < 1e-9, "{joined:?}"); +} + /// Strokes saved before pen pressure load as they were, and are written without it. #[test] fn strokes_without_pressure_round_trip_unchanged() { let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap(); - assert!(json.get("pressure").is_none()); + for key in ["pressure", "tilt", "bearing", "rotation"] { + assert!(json.get(key).is_none(), "{key}"); + } let st: StrokeLayer = serde_json::from_value(json).unwrap(); assert_eq!(st.pressure, None); let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st }; @@ -138,6 +177,19 @@ fn strokes_without_pressure_round_trip_unchanged() { let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0); ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample()); assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None); + // The pen's tilt, bearing and rotation load, save and stay behind the same way. + let posed = StrokeLayer { + tilt: Some(PressureProfile::sample()), + bearing: Some(AngleProfile::sample()), + rotation: Some(AngleProfile { points: vec![(0.0, -45.0)] }), + ..back + }; + let again: StrokeLayer = serde_json::from_value(serde_json::to_value(&posed).unwrap()).unwrap(); + assert_eq!(again, posed); + let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0); + *ap.stroke_mut().unwrap() = posed; + let st = ap.without_pressure().stroke().cloned().unwrap(); + assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None)); } #[test] diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index b78b8eb01..6d5d50d4d 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -35,8 +35,8 @@ pub struct Artboard { pub video_ruler_pixel_aspect_ratio: f64, /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; - /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave - /// it out. + /// without one the paper is white, or the Simulate Colored Paper color. Exports and print draw + /// it behind the art ([`super::Document::with_artboard_fills`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, } @@ -77,6 +77,37 @@ impl Artboard { } } +impl super::Document { + /// A copy for output with the background colors ([`Artboard::color`]) of the artboards at + /// `indices` drawn behind all the art: each a rectangle of its artboard filled with its color + /// (in the color's own model), on a layer of their own (Artboard Fill) below the + /// others. `None` when none of them has a color. + pub fn with_artboard_fills(&self, indices: impl IntoIterator) -> Option { + let fills: Vec<(Rect, vectorcraft_color::Color)> = indices + .into_iter() + .filter_map(|i| { + let a = self.artboards.get(i)?; + Some((a.rect, a.color?)) + }) + .collect(); + if fills.is_empty() { + return None; + } + let mut d = self.clone(); + let mut layer = Node::layer(d.alloc_id(), "Artboard Fill", super::LayerColor::Preset(0)); + let paths: Vec> = fills + .into_iter() + .map(|(r, c)| { + let look = super::Appearance::basic(vectorcraft_color::Paint::solid(c), vectorcraft_color::Paint::None, 0.0); + Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look)) + }) + .collect(); + *layer.children_mut()? = paths; + d.layers.insert(0, Arc::new(layer)); + Some(d) + } +} + /// A document's locked artboards ([`Document::locked_artboards`]). pub struct LockedArtboards<'a> { pub(crate) rects: Vec, diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ced9472a7..8bca517ee 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::kurbo::ParamCurveArclen; -use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2}; +use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; @@ -862,6 +862,52 @@ impl Spine { pub fn anchor_length(&self, i: usize) -> Option { self.anchors.get(i).copied() } + /// Arc length of the point of the spine nearest `p`. An open spine's end segments run on past + /// its ends as lines, so the result is below 0 before the start and above [`Spine::length`] + /// beyond the end. With `from`, the arc length of an earlier point a drag followed, each point + /// counts as farther by half its distance along the spine from there, so a pointer passing + /// near another part of the spine stays on the part it follows. Where two points count as + /// near as each other, the one earlier along the spine counts. + pub fn length_near(&self, p: Point, from: Option) -> f64 { + self.nearest(p, from).map_or(0.0, |(_, s)| s) + } + /// [`Spine::length_near`] with how near the point counts: its distance from `p`, plus half its + /// distance along the spine from `from`. `None` for a point that is not finite. + pub fn nearest(&self, p: Point, from: Option) -> Option<(f64, f64)> { + if !p.x.is_finite() || !p.y.is_finite() { + return None; + } + let last = self.pts.len().saturating_sub(2); + let mut best: Option<(f64, f64)> = None; + for (i, (w, c)) in self.pts.windows(2).zip(&self.cum).enumerate() { + let [a, b] = [w[0], w[1]]; + let d = b - a; + let l2 = d.dot(d); + if !l2.is_finite() || l2 <= 0.0 { + continue; + } + let t = (p - a).dot(d) / l2; + let t = match (self.closed, i == 0, i == last) { + (false, true, true) => t, + (false, true, false) => t.min(1.0), + (false, false, true) => t.max(0.0), + _ => t.clamp(0.0, 1.0), + }; + let s = c + t * l2.sqrt(); + let dist = p.distance(a + d * t) + from.filter(|f| f.is_finite()).map_or(0.0, |f| self.apart(s, f) / 2.0); + if dist.is_finite() && best.is_none_or(|(b, _)| dist < b) { + best = Some((dist, s)); + } + } + best + } + /// How far apart arc lengths `s` and `f` are along the spine: the shorter way round a closed + /// spine, across its first anchor. + pub fn apart(&self, s: f64, f: f64) -> f64 { + let len = self.length(); + let d = (s - f).abs(); + if self.closed && len > 0.0 { d.rem_euclid(len).min(len - d.rem_euclid(len)) } else { d } + } /// Point and tangent angle (radians) at arc-length fraction `f` (0..1). pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) @@ -1029,6 +1075,27 @@ impl Rail { } } +/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps +/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest +/// any key paints from its centre. A step's shape lies within that distance of the point on the +/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing +/// moves. +pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { + if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + return None; + } + let spine = blend_spine(keys, spec)?.0.bounds()?; + let radius = keys + .iter() + .filter_map(|k| { + let (c, b) = (center_of(k), k.visual(symbols)?); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; + Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max)) + }) + .fold(0.0, f64::max); + Some(spine.inflate(3.0 * radius, 3.0 * radius)) +} + // ===================================================================================== // Blend evaluation // ===================================================================================== diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 8e4f80728..5abba9c63 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -107,15 +107,21 @@ fn nested_deeper(now: &[Arc], depth: usize) -> bool { /// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns /// whether anything changed. Recurses no deeper than the cap. fn limit(nodes: &mut Vec>, depth: usize) -> bool { - let mut changed = false; - let mut i = 0; - while let Some(n) = nodes.get_mut(i) { - if !nested_deeper(std::slice::from_ref(n), depth) { - i += 1; + if !nested_deeper(nodes, depth) { + return false; + } + // The list is rebuilt in order rather than spliced in place: replacing one container of many + // by what it holds would move all the siblings after it each time. + let mut out = Vec::with_capacity(nodes.len()); + // What is left to look at, the next one last. + let mut left: Vec> = std::mem::take(nodes); + left.reverse(); + while let Some(mut a) = left.pop() { + if !nested_deeper(std::slice::from_ref(&a), depth) { + out.push(a); continue; } - changed = true; - let n = Arc::make_mut(n); + let n = Arc::make_mut(&mut a); if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) { n.extras_mut().mask = None; } @@ -123,7 +129,7 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { if let Some(ch) = n.children_mut() { limit(ch, depth + 1); } - i += 1; + out.push(a); continue; } let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); @@ -140,9 +146,10 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { } } // What it held takes its place, and is looked at in its turn. - nodes.splice(i..=i, held); + left.extend(held.into_iter().rev()); } - changed + *nodes = out; + true } /// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an @@ -439,6 +446,24 @@ mod tests { } } + #[test] + fn many_containers_at_the_cap_are_merged_in_order() { + // A group just above the cap holding many groups of two paths each: every group is + // replaced by its two paths, in order (in time linear in their number). + const PAIRS: u64 = 50_000; + let path = |id| Arc::new(Node::path(NodeId(id), Default::default(), Default::default())); + let pairs = (0..PAIRS).map(|i| Arc::new(Node::group(NodeId(1_000_000 + i), vec![path(2 * i + 1), path(2 * i + 2)]))).collect(); + let mut art = Arc::new(Node::group(NodeId(500_000), pairs)); + for i in 0..MAX_DEPTH - 3 { + art = Arc::new(Node::group(NodeId(600_000 + i as u64), vec![art])); + } + let mut d = doc(art); + assert!(d.limit_depth()); + assert!(!d.too_deep(None)); + let holder = d.node(NodeId(500_000)).and_then(Node::children).map(|c| c.iter().map(|c| c.id.0).collect::>()); + assert_eq!(holder, Some((1..=2 * PAIRS).collect())); + } + #[test] fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() { let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true }); @@ -485,7 +510,7 @@ mod tests { #[test] fn symbols_and_patterns_are_limited_too() { let mut d = Document::new(10.0, 10.0); - d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: nested(MAX_DEPTH + 3) }); assert!(d.too_deep(None)); assert!(d.limit_depth()); assert!(!d.too_deep(None)); @@ -504,7 +529,7 @@ mod tests { for _ in 0..levels { art = Arc::new(Node::group(NodeId(3), vec![art])); } - d.symbols.push(crate::Symbol { name: format!("s{i}"), art }); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("s{i}"), art }); } d } @@ -573,7 +598,7 @@ mod tests { }; let through_a_symbol = |d: &mut Document| { d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")])); - d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") }); + d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: painted(3, "p") }); }; for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] { let mut d = doc(painted(1, "p")); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 478e54ca8..976d48485 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -47,6 +47,7 @@ impl Document { patterns, pattern_edit, mask_edit, + symbol_edit, next_id: _, unknown, page_isolate, @@ -113,6 +114,7 @@ impl Document { (a, b) => a.is_none() && b.is_none(), } && *mask_edit == other.mask_edit + && *symbol_edit == other.symbol_edit && *unknown == other.unknown && *page_isolate == other.page_isolate && *page_knockout == other.page_knockout @@ -221,6 +223,7 @@ impl Document { patterns: vec![], pattern_edit: None, mask_edit: None, + symbol_edit: None, next_id: 1, unknown: Default::default(), page_isolate: false, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index c0c85b124..c9a6c5f56 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -2,8 +2,8 @@ use super::graphic_style::GraphicStyle; use super::limits::MAX_NEXT_ID; use super::records::{ImageBlob, SavedSelection, SavedView}; use super::{ - Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice, - SymbolRegistration, + Arc, Document, EnvelopeKind, ImageObject, LiveShape, Node, NodeKind, Point, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, + SymbolExportType, SymbolNineSlice, SymbolRegistration, placed_document, }; impl Document { @@ -98,24 +98,134 @@ impl Document { self.views = kept; } - /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the - /// mask already holds (the engine syncs it after every edit). + /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working + /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). pub fn drop_edit_modes(&mut self) { if let Some(me) = self.mask_edit.take() { self.layers.retain(|l| l.id != me.layer); } + if let Some(se) = self.symbol_edit.take() { + self.layers.retain(|l| l.id != se.layer); + } } - /// The document as saved and exported: without the opacity-mask editing layer. Borrowed when - /// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.) + /// The document as saved and exported: without the opacity-mask or symbol editing layer. + /// Borrowed when neither mode is on. (Pattern editing is saved: its tile layer holds unapplied + /// edits.) pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> { - if self.mask_edit.is_none() { + if self.mask_edit.is_none() && self.symbol_edit.is_none() { return std::borrow::Cow::Borrowed(self); } let mut d = self.clone(); d.drop_edit_modes(); std::borrow::Cow::Owned(d) } + + /// Round every path's anchors and handles to `decimals` decimal places: path objects (with + /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine + /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being + /// edited included), opacity masks and the brushes kept as foreign data. Colors, transforms, + /// opacity, live shapes' own sizes, meshes and type (its frames and paths included) are left + /// as they are. + pub fn round_geometry(&mut self, decimals: u8) { + let scale = 10f64.powi(i32::from(decimals)); + let round = |v: f64| { + let r = (v * scale).round() / scale; + if !r.is_finite() { + v + } else if r == 0.0 { + // `-0.0` is written as `0.0`. + 0.0 + } else { + r + } + }; + let round_path = |path: &mut vectorcraft_geom::PathData| { + for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) { + for p in [&mut a.p, &mut a.h_in, &mut a.h_out] { + *p = Point::new(round(p.x), round(p.y)); + } + } + }; + let mut fix = |n: &mut Node| { + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + round_path(path); + // Live Corners' uncut outline. + if let Some(LiveShape::Path { base, .. }) = live.as_deref_mut() { + round_path(base); + } + } + NodeKind::Blend { spec, .. } => { + if let Some(spine) = &mut spec.spine { + round_path(spine); + } + } + NodeKind::Envelope { kind, .. } => { + if let EnvelopeKind::TopObject { path } = kind.as_mut() { + round_path(path); + } + } + _ => {} + } + if let Some(e) = n.extras_if_any() { + for area in e.intertwine.as_deref_mut().into_iter().chain(e.intertwine_tiers.iter_mut()) { + round_path(area); + } + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + placed_document::each_mut(Arc::make_mut(root), &mut fix); + } + if self.unknown.contains_key("brushes") + && let Some(brushes) = self.unknown.get_mut("brushes") + { + round_json_paths(brushes, &round); + } + } +} + +/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every +/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as +/// [`Document::round_geometry`] rounds the nodes it holds. +fn round_json_paths(v: &mut serde_json::Value, round: &impl Fn(f64) -> f64) { + use serde_json::Value; + match v { + Value::Object(o) => { + if o.get("type").and_then(Value::as_str) == Some("text") { + return; + } + if let Some(subpaths) = o.get_mut("subpaths").and_then(Value::as_array_mut) { + let anchors = subpaths.iter_mut().filter_map(|s| s.get_mut("anchors").and_then(Value::as_array_mut)).flatten(); + for a in anchors.filter_map(Value::as_object_mut) { + let points = a.iter_mut().filter(|(k, _)| matches!(k.as_str(), "p" | "in" | "out")).filter_map(|(_, v)| v.as_array_mut()); + for xy in points { + for n in xy.iter_mut().filter(|n| n.is_f64()) { + if let Some(r) = n.as_f64().and_then(|x| serde_json::Number::from_f64(round(x))) { + *n = Value::Number(r); + } + } + } + } + } + for c in o.values_mut() { + round_json_paths(c, round); + } + } + Value::Array(a) => { + for c in a { + round_json_paths(c, round); + } + } + _ => {} + } } /// Graphic styles. diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 837985874..cb972b94f 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1,7 +1,9 @@ //! Hit testing over the document tree. +use std::cell::RefCell; use std::collections::HashSet; -use std::sync::OnceLock; +use std::rc::Rc; +use std::sync::{Arc, OnceLock}; use vectorcraft_geom::hit::{fill_contains, stroke_contains}; use vectorcraft_geom::{BezPath, FillRule, Point, Rect}; @@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n)) } +/// One level of evaluation of a live object in a document, as the canvas draws it: type in an +/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated +/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the +/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else. +pub type LiveArt = fn(Option<&Document>, &Node) -> Vec; + +/// A number that changes whenever something outside the document that [`LiveArt`] reads changes, +/// such as the installed fonts or the plug-ins. +pub type LiveInputs = fn() -> u64; + +static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new(); + +/// Register how a live object's art is evaluated for clicks, and the number that changes with what +/// else it reads (the first registration stays). +pub fn set_live_art(f: LiveArt, inputs: LiveInputs) { + // Already set: the same engine, registered by another session. + let _ = LIVE_ART.set((f, inputs)); +} + +/// The registered [`LiveInputs`] now, 0 while nothing is registered. +fn live_inputs() -> u64 { + LIVE_ART.get().map_or(0, |(_, inputs)| inputs()) +} + +/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by +/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`]. +struct EvaluatedLive { + node: Node, + symbols: Vec<(String, Arc)>, + patterns: Vec, + inputs: u64, + art: Rc>, + size: usize, +} + +impl EvaluatedLive { + /// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now? + fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool { + self.inputs == inputs + && self.symbols.len() == doc.symbols.len() + && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art)) + && self.node == *n + && self.patterns == doc.patterns + } +} + +/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an +/// envelope's stroke band hits it again and again, and an envelope can hold a blend. +const LIVE_KEPT: usize = 4; + +/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than +/// this is not kept. +const LIVE_KEPT_SIZE: usize = 250_000; + +/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths. +fn art_size(art: &[Node]) -> usize { + let mut size = 0usize; + for n in art { + n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count()))); + } + size +} + +/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes +/// when the file is read, so [`live_art`] does not keep an evaluation of `n`. +fn holds_placed(doc: &Document, n: &Node) -> bool { + let mut placed = false; + let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); + n.walk(&mut find); + doc.symbols.iter().for_each(|s| s.art.walk(&mut find)); + doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find)); + placed +} + +thread_local! { + /// The evaluations [`live_art`] keeps, the latest first. + static EVALUATED: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while +/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of +/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's +/// symbols or patterns hold a placed document ([`holds_placed`]). +fn live_art(doc: &Document, n: &Node) -> Rc> { + let inputs = live_inputs(); + let kept = EVALUATED.with_borrow_mut(|kept| { + let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?; + let e = kept.remove(i); + let art = e.art.clone(); + kept.insert(0, e); + Some(art) + }); + if let Some(art) = kept { + return art; + } + #[cfg(test)] + LIVE_EVALUATED.with(|c| c.set(c.get() + 1)); + let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n))); + let size = art_size(&art); + if size > LIVE_KEPT_SIZE || holds_placed(doc, n) { + return art; + } + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect(); + let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size }; + EVALUATED.with_borrow_mut(|kept| { + kept.insert(0, e); + kept.truncate(LIVE_KEPT); + let mut total = 0usize; + kept.retain(|e| { + total = total.saturating_add(e.size); + total <= LIVE_KEPT_SIZE + }); + }); + art +} + +#[cfg(test)] +thread_local! { + /// How many live objects [`live_art`] evaluated on this thread. + static LIVE_EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Topmost editable object under `p`. pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { hit_test_skipping(doc, p, opt, &|_| false, true) @@ -110,6 +234,12 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option bool) -> Option { + hit_test_skipping(doc, p, opt, skip, false) +} + /// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask /// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A /// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that @@ -447,7 +577,41 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols // (An envelope's content sits where it was, not where the envelope draws it.) edits_contents(c) || c.extras().shaper.is_some() && source_of(c, opt.scope) - || c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) + || cull_reach(c, symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) +} + +/// A box round everything `c` can paint, for culling clicks: [`Node::reach_bounds_with`], except +/// that paths and compound paths are measured by their control points ([`PathData::control_bounds`], +/// which hold the curves) instead of along their curves, which a compound path of thousands of +/// members takes milliseconds to do on every click and hover. The box only rules points out, and it +/// is at least as large, so the same objects are hit. +/// +/// [`PathData::control_bounds`]: vectorcraft_geom::PathData::control_bounds +fn cull_reach(c: &Node, symbols: &SymbolBoxes) -> Option { + let control = match &c.kind { + NodeKind::Path { path, .. } => path.control_bounds(), + NodeKind::Compound { children, .. } if children.iter().all(|m| m.path_data().is_some()) => { + children.iter().filter_map(|m| m.path_data().and_then(vectorcraft_geom::PathData::control_bounds)).reduce(|a, b| a.union(b)) + } + _ => return c.reach_bounds_with(symbols), + }; + let o = c.appearance.outset(); + control.map(|b| b.inflate(o, o)) +} + +/// The members of compound path `children` that a click at `p` within `tol` can touch, as one +/// path: those whose control points' box, grown by `tol`, holds `p` (edges included). A member +/// outside that box is farther than `tol` from `p` and winds zero times round it, so this path's +/// outline and fill answer a click as the whole compound path's do, without assembling it. +fn compound_members_near(children: &[std::sync::Arc], p: Point, tol: f64) -> vectorcraft_geom::BezPath { + let near = |b: Rect| b.x0 - tol <= p.x && p.x <= b.x1 + tol && b.y0 - tol <= p.y && p.y <= b.y1 + tol; + let mut bp = vectorcraft_geom::BezPath::new(); + for pd in children.iter().filter_map(|m| m.path_data()) { + if pd.control_bounds().is_some_and(near) { + bp.extend(pd.to_bezpath()); + } + } + bp } /// Is the first child of `n` the isolated container `scope`? @@ -538,20 +702,15 @@ fn hit_children( } /// The order a click at `p` tries the children of `parent` in, front first: their stacking order -/// from the top, except that in an Intertwine group the members drawn over the others at `p` -/// come first (from the top among them too). +/// from the top, except that in an Intertwine group the members are tried in the order they are +/// drawn at `p`, from the top ([`crate::intertwine::tier_at`]). fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc], p: Point) -> impl Iterator + 'a { - let over: Vec = if matches!(parent.kind, NodeKind::Intertwine { .. }) { - let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p); - (0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect() - } else { - vec![] - }; - let rest = (0..children.len()).rev().filter({ - let over = over.clone(); - move |i| !over.contains(i) - }); - over.into_iter().chain(rest) + let mut order: Vec = (0..children.len()).rev().collect(); + if matches!(parent.kind, NodeKind::Intertwine { .. }) { + let tier = |i: usize| children.get(i).map_or(0, |c| crate::intertwine::tier_at(c, p)); + order.sort_by_cached_key(|i| std::cmp::Reverse((tier(*i), *i))); + } + order.into_iter() } /// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke @@ -564,7 +723,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom:: stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline) } +#[cfg(test)] +thread_local! { + /// How many objects [`hit_leaf`] tested on this thread. + static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + #[cfg(test)] + LEAVES_TESTED.with(|c| c.set(c.get() + 1)); match &n.kind { NodeKind::Path { path, rule, .. } => { let bp = path.to_bezpath(); @@ -577,12 +744,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio } None } - NodeKind::Compound { rule, .. } => { - let bp = n.stroke_path()?; - if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) { - return Some(k); + NodeKind::Compound { children, rule } => { + // Painted strokes follow the whole compound path (alignment, dashes, width profiles): + // they are measured along all of it, as before. + if !opt.outline && n.appearance.painted_strokes().next().is_some() { + let bp = n.stroke_path()?; + if n.appearance.stroke_hit(&bp, *rule, p, opt.tol) { + return Some(HitKind::Stroke); + } } - (!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) + // The outline and the fill only need the members near `p`. (Measured without + // `stroke_contains`'s box check: the box of the members near `p` is smaller than the + // whole path's, and its far edges would turn away points exactly `tol` away.) + let near = compound_members_near(children, p, opt.tol); + if vectorcraft_geom::hit::distance_to_outline(&near, p) <= opt.tol { + return Some(HitKind::Outline); + } + (!opt.outline && !opt.path_only && fill_contains(&near, *rule, p)).then_some(HitKind::Fill) } // The type path lies within the bounds: they rule out the rest before it is measured. NodeKind::Text(t) if opt.type_path_only => { @@ -611,9 +789,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A blend or an envelope is hit within its bounds, and outside them where its evaluated + // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, + // and the strokes of an envelope's distorted contents past its bounds. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols)) + } NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); @@ -650,8 +837,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool { /// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + // Hidden art does not hit. + if !n.visible { + return None; + } + // Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are + // passed by without being measured. + if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } match &n.kind { - NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)), + // A clip group only hits inside its clipping path. + NodeKind::Group { children, clip } => { + if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) { + return None; + } + children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)) + } + // An intertwine group hits by its members, as on the canvas. + NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)), _ => hit_leaf(n, p, opt, symbols), } } @@ -676,6 +880,16 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { + marquee_with(doc, r, scope, leaves, false) +} + +/// Candidates for whole-object enclosure by a loop bounded by `r`. Eligibility and group +/// traversal follow [`marquee`]; compounds contribute the complete path they paint. +pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option) -> Vec { + marquee_with(doc, r, scope, true, true) +} + +fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool) -> Vec { let mut out = Vec::new(); // `tops` pairs each object with the part of `r` in which the marquee can touch it. let tops: Vec<(&std::sync::Arc, Rect)> = match scope.and_then(|s| doc.node(s)) { @@ -720,13 +934,19 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> /// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched /// by the copies it draws, as clicks hit them. Objects of every other kind except paths, /// compound paths, layers and groups are touched by their box. - fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option) -> bool { + fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r) - .is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))), - NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)), - NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { + marquee_in(n, r).is_some_and(|r| { + children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope, whole_compounds)) + }) + } + NodeKind::Compound { children, .. } if whole_compounds => { + children.iter().filter_map(|c| c.path_data()).any(|path| vectorcraft_geom::hit::intersects_rect(path, r)) + } + NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds)), + NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -735,18 +955,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> }), } } - fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, out: &mut Vec) { + fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { let Some(r) = marquee_in(n, r) else { return }; for (i, c) in children.iter().enumerate() { if c.visible && !c.locked && !shaper_hides(n, i, scope) { - collect_leaves(c, s, r, scope, out); + collect_leaves(c, s, r, scope, whole_compounds, out); } } } _ => { - if touches(n, s, r, scope) { + if touches(n, s, r, scope, whole_compounds) { out.push(n.id); } } @@ -759,8 +979,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> continue; } if leaves { - collect_leaves(t, &symbols, r, scope, &mut out); - } else if touches(t, &symbols, r, scope) { + collect_leaves(t, &symbols, r, scope, whole_compounds, &mut out); + } else if touches(t, &symbols, r, scope, whole_compounds) { out.push(t.id); } } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 08503e8a7..452055a8f 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { } } +/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps +/// whose own reach holds the point, not every step of the evaluated blend. +#[test] +fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + // 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart. + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let count = |p: Point| { + LEAVES_TESTED.with(|c| c.set(0)); + let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf); + (h, LEAVES_TESTED.with(std::cell::Cell::get)) + }; + // On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it. + let (h, tested) = count(Point::new(450.0, 291.0)); + assert_eq!(h, Some(blend)); + assert!(tested < 20, "{tested} objects tested"); + // In the corner of the reach, outside every stroke: nothing hits. + let (h, tested) = count(Point::new(6.0, 291.0)); + assert_eq!(h, None); + assert!(tested < 20, "{tested} objects tested"); +} + +/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its +/// members. +#[test] +fn evaluated_art_hits_an_intertwine_group_by_its_members() { + let d = Document::new(200.0, 200.0); + let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art())); + let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] }); + let symbols = d.symbol_boxes(); + assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill)); + assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None); +} + +/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend +/// or a symbol of the document changes. +#[test] +fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0, 452.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 1); + // 100 steps 8.7 pt apart: a step still reaches (450, 291). + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) { + spec.spacing = crate::live::BlendSpacing::Steps(100); + } + assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + assert_eq!(evaluated(), before + 2); + let art = circle(d.alloc_id(), 0.0); + d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art }); + hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()); + assert_eq!(evaluated(), before + 3); + hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()); + assert_eq!(evaluated(), before + 3); +} + +/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band +/// evaluates it again. +#[test] +fn evaluated_art_past_the_kept_size_is_not_kept() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + // A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps. + let circle = |id, x: f64| { + let points: Vec = (0..2000) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 2000.0; + Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin()) + }) + .collect(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Arc::new(Node::path(id, path, stroked.clone())) + }; + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 2); +} + +/// While the document's symbols hold a placed document, whose art changes when its file is read, +/// each click in a blend's stroke band evaluates the blend again. +#[test] +fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let placed = crate::PlacedDocument { + link: crate::LinkInfo::new("/art/logo.vectorcraft"), + key: "logo".into(), + bounding: false, + page_box: crate::PageBox::Crop, + width: 80.0, + height: 40.0, + xf: vectorcraft_geom::Affine::IDENTITY, + placement: Default::default(), + }; + let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))); + d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art }); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 2); +} + +/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins +/// ([`LiveInputs`]) are as they were. +#[test] +fn a_kept_evaluation_is_stale_after_the_live_inputs_change() { + let d = Document::new(100.0, 100.0); + let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 }; + assert!(e.is_of(&d, &n, 7)); + assert!(!e.is_of(&d, &n, 8)); +} + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the /// clipping path and the art. @@ -841,7 +986,7 @@ fn objects_at_matches_repeated_hits_on_random_documents() { fn objects_at_finds_symbol_instances_by_their_art() { let mut d = Document::new(200.0, 200.0); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art()); - d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id()); let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) }); d.insert(Some(layer), 0, instance(top, 0.0)).unwrap(); @@ -975,3 +1120,223 @@ fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { d.node_mut(l).unwrap().visible = false; assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); } + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } +} + +/// A closed blob of `n` smooth anchors round `c` (radius about `r`), turning either way, with +/// handles long enough that its control points reach past its curves. +fn blob(rng: &mut Rng, c: Point, r: f64, n: usize, clockwise: bool) -> vectorcraft_geom::SubPath { + use vectorcraft_geom::{Anchor, Vec2}; + let anchors = (0..n) + .map(|i| { + let k = if clockwise { i as f64 } else { (n - i) as f64 }; + let a = k / n as f64 * std::f64::consts::TAU; + let rr = r * (0.6 + 0.8 * rng.next()); + let p = c + Vec2::new(a.cos(), a.sin()) * rr; + let t = Vec2::new(-a.sin(), a.cos()) * (if clockwise { 1.0 } else { -1.0 }) * rr * (0.2 + 1.3 * rng.next()); + Anchor::smooth(p, p + t) + }) + .collect(); + vectorcraft_geom::SubPath::new(anchors, true) +} + +/// A document with one compound path of `members` blobs (some of them holes), its id and the +/// members' paths. +fn compound_doc(members: usize, rule: vectorcraft_geom::FillRule, appearance: Appearance) -> (Document, NodeId, Vec) { + let mut d = Document::new(800.0, 800.0); + let l = d.layers[0].id; + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let mut paths = vec![]; + let mut children = vec![]; + for i in 0..members { + let c = Point::new(20.0 + rng.next() * 600.0, 20.0 + rng.next() * 600.0); + let r = 3.0 + rng.next() * 9.0; + let pd = vectorcraft_geom::PathData::single(blob(&mut rng, c, r, 3 + i % 4, i % 3 != 0)); + children.push(Arc::new(Node::path(d.alloc_id(), pd.clone(), Appearance::default()))); + paths.push(pd); + } + let id = d.alloc_id(); + let mut compound = Node::new(id, NodeKind::Compound { children, rule }); + compound.appearance = appearance.into(); + d.insert(Some(l), 0, compound).unwrap(); + (d, id, paths) +} + +/// What a click on compound path `n` gave before compound paths were culled by their control +/// points and their members near the click: the whole path assembled (`full`), culled by its +/// reach measured along the curves. +fn compound_hit_before(n: &Node, full: &vectorcraft_geom::BezPath, p: Point, opt: HitOptions) -> Option { + let NodeKind::Compound { rule, .. } = &n.kind else { return None }; + if !n.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } + if !opt.outline && n.appearance.stroke_hit(full, *rule, p, opt.tol) { + return Some(HitKind::Stroke); + } + if stroke_contains(full, 0.0, opt.tol, p) { + return Some(HitKind::Outline); + } + (!opt.outline && !opt.path_only && fill_contains(full, *rule, p)).then_some(HitKind::Fill) +} + +/// The points a test clicks for `paths`: random ones over them and past them, and, for some +/// members, their anchors, points just over the tolerance from them, and the corners and edge +/// midpoints of their control and curve boxes, on them and grown by the tolerance. +fn probe_points(paths: &[vectorcraft_geom::PathData], tol: f64, random: usize, every: usize) -> Vec { + let all = paths.iter().filter_map(vectorcraft_geom::PathData::control_bounds).reduce(|a, b| a.union(b)).unwrap().inflate(30.0, 30.0); + let mut rng = Rng(7); + let mut pts: Vec = (0..random).map(|_| Point::new(all.x0 + rng.next() * all.width(), all.y0 + rng.next() * all.height())).collect(); + for pd in paths.iter().step_by(every) { + for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) { + pts.extend([a.p, Point::new(a.p.x + tol, a.p.y), Point::new(a.p.x, a.p.y - tol), Point::new(a.p.x + tol * 1.0001, a.p.y)]); + } + for b in [pd.control_bounds().unwrap(), pd.bounds().unwrap()] { + for b in [b, b.inflate(tol, tol)] { + let (cx, cy) = (b.center().x, b.center().y); + pts.extend( + [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1), (cx, b.y0), (cx, b.y1), (b.x0, cy), (b.x1, cy)] + .map(|(x, y)| Point::new(x, y)), + ); + } + } + } + pts +} + +/// A compound path of thousands of members hits exactly as it did when it was assembled +/// whole for every click: at random points, on and next to anchors, and on the edges of the +/// members' boxes (control points' and curves', with and without the tolerance), for both fill +/// rules, in outline mode and with Object Selection by Path Only. +#[test] +fn culled_compound_hits_as_the_whole_path() { + use vectorcraft_geom::FillRule; + for (rule, opt) in [ + (FillRule::NonZero, HitOptions::default()), + (FillRule::EvenOdd, HitOptions { tol: 0.5, ..Default::default() }), + (FillRule::NonZero, HitOptions { outline: true, tol: 1.0, ..Default::default() }), + (FillRule::EvenOdd, HitOptions { path_only: true, ..Default::default() }), + ] { + let (d, id, paths) = compound_doc(2000, rule, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let n = d.node(id).unwrap(); + let full = n.stroke_path().unwrap(); + // The whole path's box grown by the tolerance. Before, a point on its far edges (right + // and bottom: `Rect::contains` leaves them out) never hit, even exactly `tol` from the + // outline; now it hits there as on the near edges. Those points are clicked too, and + // checked against the distance alone. + let far = vectorcraft_geom::Shape::bounding_box(&full).inflate(opt.tol, opt.tol); + let mut pts = probe_points(&paths, opt.tol, 200, 120); + let rightmost = paths.iter().filter_map(vectorcraft_geom::PathData::bounds).max_by(|a, b| a.x1.total_cmp(&b.x1)).unwrap(); + pts.push(Point::new(far.x1, rightmost.center().y)); + let (mut hits, mut kinds) = (0, HashSet::new()); + for p in pts { + let before = compound_hit_before(n, &full, p, opt); + let now = hit_test(&d, p, opt); + if p.x == far.x1 || p.y == far.y1 { + let outline = vectorcraft_geom::hit::distance_to_outline(&full, p) <= opt.tol; + assert_eq!(now.is_some(), outline || before.is_some(), "{rule:?} {opt:?} on the far edge at {p:?}"); + continue; + } + assert_eq!(now.as_ref().map(|h| (h.leaf, h.kind)), before.map(|k| (id, k)), "{rule:?} {opt:?} at {p:?}"); + hits += usize::from(before.is_some()); + kinds.extend(before.map(|k| format!("{k:?}"))); + } + // The points reach both hits and misses, and the outline as well as the fill. + assert!(hits > 100, "{rule:?} {opt:?}: {hits} hits"); + assert!(kinds.contains("Outline"), "{rule:?} {opt:?}: {kinds:?}"); + } +} + +/// A wide stroke paints far past a path's control points: clicks on it there still hit, for a +/// path and for a compound path (whose strokes are measured along all of it), exactly as when +/// they were culled by their reach measured along the curves. +#[test] +fn wide_strokes_hit_past_the_control_points() { + use vectorcraft_geom::{Anchor, PathData, SubPath}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 40.0); + // A zigzag 10 pt high whose 40 pt stroke reaches 20 pt (more at its mitred turns) past it. + let zig = PathData::single(SubPath::new( + [(100.0, 100.0), (110.0, 110.0), (120.0, 100.0), (130.0, 110.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), + false, + )); + let path = d.alloc_id(); + d.insert(Some(l), 0, Node::path(path, zig.clone(), stroke.clone())).unwrap(); + // A compound path of two small rings with the same stroke, further along. + let mut rng = Rng(3); + let members: Vec> = [Point::new(250.0, 250.0), Point::new(262.0, 250.0)] + .into_iter() + .map(|c| Arc::new(Node::path(d.alloc_id(), PathData::single(blob(&mut rng, c, 4.0, 4, true)), Appearance::default()))) + .collect(); + let compound = d.alloc_id(); + let mut node = Node::new(compound, NodeKind::Compound { children: members, rule: vectorcraft_geom::FillRule::NonZero }); + node.appearance = stroke.into(); + d.insert(Some(l), 9, node).unwrap(); + let symbols = d.symbol_boxes(); + let opt = HitOptions::default(); + for id in [path, compound] { + let n = d.node(id).unwrap(); + let full = n.stroke_path().unwrap(); + let control = match &n.kind { + NodeKind::Path { path, .. } => path.control_bounds().unwrap(), + _ => n.children().unwrap().iter().filter_map(|m| m.path_data()?.control_bounds()).reduce(|a, b| a.union(b)).unwrap(), + }; + let (mut outside, mut checked) = (0, 0); + let area = control.inflate(40.0, 40.0); + for i in 0..=80 { + for j in 0..=80 { + let p = Point::new(area.x0 + area.width() * f64::from(i) / 80.0, area.y0 + area.height() * f64::from(j) / 80.0); + let before = match &n.kind { + NodeKind::Path { .. } => n + .reach_bounds_with(&symbols) + .is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) + .then(|| hit_leaf(n, p, opt, &symbols)) + .flatten(), + _ => compound_hit_before(n, &full, p, opt), + }; + let now = hit_test(&d, p, opt).filter(|h| h.leaf == id).map(|h| h.kind); + assert_eq!(now, before, "{id:?} at {p:?}"); + checked += 1; + outside += usize::from(before == Some(HitKind::Stroke) && !control.contains(p)); + } + } + assert!(outside > 50, "{id:?}: {outside} of {checked} points hit the stroke outside the control points' box"); + } +} + +/// The box a click is culled by holds the object's reach for paths and compound paths (it is +/// measured by their control points, which hold their curves, and grown by the same stroke reach), +/// so culling by it never turns away a click the reach let through. (Up to rounding: where a curve's +/// extreme is an anchor, the two boxes measure the same point in different ways.) +#[test] +fn cull_box_holds_the_reach_of_paths_and_compound_paths() { + const E: f64 = 1e-9; + let holds = + |outer: Rect, inner: Rect| outer.x0 - E <= inner.x0 && outer.y0 - E <= inner.y0 && inner.x1 <= outer.x1 + E && inner.y1 <= outer.y1 + E; + let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 12.0); + let (mut d, id, _) = compound_doc(200, vectorcraft_geom::FillRule::NonZero, stroke.clone()); + let path_id = d.alloc_id(); + let symbols = d.symbol_boxes(); + let n = d.node(id).unwrap(); + let mut checked = 0; + for node in std::iter::once(n).chain(n.children().unwrap().iter().map(|m| &**m)) { + let (cull, reach) = (cull_reach(node, &symbols).unwrap(), node.reach_bounds_with(&symbols).unwrap()); + assert!(holds(cull, reach), "{:?}: {cull:?} does not hold {reach:?}", node.id); + checked += 1; + } + // A stroked plain path, its handles reaching past its curves. + let mut rng = Rng(11); + let path = Node::path(path_id, vectorcraft_geom::PathData::single(blob(&mut rng, Point::new(50.0, 50.0), 20.0, 5, true)), stroke); + let (cull, reach) = (cull_reach(&path, &symbols).unwrap(), path.reach_bounds_with(&symbols).unwrap()); + assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); + assert_eq!(checked, 201); +} diff --git a/crates/doc/src/intertwine.rs b/crates/doc/src/intertwine.rs index e22ccbbaf..8a6ab6786 100644 --- a/crates/doc/src/intertwine.rs +++ b/crates/doc/src/intertwine.rs @@ -1,36 +1,51 @@ //! Object › Intertwine: objects that pass over and under each other. //! //! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own -//! stacking order. A member can be drawn over the other members in part of the group: that area -//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group -//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for -//! each member with an area, a copy of it clipped to that area. Where several members are drawn -//! over the others in one place, the upper one in the stacking order ends on top. +//! stacking order. A member can be drawn over other members in part of the group, at a tier: the +//! area of tier 1 is the member's [`crate::node::NodeExtras::intertwine`], and the areas of tiers 2 +//! and up are its [`crate::node::NodeExtras::intertwine_tiers`], in document coordinates. In each +//! place the members are drawn by their tier there (0 outside their areas), and members of one +//! tier in their stacking order, so any order of the members can be set for each overlap area. +//! The group draws (and exports, and is hit) as its live expansion ([`expand`]): the members, +//! then, tier by tier, a copy of each member with an area of that tier clipped to it. //! //! The members stay editable, and Release gives them back as they were (the areas let go). //! Behaviour: . use std::sync::Arc; -use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{FillRule, PathData, Point}; use crate::{Node, NodeId, NodeKind}; -/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere). -pub fn over_area(n: &Node) -> Option<&PathData> { - n.extras().intertwine.as_deref().filter(|a| !a.is_empty()) +/// The areas where `n` is drawn over other members of its Intertwine group, each with its tier +/// (1 and up); empty ones are left out. +pub fn tier_areas(n: &Node) -> impl Iterator { + let e = n.extras(); + e.intertwine + .as_deref() + .map(|a| (1, a)) + .into_iter() + .chain(e.intertwine_tiers.iter().enumerate().map(|(k, a)| (k + 2, a))) + .filter(|(_, a)| !a.is_empty()) } -/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each -/// member with an area drawn over the others, as a clip group of that area around a copy of it -/// (ids 0: generated art). +/// The tier `n` is drawn at at `p`: the highest tier whose area holds `p` (0: none does). +pub fn tier_at(n: &Node, p: Point) -> usize { + tier_areas(n).filter(|(_, a)| fill_contains(&a.to_bezpath(), FillRule::NonZero, p)).map(|(t, _)| t).max().unwrap_or(0) +} + +/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then, for +/// each tier from 1 up, each member with an area of that tier, as a clip group of that area +/// around a copy of it (ids 0: generated art). pub fn expand(children: &[Arc]) -> Vec { let mut out: Vec = children.iter().map(|c| plain(c)).collect(); - for c in children { - let Some(area) = over_area(c) else { continue }; - if !c.visible { - continue; - } + let mut over: Vec<(usize, usize, &PathData)> = + children.iter().enumerate().filter(|(_, c)| c.visible).flat_map(|(i, c)| tier_areas(c).map(move |(t, a)| (t, i, a))).collect(); + over.sort_by_key(|(t, i, _)| (*t, *i)); + for (_, i, area) in over { + let Some(c) = children.get(i) else { continue }; let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false }); clip.appearance = crate::Appearance::default().into(); let mut copy = plain(c); @@ -40,11 +55,13 @@ pub fn expand(children: &[Arc]) -> Vec { out } -/// `n` without its Intertwine area (the expansion is plain art). +/// `n` without its Intertwine areas (the expansion is plain art). fn plain(n: &Node) -> Node { let mut n = n.clone(); - if n.extras().intertwine.is_some() { - n.extras_mut().intertwine = None; + if n.extras().intertwine.is_some() || !n.extras().intertwine_tiers.is_empty() { + let mut e = n.extras_mut(); + e.intertwine = None; + e.intertwine_tiers = Vec::new(); } n } @@ -88,6 +105,28 @@ mod tests { assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own"); } + #[test] + fn copies_are_drawn_tier_by_tier() { + let over = || shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0)); + // The lower member at tier 2, the upper one at tier 1: the lower one ends on top. + let mut a = (*square(1, 0.0)).clone(); + a.extras_mut().intertwine_tiers = vec![over()]; + let mut b = (*square(2, 5.0)).clone(); + b.extras_mut().intertwine = Some(Box::new(over())); + let kids = vec![Arc::new(a), Arc::new(b)]; + assert_eq!((tier_at(&kids[0], Point::new(7.0, 5.0)), tier_at(&kids[1], Point::new(7.0, 5.0))), (2, 1)); + assert_eq!(tier_at(&kids[0], Point::new(2.0, 5.0)), 0); + let out = expand(&kids); + assert_eq!(out.len(), 4, "both members, then the copy of tier 1, then the one of tier 2"); + assert!(out.iter().all(|n| n.extras().intertwine.is_none() && n.extras().intertwine_tiers.is_empty()), "the expansion is plain art"); + let copied = |n: &Node| match &n.kind { + NodeKind::Group { children, clip: true } => children[1].path_data().cloned(), + k => panic!("a clip group: {k:?}"), + }; + assert_eq!(copied(&out[2]).as_ref(), kids[1].path_data()); + assert_eq!(copied(&out[3]).as_ref(), kids[0].path_data()); + } + #[test] fn a_hidden_member_is_not_drawn_over_the_others() { let mut a = (*square(1, 0.0)).clone(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0b3c3f1fb..4ac86d1a5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -25,11 +25,16 @@ pub mod intertwine; pub mod shared; mod symbol_export_type; mod symbol_nine_slice; +pub mod symbol_overrides; mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; -pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice}; +pub use symbol_nine_slice::{ + EditedNineSlice, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, + charge_symbol_art, +}; +pub use symbol_overrides::SymbolOverride; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; @@ -74,8 +79,8 @@ pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ - Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile, - StrokeAlign, StrokeLayer, WidthProfile, + AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, + SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use compound_shape::ShapeMode; @@ -105,10 +110,12 @@ pub use selection::{AnchorRef, SegmentRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; +pub use text::FontHeightReference; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, - RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, + RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, + WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; @@ -139,7 +146,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; #[cfg(test)] use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; -pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -219,6 +226,10 @@ pub struct Document { /// still read so files saved mid-edit by older versions load without the editing layer. #[serde(default, skip_serializing)] pub mask_edit: Option, + /// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the + /// symbol already holds the art of its editing layer. + #[serde(default, skip_serializing)] + pub symbol_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "skip::is_empty_map")] diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 6a29b5b9e..9a2aa4fea 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -63,7 +63,7 @@ pub use expand::{ }; #[cfg(test)] use mesh::grid_eval; -pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; +pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; #[cfg(test)] @@ -181,6 +181,44 @@ mod tests { assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}"); } + /// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the + /// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory + /// does, and each patch stays at least one quad. + #[test] + fn a_large_mesh_tessellates_within_the_quad_budget() { + let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5)); + // 3,600 patches at 32 x 32 would be 3.7 million quads. + let m = mesh(60); + let quads = m.quads(32); + assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len()); + assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len()); + // A small mesh keeps the subdivision asked for. + assert_eq!(mesh(3).quads(8).len(), 9 * 64); + // The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch. + let big = mesh(150).quads(64); + assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len()); + // More patches than the budget has quads is malformed, as a point count that doesn't fit is. + let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] }; + let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] }; + assert!(!huge.is_valid()); + assert!(huge.quads(1).is_empty()); + } + + /// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`] + /// gives way to: each patch's pieces are its own quads', so together they cover the mesh once. + #[test] + fn a_large_meshs_pieces_follow_the_quad_budget() { + let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5)); + let area = |pts: &[Point]| { + let n = pts.len(); + (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::().abs() / 2.0 + }; + let pieces = m.pieces(32); + assert!(pieces.len() >= 3600, "{} pieces", pieces.len()); + let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum(); + assert!((total - 90_000.0).abs() < 90.0, "covered {total}"); + } + /// A file's rows and columns can be anything: a point count too large to compute is no match /// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points). #[test] @@ -195,17 +233,86 @@ mod tests { assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p); } + /// How far the border of `m` strays from the ellipse in `r`, in points (measured across the + /// ellipse's short radius), checked at 20 places along every border edge. + fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 { + let (rows, cols) = (m.rows as usize, m.cols as usize); + let edges = (0..cols).flat_map(|c| [m.edge_u(0, c), m.edge_u(rows, c)]).chain((0..rows).flat_map(|k| [m.edge_v(k, 0), m.edge_v(k, cols)])); + let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); + edges + .flat_map(|e| (0..=20).map(move |i| e.eval(f64::from(i) / 20.0))) + .map(|p| ((p.x - c.x) / a).hypot((p.y - c.y) / b) - 1.0) + .fold(0.0f64, |w, d| w.max(d.abs() * a.min(b))) + } + + /// #3187: a gradient mesh made from an ellipse keeps the ellipse's shape: a Mesh tool click + /// (a 1×1 mesh with lines through the point) and Create Gradient Mesh's 4×4 grid both lay + /// their border on the outline, within about twice the 0.35 pt the outline is flattened to. + /// The 1×1 mesh was up to 7 pt off this 400 × 300 pt ellipse. + #[test] + fn a_mesh_from_an_ellipse_keeps_its_outline() { + const OFF: f64 = 0.75; + let r = Rect::new(100.0, 100.0, 500.0, 400.0); + let e = shapes::ellipse(r); + for (rows, cols) in [(1, 1), (4, 4), (2, 5)] { + let m = GradientMesh::for_path(&e, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let off = mesh_border_off_ellipse(&m, r); + assert!(off < OFF, "{rows}×{cols} mesh: border {off} pt off the ellipse"); + } + let made = GradientMesh::for_path(&e, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let mut m = made.clone(); + // Off centre, so each line splits its edges unevenly. + let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap(); + let off = mesh_border_off_ellipse(&m, r); + assert!(off < OFF, "after a Mesh tool click: {off} pt"); + // Deleting the lines again (Alt-click) gives the border back as it was. + assert!(m.remove_point_lines(i)); + assert_eq!((m.rows, m.cols), (1, 1)); + for (k, (now, was)) in m.points.iter().zip(&made.points).enumerate() { + let moved = now.handles.iter().zip(&was.handles).map(|(a, b)| (*a - *b).hypot()).fold(0.0, f64::max); + assert!(now.p.distance(was.p) < 1e-6 && moved < 1e-3, "point {k}: {now:?}, made as {was:?}"); + } + // With a handle at the new border point shortened since, the two edges no longer make one + // curve: the merged edge is straight rather than grown past its ends. + let mut m = made.clone(); + let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap(); + let top = m.idx(0, 1); + m.points[top].handles[H_LEFT] *= 1e-4; + assert!(m.remove_point_lines(i)); + let (a, b) = (m.points[0].p, m.points[1].p); + assert!((m.points[0].handles[H_RIGHT] - (b - a) / 3.0).hypot() < 1e-9, "{:?}", m.points[0].handles); + // A rectangle's mesh keeps straight edges with handles a third of the way along. + let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 60.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let e = m.edge_u(0, 0); + assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}"); + } + #[test] fn mesh_insert_and_remove_lines() { let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); - let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap(); + let i = m.add_lines_at(Point::new(30.0, 60.0), 0.0).unwrap(); assert_eq!((m.rows, m.cols), (2, 2)); assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p); assert!(m.remove_point_lines(i)); assert_eq!((m.rows, m.cols), (1, 1)); } + /// A point outside the mesh within the tolerance locates the nearest point of its edge. + #[test] + fn mesh_locate_within_reaches_the_edge() { + let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let p = Point::new(102.0, 30.0); + assert_eq!(m.locate(p), None); + assert_eq!(m.locate_within(p, 1.0), None); + let (r, c, u, v) = m.locate_within(p, 3.0).unwrap(); + assert_eq!((r, c), (0, 1)); + assert_eq!(u, 1.0, "on the right edge"); + let q = m.eval(r, c, u, v); + assert!(q.distance(Point::new(100.0, 30.0)) < 1e-6, "{q:?}"); + assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0))); + } + /// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB. #[test] fn a_cmyk_mesh_mixes_its_inks() { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index c67dbee3e..66d20f27b 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool { ) } -/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. +/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined +/// where the colour hardly changes ([`GradientMesh::pieces`]). pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { - m.quads(n) + m.pieces(n) .into_iter() .map(|q| { let mut ap = Appearance::default(); diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index dbbfc66bd..a87cbd9b4 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -35,6 +35,15 @@ pub struct MeshQuad { pub corners: [(Color, f32); 4], } +/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through +/// tessellation points, its colour and opacity. +#[derive(Clone, Debug, PartialEq)] +pub struct MeshPiece { + pub pts: Vec, + pub color: Color, + pub opacity: f32, +} + /// Create Gradient Mesh → Appearance. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MeshAppearance { @@ -157,6 +166,76 @@ impl Coons { // Gradient meshes // ===================================================================================== +/// The handles, as offsets from `ends`, of the cubic from `ends.0` to `ends.1` that comes closest +/// (least squares) to `side` between `span.0` and `span.1`; `None` when they can't be computed. +/// Each point of the side is matched to the curve's nearest point, refined over a few rounds. +fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(Vec2, Vec2)> { + const N: usize = 32; + const ROUNDS: usize = 6; + let (p0, p3) = (ends.0, ends.1); + let qs: Vec = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect(); + let mut ts: Vec = (1..N).map(|i| i as f64 / N as f64).collect(); + let mut fit = None; + for _ in 0..ROUNDS { + let (mut c11, mut c12, mut c22) = (0.0, 0.0, 0.0); + let (mut x1, mut x2) = (Vec2::ZERO, Vec2::ZERO); + for (q, &t) in qs.iter().zip(&ts) { + let s = 1.0 - t; + let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t); + let r = q.to_vec2() - b0 * p0.to_vec2() - b3 * p3.to_vec2(); + c11 += b1 * b1; + c12 += b1 * b2; + c22 += b2 * b2; + x1 += b1 * r; + x2 += b2 * r; + } + let det = c11 * c22 - c12 * c12; + if det.abs() < 1e-12 { + break; + } + let (p1, p2) = (((c22 * x1 - c12 * x2) / det).to_point(), ((c11 * x2 - c12 * x1) / det).to_point()); + if !(p1.is_finite() && p2.is_finite()) { + break; + } + fit = Some((p1 - p0, p2 - p3)); + // One Newton step towards the nearest point of the curve for each point of the side. + let c = CubicBez::new(p0, p1, p2, p3); + let (a, b, k) = (p1 - p0, p2 - p1, p3 - p2); + for (q, t) in qs.iter().zip(ts.iter_mut()) { + let s = 1.0 - *t; + let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k); + let acc = 6.0 * (s * (b - a) + *t * (k - b)); + let e = c.eval(*t) - *q; + let den = v.hypot2() + e.dot(acc); + if den.abs() > 1e-12 { + *t = (*t - e.dot(v) / den).clamp(0.0, 1.0); + } + } + } + fit +} + +/// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles +/// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer +/// ends: each outer handle grown back by the share of the curve its edge had, which undoes the +/// split [`GradientMesh::insert_row`] and [`GradientMesh::insert_col`] make, so deleting a mesh +/// line keeps the curve of the border it crossed (#3187). The share is the split parameter, read +/// from the lengths of the middle handles. When the edges no longer fit one cubic (a point or a +/// handle was moved since the split), the cubic grown back would not pass through the middle +/// point, and the merged edge is straight. +fn merged_handles(a: (Point, Vec2), mid: (Point, Vec2, Vec2), b: (Point, Vec2)) -> (Vec2, Vec2) { + let straight = ((b.0 - a.0) / 3.0, (a.0 - b.0) / 3.0); + let (la, lb) = (mid.1.hypot(), mid.2.hypot()); + let t = la / (la + lb); + if !(0.01..=0.99).contains(&t) { + return straight; + } + let (ha, hb) = (a.1 / t, b.1 / (1.0 - t)); + let through = CubicBez::new(a.0, a.0 + ha, b.0 + hb, b.0).eval(t); + let tol = 1e-3 * (a.0.distance(mid.0) + mid.0.distance(b.0)).max(1e-9); + if ha.is_finite() && hb.is_finite() && through.distance(mid.0) <= tol { (ha, hb) } else { straight } +} + /// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. fn even_steps(n: u32) -> Vec { let n = n.clamp(1, 200); @@ -170,11 +249,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option { } impl GradientMesh { + /// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can + /// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches + /// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh + /// with more patches than that is malformed, like a point count that doesn't fit (#3592). + pub const MAX_QUADS: usize = 1 << 20; + pub fn idx(&self, r: usize, c: usize) -> usize { r * (self.cols as usize + 1) + c } pub fn is_valid(&self) -> bool { - self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) + self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS + } + + /// The number of patches (rows times columns), saturating. + fn patches(&self) -> usize { + (self.rows as usize).saturating_mul(self.cols as usize) } /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v @@ -263,7 +353,49 @@ impl GradientMesh { /// colour and opacity `color_at` gives at its position (a gradient fill's, say). pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { let coons = Coons::from_path(path)?; - Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p))) + let (us, vs) = (even_steps(cols), even_steps(rows)); + let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)); + m.fit_border(&coons, &us, &vs); + Some(m) + } + + /// Give each edge on the mesh's border the handles that make it follow the outline it stands + /// for, in place of the handles [`Self::from_grid`] estimates from the surface's derivatives + /// (#3187). Where two border edges meet along a side, their handles are made collinear, so the + /// border stays smooth there; the four corners keep the handles their own edges were fitted with. + fn fit_border(&mut self, coons: &Coons, us: &[f64], vs: &[f64]) { + let (rows, cols) = (self.rows as usize, self.cols as usize); + if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 { + return; + } + let edges = [(0, &coons.top), (rows, &coons.bottom)] + .map(|(r, side)| (r, side, true)) + .into_iter() + .chain([(0, &coons.left), (cols, &coons.right)].map(|(c, side)| (c, side, false))); + for (line, side, along_u) in edges { + let (n, steps) = if along_u { (cols, us) } else { (rows, vs) }; + for k in 0..n { + let (a, b, out, back) = if along_u { + (self.idx(line, k), self.idx(line, k + 1), H_RIGHT, H_LEFT) + } else { + (self.idx(k, line), self.idx(k + 1, line), H_DOWN, H_UP) + }; + if let Some((h1, h2)) = fit_edge(side, (steps[k], steps[k + 1]), (self.points[a].p, self.points[b].p)) { + self.points[a].handles[out] = h1; + self.points[b].handles[back] = h2; + } + } + for k in 1..n { + let (i, out, back) = if along_u { (self.idx(line, k), H_RIGHT, H_LEFT) } else { (self.idx(k, line), H_DOWN, H_UP) }; + let h = &mut self.points[i].handles; + let (fwd, bwd) = (h[out], h[back]); + let dir = (fwd.normalize() - bwd.normalize()).normalize(); + if fwd.hypot2() > 0.0 && bwd.hypot2() > 0.0 && dir.is_finite() { + h[out] = dir * fwd.hypot(); + h[back] = -dir * bwd.hypot(); + } + } + } } /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to @@ -347,13 +479,20 @@ impl GradientMesh { (Color::rgb(rgb[0], rgb[1], rgb[2]), op) } - /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. + /// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser + /// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least). + fn subdivision(&self, n: usize) -> usize { + n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1) + } + + /// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre + /// and its corners. pub fn quads(&self, n: usize) -> Vec { if !self.is_valid() { return vec![]; } - let n = n.clamp(1, 64); - let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); + let n = self.subdivision(n); + let mut out = Vec::with_capacity(self.patches() * n * n); let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; for r in 0..self.rows as usize { @@ -383,6 +522,105 @@ impl GradientMesh { out } + /// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG, + /// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads + /// joined into square blocks as large as keeps the colour across each block within about one + /// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps + /// all its quads. A piece's outline runs through its quads' outer corners, so it covers what + /// they covered, and it takes the colour at its centre. + pub fn pieces(&self, n: usize) -> Vec { + let n = self.subdivision(n); + let quads = self.quads(n); + let cols = (self.cols as usize).max(1); + let mut out = Vec::with_capacity(quads.len()); + for (k, patch) in quads.chunks_exact(n * n).enumerate() { + let (r, c) = (k / cols, k % cols); + let side = self.block_side(r, c, n, patch); + if side == 1 { + out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity })); + continue; + } + // Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of. + let at = |i: usize, j: usize| -> Option { + let (qi, qj) = (i.min(n - 1), j.min(n - 1)); + let q = patch.get(qj * n + qi)?; + q.pts + .get(match (i > qi, j > qj) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + }) + .copied() + }; + for j0 in (0..n).step_by(side) { + for i0 in (0..n).step_by(side) { + let (i1, j1) = (i0 + side, j0 + side); + // Around the block, in the quads' order: along its top, down its right side, + // back along its bottom and up its left side. + let ring = (i0..i1) + .map(|i| (i, j0)) + .chain((j0..j1).map(|j| (i1, j))) + .chain((i0 + 1..=i1).rev().map(|i| (i, j1))) + .chain((j0 + 1..=j1).rev().map(|j| (i0, j))); + let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::>>() else { continue }; + let half = side as f64 / 2.0; + let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64); + out.push(MeshPiece { pts, color, opacity }); + } + } + } + out + } + + /// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into + /// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one + /// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline + /// of a block across the fold wouldn't fill it as its quads do. + fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize { + let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc)); + let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else { + return 1; + }; + // Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too. + let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. })); + let channels = |p: &MeshPoint| -> Vec { + let mut v = p.color.to_rgb().to_vec(); + if inks && let Color::Cmyk { c, m, y, k } = p.color { + v.extend([c, m, y, k]); + } + v.push(p.opacity); + v + }; + let [a, b, cc, d] = [p00, p01, p10, p11].map(channels); + // A bilinear patch changes across a block of side h (0..1 of the patch) by at most + // h × (du + dv) in each channel, du and dv its largest change along each parameter. + let mut steps = 0.0f32; + for k in 0..a.len() { + let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default(); + let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs()); + let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs()); + steps = steps.max((du + dv) * 255.0); + } + if !steps.is_finite() { + return 1; + } + let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1); + if side > 1 { + // Every quad turning the same way: the patch doesn't fold, so a block's outline + // encloses just what its quads cover. + let turn = |q: &MeshQuad| { + let [a, b, c, d] = q.pts; + (b - a).cross(c - a) + (c - a).cross(d - a) + }; + let first = quads.first().map(turn).unwrap_or_default(); + if !quads.iter().all(|q| turn(q) * first > 0.0) { + return 1; + } + } + side + } + /// The mesh's outer boundary as a closed path. pub fn outline(&self) -> PathData { if !self.is_valid() { @@ -466,12 +704,21 @@ impl GradientMesh { /// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh). pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> { + self.locate_within(p, 0.0) + } + + /// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the + /// patch and local (u, v) of the nearest point of the mesh's edge. + pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> { if !self.is_valid() { return None; } let bp = self.outline().to_bezpath(); if bp.winding(p) == 0 { - return None; + if tol.is_nan() || tol <= 0.0 { + return None; + } + return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); } const N: usize = 16; let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); @@ -508,6 +755,30 @@ impl GradientMesh { Some((r, c, u, v)) } + /// The point of the mesh's outer edge nearest `p`: its squared distance from `p`, and its + /// boundary patch and local (u, v). + fn nearest_edge(&self, p: Point) -> Option<(f64, usize, usize, f64, f64)> { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut best: Option<(f64, usize, usize, f64, f64)> = None; + let mut take = |e: CubicBez, at: (usize, usize, Option, Option)| { + let n = e.nearest(p, 1e-9); + if best.is_none_or(|b| n.distance_sq < b.0) { + let (r, c, u, v) = at; + best = Some((n.distance_sq, r, c, u.unwrap_or(n.t), v.unwrap_or(n.t))); + } + }; + for c in 0..cols { + take(self.edge_u(0, c), (0, c, None, Some(0.0))); + take(self.edge_u(rows, c), (rows - 1, c, None, Some(1.0))); + } + for r in 0..rows { + take(self.edge_v(r, 0), (r, 0, Some(0.0), None)); + take(self.edge_v(r, cols), (r, cols - 1, Some(1.0), None)); + } + best + } + /// Index of the mesh point within `tol` of `p`. pub fn point_near(&self, p: Point, tol: f64) -> Option { self.points @@ -595,9 +866,10 @@ impl GradientMesh { self.cols += 1; } - /// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index. - pub fn add_lines_at(&mut self, p: Point) -> Option { - let (r, c, u, v) = self.locate(p)?; + /// Mesh tool click: add a row and a column through `p` (inside the mesh, or outside within + /// `tol` of its edge, through the nearest point of the edge). Returns the new point index. + pub fn add_lines_at(&mut self, p: Point, tol: f64) -> Option { + let (r, c, u, v) = self.locate_within(p, tol)?; let mut rr = r; let mut cc = c; let row_new = v > 0.02 && v < 0.98; @@ -632,10 +904,11 @@ impl GradientMesh { let above = self.idx(r - 1, cc); let below = self.idx(r + 1, cc); let mid = self.idx(r, cc); - let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p); - let _ = pm; - self.points[above].handles[H_DOWN] = (pb - pa) / 3.0; - self.points[below].handles[H_UP] = (pa - pb) / 3.0; + let (pa, pb, m) = (self.points[above].p, self.points[below].p, &self.points[mid]); + let hm = (m.p, m.handles[H_UP], m.handles[H_DOWN]); + let (down, up) = merged_handles((pa, self.points[above].handles[H_DOWN]), hm, (pb, self.points[below].handles[H_UP])); + self.points[above].handles[H_DOWN] = down; + self.points[below].handles[H_UP] = up; } self.points.drain(r * w..(r + 1) * w); self.rows -= 1; @@ -646,9 +919,11 @@ impl GradientMesh { let rows = self.rows as usize; for rr in 0..=rows { let (l, rt) = (rr * w + c - 1, rr * w + c + 1); - let (pl, pr) = (self.points[l].p, self.points[rt].p); - self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0; - self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0; + let (pl, pr, m) = (self.points[l].p, self.points[rt].p, &self.points[rr * w + c]); + let hm = (m.p, m.handles[H_LEFT], m.handles[H_RIGHT]); + let (right, left) = merged_handles((pl, self.points[l].handles[H_RIGHT]), hm, (pr, self.points[rt].handles[H_LEFT])); + self.points[l].handles[H_RIGHT] = right; + self.points[rt].handles[H_LEFT] = left; } let mut k = 0; self.points.retain(|_| { diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a971a383b..a1268505f 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -253,6 +253,10 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize); b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::() + path_bytes(a)); + if e.intertwine_tiers.capacity() > 0 { + b += ALLOC + e.intertwine_tiers.capacity() * size_of::() + e.intertwine_tiers.iter().map(path_bytes).sum::(); + } + b += e.symbol_overrides.capacity() * size_of::(); b += e.objects_on_path.as_deref().map_or(0, |o| { ALLOC + size_of::() + o.members.capacity() * size_of::() }); diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 633f9b696..239dce93c 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -44,6 +44,11 @@ pub struct NodeExtras { /// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates, /// filled non-zero) where this object is drawn over the group's other members. pub intertwine: Option>, + /// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates, + /// filled non-zero) where this object is drawn over members that are themselves drawn over + /// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1 + /// (see [`crate::intertwine`]). + pub intertwine_tiers: Vec, /// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults). pub live_paint_gaps: Option>, /// Object › Objects on Path: the group's path and the objects attached to it (see @@ -58,6 +63,9 @@ pub struct NodeExtras { pub compound_shape: bool, /// As a member of a compound shape, how this object combines with the members below it. pub shape_mode: crate::ShapeMode, + /// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's + /// art (see [`crate::symbol_overrides`]); empty for none. + pub symbol_overrides: Vec, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -76,11 +84,13 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { perspective: None, dimension: None, intertwine: None, + intertwine_tiers: Vec::new(), live_paint_gaps: None, objects_on_path: None, colorize: None, compound_shape: false, shape_mode: crate::ShapeMode::Add, + symbol_overrides: Vec::new(), }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index cdbdeec27..07899196c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -185,8 +185,13 @@ impl Node { match &self.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), NodeKind::Compound { children, .. } => { - let mut bp = vectorcraft_geom::BezPath::new(); - children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath())); + // Sized up front: grown by doubling, the path of a compound path of hundreds of + // thousands of anchors is copied into ever larger buffers, up to tens of MB, on + // every call. + let subpaths = || children.iter().filter_map(|c| c.path_data()).flat_map(|pd| &pd.subpaths); + let elements = subpaths().filter(|sp| !sp.anchors.is_empty()).map(|sp| 1 + sp.segment_count() + usize::from(sp.closed)).sum(); + let mut bp = vectorcraft_geom::BezPath::from_vec(Vec::with_capacity(elements)); + subpaths().for_each(|sp| sp.to_bezpath_into(&mut bp)); Some(bp) } _ => None, @@ -198,7 +203,8 @@ impl Node { /// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in /// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers /// without a clipping mask and Intertwine groups take in their members' boxes, grown by their - /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. + /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box + /// takes in where its spine can put its keys and steps. pub fn reach_bounds(&self) -> Option { self.reach(None) } @@ -234,6 +240,11 @@ impl Node { let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) }), + // Keys and steps placed on a spine, turned to it with Align to Path, can reach past the + // keys and the spine. + NodeKind::Blend { children, spec } => { + vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols)) + } _ => self.visual(symbols), } } @@ -387,6 +398,12 @@ impl Node { self.transform_scaled(a, &scaling.into()); } + /// The path of the child an Objects on Path group's objects are attached to. + fn on_path_data(&self) -> Option<&PathData> { + let r = self.extras().objects_on_path.as_deref()?; + self.children()?.iter().find(|c| c.id == r.path)?.path_data() + } + fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { // A dimension's measurement moves with it. if let Some(e) = self.extras_if_any() @@ -394,12 +411,8 @@ impl Node { { dimension.transform(a); } - // The frames of the objects attached to a path move with them. - if let Some(e) = self.extras_if_any() - && let Some(on_path) = &mut e.objects_on_path - { - on_path.transform(a); - } + // The path the objects are attached to, before it is transformed. + let on_path_before = self.on_path_data().cloned(); if !self.is_layer() { self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); } @@ -490,14 +503,27 @@ impl Node { NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), } + // The frames of the objects attached to a path move with them, and the distances along the + // path follow the path as it is now (a live shape's path is regenerated). + let on_path_after = on_path_before.as_ref().and_then(|_| self.on_path_data().cloned()); + if let Some(e) = self.extras_if_any() + && let Some(on_path) = &mut e.objects_on_path + { + on_path.transform(a, on_path_before.as_ref().zip(on_path_after.as_ref())); + } if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) && m.linked { Arc::make_mut(&mut m.art).transform_scaled(a, sc); } // The area where an Intertwine member is drawn over the others moves with it. - if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) { - area.transform(a); + if let Some(e) = self.extras_if_any() { + if let Some(area) = e.intertwine.as_mut() { + area.transform(a); + } + for area in &mut e.intertwine_tiers { + area.transform(a); + } } } @@ -683,9 +709,15 @@ impl Node { impl Node { /// Whether this object shows any transparency, itself or anything inside it: object, fill or - /// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient - /// stops. Hidden objects show none. + /// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient + /// stops or a colorized image. Hidden objects show none. pub fn shows_transparency(&self) -> bool { + self.shows_transparency_with(&|_| true) + } + + /// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize` + /// accepts it, for files that write some colorized images in their colorize paint (EPS). + pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { use vectorcraft_color::Paint; let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); @@ -693,13 +725,14 @@ impl Node { && (self.opacity < 1.0 || self.blend != BlendMode::Normal || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) + || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) || effects(&self.appearance.effects) || self .appearance .items .iter() .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) - || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency()))) + || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) } } diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index dc241841f..1669de1a2 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -241,6 +241,34 @@ fn uneven_scales_keep_live_corners_circular() { assert_round_corners(&n, Affine::IDENTITY, 40.0); } +/// Objects attached to a rounded rectangle that keeps its corners when scaled stay where the scale +/// takes them on the regenerated path, which is not twice as long when twice the size. +#[test] +fn objects_on_a_live_rectangle_follow_its_regenerated_path() { + use crate::objects_on_path::{CENTER, ObjectsOnPath, stops}; + let path = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); + let objects: Vec = + (2..5).map(|i| Node::path(NodeId(i), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default())).collect(); + let members: Vec<&Node> = objects.iter().collect(); + let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &members, CENTER, 0.0) }; + let mut children = vec![Arc::new(path)]; + children.extend(objects.iter().cloned().map(Arc::new)); + let mut g = Node::new(NodeId(9), NodeKind::Group { children, clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + let path_of = |g: &Node| g.children().and_then(|c| c.first()).and_then(|p| p.path_data().cloned()).unwrap(); + let rec_of = |g: &Node| g.extras().objects_on_path.as_deref().cloned().unwrap(); + let r = rec_of(&g); + let before = stops(&path_of(&g), 3, r.spacing, r.start).unwrap(); + let a = Affine::scale(2.0); + g.transform(a, KEEP_CORNERS); + assert_round_corners(&g.children().unwrap()[0], Affine::IDENTITY, 20.0); + let r = rec_of(&g); + let after = stops(&path_of(&g), 3, r.spacing, r.start).unwrap(); + for ((p, _), (q, _)) in before.iter().zip(&after) { + assert!((a * *p).distance(*q) < 1e-6, "{p:?} went to {q:?}: {r:?}"); + } +} + #[test] fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); @@ -339,6 +367,35 @@ fn transform_group_recurses() { assert_eq!(child.appearance.stroke_width(), 2.0); } +/// A symbol instance in perspective keeps its picture moved by a transform, and loses its +/// projection when no invertible map of finite numbers draws the moved picture. +#[test] +fn a_projection_follows_the_transform_or_is_removed() { + use vectorcraft_geom::{Homography, Point}; + let instance = |h: Homography| { + let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); + n.extras_mut().perspective = + Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) })); + n + }; + let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection); + // A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down. + let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + let mut n = instance(big); + n.transform(Affine::scale(10.0), false); + let p = Point::new(3.0, 7.0); + assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9); + // A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0). + let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let mut n = instance(far); + n.transform(Affine::scale(1e150), false); + assert_eq!(projection(&n), None); + // A transform that is not invertible leaves the projection as it is. + let mut n = instance(far); + n.transform(Affine::scale(0.0), false); + assert_eq!(projection(&n), Some(far.to_array())); +} + #[test] fn unplaced_gradients_pin_only_when_a_refit_would_differ() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; @@ -455,6 +512,23 @@ fn objects_are_read_within_the_node_budget() { assert!(with_node_budget(3, read).is_ok()); } +/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts +/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused. +#[test] +fn intertwine_tiers_are_read_within_the_node_budget() { + let mut n = Node::group(NodeId(1), vec![]); + let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + n.extras_mut().intertwine_tiers = vec![PathData::default(), area]; + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains("\"intertwine_tiers\""), "{json}"); + let read = || serde_json::from_str::(&json); + assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three"); + let why = with_node_budget(2, read).unwrap_err().to_string(); + assert!(why.contains("more than 2 objects"), "{why}"); + let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap(); + assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}"); +} + /// #3587: fields read before an object's `type` are buffered value by value within /// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers /// its own fields again without counting them twice. VectorCraft writes `type` first, which @@ -472,3 +546,23 @@ fn fields_before_the_type_are_buffered_within_the_value_budget() { assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}"); assert!(read(reordered).is_ok(), "no budget outside a read"); } + +/// A compound path's path is its members' paths one after another, open and closed, empty +/// members and single anchors included. +#[test] +fn compound_stroke_path_is_its_members_in_order() { + use vectorcraft_geom::{Anchor, PathData, SubPath}; + let open = PathData::single(SubPath::new([(0.0, 0.0), (5.0, 1.0), (9.0, 7.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), false)); + let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(3.0, 3.0))], false)); + let members = + [shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), open, PathData::default(), lone, shapes::ellipse(Rect::new(10.0, 10.0, 20.0, 16.0))]; + let mut expected = vectorcraft_geom::BezPath::new(); + for pd in &members { + expected.extend(pd.to_bezpath()); + } + let children = members.iter().enumerate().map(|(i, pd)| Arc::new(Node::path(NodeId(i as u64 + 1), pd.clone(), Appearance::default()))).collect(); + let n = Node::new(NodeId(99), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::EvenOdd }); + let bp = n.stroke_path().unwrap(); + assert_eq!(bp.elements(), expected.elements()); + assert!(!bp.elements().is_empty()); +} diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 76d4c3c14..ed81b1391 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -46,11 +46,13 @@ impl Serialize for Node { e.perspective.is_none(), e.dimension.is_none(), e.intertwine.is_none(), + e.intertwine_tiers.is_empty(), e.live_paint_gaps.is_none(), e.objects_on_path.is_none(), e.colorize.is_none(), is_default(&e.compound_shape), is_default(&e.shape_mode), + e.symbol_overrides.is_empty(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -85,11 +87,13 @@ impl Serialize for Node { field!("perspective", &e.perspective); field!("dimension", &e.dimension); field!("intertwine", &e.intertwine); + field!("intertwine_tiers", &e.intertwine_tiers); field!("live_paint_gaps", &e.live_paint_gaps); field!("objects_on_path", &e.objects_on_path); field!("colorize", &e.colorize); field!("compound_shape", &e.compound_shape); field!("shape_mode", &e.shape_mode); + field!("symbol_overrides", &e.symbol_overrides); st.end() } } @@ -123,11 +127,39 @@ pub fn with_node_budget(max: usize, f: impl FnOnce() -> T) -> T { /// each counts once, and the id usually comes first, before the node's children are read. fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let id = NodeId::deserialize(d)?; + count_one()?; + Ok(id) +} + +/// Count one object against [`with_node_budget`]. +fn count_one() -> Result<(), E> { if let Some((left, max)) = NODES_LEFT.get() { - let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?; + let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?; NODES_LEFT.set(Some((left, max))); } - Ok(id) + Ok(()) +} + +/// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol +/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed +/// file of repeated empty items stays small while they take memory once read. +fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + struct Items(std::marker::PhantomData); + impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(item) = seq.next_element::()? { + count_one()?; + out.push(item); + } + Ok(out) + } + } + d.deserialize_seq(Items(std::marker::PhantomData)) } /// How a [`Node`] is read: the flat field list of the file format. Fields move into the node @@ -186,6 +218,8 @@ pub(super) struct NodeWire { dimension: Option>, #[serde(default)] intertwine: Option>, + #[serde(default, deserialize_with = "counted_list")] + intertwine_tiers: Vec, #[serde(default)] live_paint_gaps: Option>, #[serde(default)] @@ -196,6 +230,8 @@ pub(super) struct NodeWire { compound_shape: bool, #[serde(default)] shape_mode: crate::ShapeMode, + #[serde(default, deserialize_with = "counted_list")] + symbol_overrides: Vec, } impl From for Node { @@ -215,11 +251,13 @@ impl From for Node { perspective: w.perspective, dimension: w.dimension, intertwine: w.intertwine, + intertwine_tiers: w.intertwine_tiers, live_paint_gaps: w.live_paint_gaps, objects_on_path: w.objects_on_path, colorize: w.colorize, compound_shape: w.compound_shape, shape_mode: w.shape_mode, + symbol_overrides: w.symbol_overrides, }; Node { id: w.id, diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index ca2ec7dd9..67c9c0ee8 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -5,8 +5,9 @@ //! the other children are the attached objects, kept where they are drawn: the group draws, hits, //! saves and exports as a group. The record lists the objects in their order along the path, each //! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them -//! evenly along the path from its first anchor (the first object sits on that anchor, and the last -//! one sits on the end of an open path), puts the attachment point of each one +//! along the path ([`stops`]): evenly from its first anchor by default (the first object sits on +//! that anchor, and the last one sits on the end of an open path), or [`ObjectsOnPath::spacing`] +//! apart from [`ObjectsOnPath::start`]. It puts the attachment point of each one //! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there, //! tilted by [`ObjectsOnPath::rotate`]. //! @@ -45,6 +46,14 @@ pub struct ObjectsOnPath { /// upright object has its frame's x axis along the path's direction at its attachment point. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub rotate: f64, + /// Space: the distance along the path (points) from each object's attachment point to the + /// next one's. `None` (Auto) spreads the objects evenly ([`layout`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spacing: Option, + /// The distance along the path (points) from its first anchor to the first object's + /// attachment point. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub start: f64, } /// An object attached to the path of an Objects on Path group. @@ -95,19 +104,38 @@ impl ObjectsOnPath { /// in its frame as drawn ([`upright_frame`]). pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self { let members = members.iter().map(|n| Member::new(n)).collect(); - Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } } + Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 }, spacing: None, start: 0.0 } } /// The frames go with the group, and each frame's angle becomes the transformed path's /// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear /// and reflection when it spreads the objects along the transformed path again. A reflection - /// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path. - pub fn transform(&mut self, a: Affine) { + /// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path. With `paths`, + /// the group's path before the transform and as it is after it, the start and a Space value + /// keep the first and last objects where the transform takes them on the path; a value that + /// cannot be followed that way scales by how much the path's length changed. Without `paths`, + /// they scale by the transform's average scale (the square root of its area scale). A + /// value that would not be finite once scaled stays as it is. + pub fn transform(&mut self, a: Affine, paths: Option<(&PathData, &PathData)>) { let before = tilt(self.rotate); + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); if a.determinant() < 0.0 && self.rotate != 0.0 { self.rotate = -self.rotate; } + let along = paths.and_then(|(old, new)| { + let s = path_length(new)?.0 / path_length(old)?.0; + (s.is_finite() && s > 0.0).then_some(s) + }); + let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); + // A move leaves the distances along the path as they are. + let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0; + if !moves && scale.is_finite() && scale > 0.0 { + let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); + let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale)); + let (spacing, start) = moved.unwrap_or((None, None)); + self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); + self.start = start.unwrap_or_else(|| scaled(self.start)); + } let after = tilt(self.rotate); - let [a0, a1, a2, a3, _, _] = a.as_coeffs(); for m in &mut self.members { m.frame = a * m.frame; let (sin, cos) = (m.angle - before).sin_cos(); @@ -117,6 +145,41 @@ impl ObjectsOnPath { } } } + /// The start and Space value on `new`, the path `old` after the transform `a` (regenerated, for + /// a live shape), that keep the first object and the last one where `a` takes them: each one's + /// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along + /// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the + /// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer + /// than two objects. `None` for a path without length or a start that is not finite. + fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> { + let (was, is) = (Spine::new(old)?, Spine::new(new)?); + let (len, closed) = path_length(old)?; + let (new_len, _) = path_length(new)?; + if !self.start.is_finite() { + return None; + } + let n = self.members.len(); + let (s0, gap) = layout(len, closed, n, self.spacing, self.start); + let follow = |s: f64| -> Option { + let (p, _) = was.at_length(s); + let t = is.nearest(a * p, Some(s * scale))?.1; + t.is_finite().then_some(if closed { wrap(t, new_len) } else { t.clamp(0.0, new_len) }) + }; + let t0 = follow(s0)?; + // On a closed path the start keeps the number of times it goes round, when that stays finite. + let start = + if closed { Some(t0 + ((self.start - s0) / len).round() * new_len).filter(|s| s.is_finite()) } else { (s0 == self.start).then_some(t0) }; + let spacing = self.spacing.filter(|g| n > 1 && g.is_finite() && *g > 0.0 && *g <= gap).and_then(|g| { + let t1 = follow(nth_length(len, closed, s0, gap, n - 1))?; + let steps = (n - 1) as f64; + // Round a closed path, the span nearest the scaled one. + let expected = steps * g * scale; + let span = if closed { wrap(t1 - t0 - expected + new_len / 2.0, new_len) - new_len / 2.0 + expected } else { t1 - t0 }; + let g = span / steps; + (g.is_finite() && g >= 0.0).then_some(g) + }); + Some((spacing, start)) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -210,19 +273,75 @@ pub fn upright_frame(n: &Node) -> Affine { } } -/// Where `n` objects attach along `path` (its first subpath): the point and the direction there -/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the -/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way -/// around. `None` for a path without length. -pub fn stops(path: &PathData, n: usize) -> Option> { - let spine = Spine::new(path)?; - let len = spine.length(); - if !len.is_finite() || len <= 1e-9 { - return None; - } +/// The length of `path`'s first subpath and whether it is closed. `None` for a path without +/// length. +pub fn path_length(path: &PathData) -> Option<(f64, bool)> { + let len = Spine::new(path)?.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); - let gaps = if closed { n } else { n.saturating_sub(1) }.max(1); - Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect()) + (len.is_finite() && len > 1e-9).then_some((len, closed)) +} + +/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the +/// distance from each object's attachment point to the next one's, both along the path (points), +/// for Space `spacing` (`None`: Auto) from `start`: +/// - The first object is `start` from the first anchor: on an open path no further than its end, +/// and on a closed path less than once around. +/// - Auto spreads the objects evenly from there: on an open path the last one is at its end, and on +/// a closed path the gaps go all the way around. +/// - A Space value is kept to what the path has room for, so a shorter path draws the objects +/// closer while the value stays: on an open path the last object reaches its end at most, and on +/// a closed path the last one stops at least a gap short of the first. +/// +/// A Space value that is negative or not finite counts as Auto, and a `start` that is not finite as 0. +pub fn layout(len: f64, closed: bool, n: usize, spacing: Option, start: f64) -> (f64, f64) { + let start = match (start.is_finite(), closed) { + (false, _) => 0.0, + (true, true) => wrap(start, len), + (true, false) => start.clamp(0.0, len.max(0.0)), + }; + let room = if closed { + len / n.max(1) as f64 + } else if n > 1 { + (len - start) / (n - 1) as f64 + } else { + 0.0 + }; + let room = if room.is_finite() { room.max(0.0) } else { 0.0 }; + match spacing.filter(|g| g.is_finite() && *g >= 0.0) { + Some(g) => (start, g.min(room)), + None => (start, room), + } +} + +/// `s` taken round a closed path of length `len`: 0 or more, and less than `len`. +fn wrap(s: f64, len: f64) -> f64 { + let w = s.rem_euclid(len); + if w.is_finite() && w < len { w } else { 0.0 } +} + +/// Where `n` objects attach along `path` (its first subpath): the point and the direction there +/// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them +/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. +pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { + let spine = Spine::new(path)?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, n, spacing, start); + Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) +} + +/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. +pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { + let spine = Spine::new(path)?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, n, spacing, start); + (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i))) +} + +/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and +/// `gap` apart as [`layout`] gives them. +pub fn nth_length(len: f64, closed: bool, start: f64, gap: f64, i: usize) -> f64 { + let s = start + gap * i as f64; + if closed { wrap(s, len) } else { s.min(len) } } /// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees @@ -277,7 +396,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { } } let mut moved = false; - if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) { + if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) { let turn = tilt(rec.rotate); for (m, (p, dir)) in rec.members.iter_mut().zip(stops) { let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue }; @@ -309,15 +428,192 @@ mod tests { #[test] fn stops_spread_from_the_first_anchor() { let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); - let s = stops(&open, 4).unwrap(); + let s = stops(&open, 4, None, 0.0).unwrap(); let xs: Vec = s.iter().map(|(p, _)| p.x.round()).collect(); assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path"); let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); - let s = stops(&closed, 4).unwrap(); + let s = stops(&closed, 4, None, 0.0).unwrap(); let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect(); pts.sort(); assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round"); - assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none()); + assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2, None, 0.0).is_none()); + } + + /// The x of each stop on the `len` pt line from (0, 0) to the right, to 1/1000 pt. + fn xs_on_line(len: f64, n: usize, spacing: Option, start: f64) -> Vec { + let open = shapes::line(Point::new(0.0, 0.0), Point::new(len, 0.0)); + stops(&open, n, spacing, start).unwrap().iter().map(|(p, _)| (p.x * 1000.0).round() / 1000.0).collect() + } + + #[test] + fn space_sets_the_distance_between_the_stops() { + assert_eq!(xs_on_line(300.0, 4, Some(60.0), 0.0), [0.0, 60.0, 120.0, 180.0], "the first stays on the first anchor"); + assert_eq!(xs_on_line(300.0, 4, Some(60.0), 60.0), [60.0, 120.0, 180.0, 240.0], "centered on the path"); + assert_eq!(xs_on_line(300.0, 4, Some(0.0), 0.0), [0.0; 4], "no space stacks them"); + // More than the path has room for: the last one reaches the end. + assert_eq!(xs_on_line(300.0, 4, Some(500.0), 0.0), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 4, Some(500.0), 150.0), [150.0, 200.0, 250.0, 300.0]); + // A shorter path draws them closer, while the Space value stays to come back with the length. + assert_eq!(xs_on_line(90.0, 4, Some(60.0), 0.0), [0.0, 30.0, 60.0, 90.0]); + assert_eq!(layout(90.0, false, 4, Some(60.0), 0.0), (0.0, 30.0)); + assert_eq!(layout(300.0, false, 4, Some(60.0), 0.0), (0.0, 60.0)); + // Values that are not usable count as Auto, and a start beyond the end stops at it. + assert_eq!(xs_on_line(300.0, 4, Some(f64::NAN), f64::INFINITY), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 4, Some(-5.0), 0.0), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 2, Some(10.0), 400.0), [300.0, 300.0]); + assert_eq!(xs_on_line(300.0, 1, Some(10.0), 20.0), [20.0], "one object has no space to set"); + assert_eq!(xs_on_line(300.0, 0, Some(10.0), 0.0), Vec::::new()); + // One stop on its own is where the stops put it. + let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let all = stops(&open, 4, Some(60.0), 60.0).unwrap(); + for (i, s) in all.iter().enumerate() { + assert_eq!(stop(&open, 4, Some(60.0), 60.0, i), Some(*s)); + } + assert_eq!(stop(&open, 4, Some(60.0), 60.0, 4), None); + } + + #[test] + fn space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object() { + // The square's outline is 400 pt round, from (0, 0) to the right and on clockwise. + let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let pts = |spacing, start| -> Vec<(i64, i64)> { + stops(&closed, 4, spacing, start).unwrap().iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect() + }; + assert_eq!(pts(Some(50.0), 0.0), [(0, 0), (50, 0), (100, 0), (100, 50)]); + // Centered on the first anchor: the span goes back past it. + assert_eq!(pts(Some(50.0), -75.0), [(0, 75), (0, 25), (25, 0), (75, 0)]); + // At most a quarter of the way round each: no two objects meet at the first anchor. + assert_eq!(pts(Some(300.0), 0.0), [(0, 0), (100, 0), (100, 100), (0, 100)]); + assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); + } + + #[test] + fn a_group_saved_without_space_reads_as_auto() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let rec = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0); + let v = serde_json::to_value(&rec).unwrap(); + assert!(v.get("spacing").is_none() && v.get("start").is_none(), "Auto is not written: {v}"); + let back: ObjectsOnPath = serde_json::from_value(v).unwrap(); + assert_eq!((back.spacing, back.start), (None, 0.0)); + let set = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..rec }; + let back: ObjectsOnPath = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap(); + assert_eq!(back, set); + } + + #[test] + fn transforming_the_group_scales_its_space() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut rec = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 30.0) }; + rec.transform(Affine::new([-2.0, 0.0, 0.0, 2.0, 5.0, 5.0]), None); + assert_eq!((rec.spacing, rec.start, rec.rotate), (Some(120.0), 25.0, -30.0)); + rec.transform(Affine::translate((40.0, 0.0)) * Affine::rotate(0.7), None); + assert!((rec.spacing.unwrap() - 120.0).abs() < 1e-9 && (rec.start - 25.0).abs() < 1e-9, "{rec:?}"); + // Twice as wide and no higher: a level path is stretched twice along its length. + let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let stretch = Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + rec.transform(stretch, Some((&level, &level.transformed(stretch)))); + assert!((rec.spacing.unwrap() - 240.0).abs() < 1e-9 && (rec.start - 50.0).abs() < 1e-9, "{rec:?}"); + let mut auto = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0); + auto.transform(Affine::scale(3.0), None); + assert_eq!((auto.spacing, auto.start), (None, 0.0), "Auto stays Auto"); + let mut huge = ObjectsOnPath { spacing: Some(1e308), start: -1e308, ..auto }; + huge.transform(Affine::scale(2.0), None); + assert_eq!((huge.spacing, huge.start), (Some(1e308), -1e308), "kept finite"); + } + + /// Stretching a path that turns a corner keeps the first and last objects where the stretch + /// takes them, not where the whole path's change in length would. + #[test] + fn a_stretch_keeps_the_first_and_last_objects_where_it_takes_them() { + // 100 pt to the right, then 100 pt down; twice as wide, the first part is 200 pt long. + let points = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)]; + let corner = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)); + let stretch = Affine::scale_non_uniform(2.0, 1.0); + let wide = corner.transformed(stretch); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 50.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + rec.transform(stretch, Some((&corner, &wide))); + assert!((rec.start - 100.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}"); + // Round a closed path, from before its first anchor: the start goes round as many times. + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let wide = square.transformed(stretch); + let mut rec = ObjectsOnPath { spacing: Some(50.0), start: -75.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + let before = stops(&square, 2, rec.spacing, rec.start).unwrap(); + rec.transform(stretch, Some((&square, &wide))); + let after = stops(&wide, 2, rec.spacing, rec.start).unwrap(); + for ((p, _), (q, _)) in before.iter().zip(&after) { + assert!((stretch * *p).distance(*q) < 1e-9, "{p:?} went to {q:?}: {rec:?}"); + } + assert!(rec.start < 0.0, "{rec:?}"); + } + + /// A start round a closed path so many times that scaling it would not be finite stays as it is. + #[test] + fn a_closed_path_start_too_large_to_scale_stays_finite() { + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let grow = Affine::scale(2.0); + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut rec = ObjectsOnPath { start: 1e308, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0) }; + rec.transform(grow, Some((&square, &square.transformed(grow)))); + assert_eq!(rec.start, 1e308, "{rec:?}"); + } + + /// Stretching the group across its level path only keeps the objects where the stretch takes + /// them: twice as far apart. + #[test] + fn a_group_stretched_along_its_path_keeps_the_objects_in_proportion() { + let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default()); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false }); + let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + g.extras_mut().objects_on_path = Some(Box::new(rec)); + let empty = crate::Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + place(&mut g, &symbols); + g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + let rec = g.extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0)); + place(&mut g, &symbols); + let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; + assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); + } + + #[test] + fn length_near_measures_along_the_path_and_past_its_ends() { + // An L: 400 pt to the right, then 300 pt up. + let points = [Point::new(0.0, 300.0), Point::new(400.0, 300.0), Point::new(400.0, 0.0)]; + let l = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)); + let spine = Spine::new(&l).unwrap(); + assert!((spine.length_near(Point::new(300.0, 320.0), None) - 300.0).abs() < 1e-9); + assert!((spine.length_near(Point::new(420.0, 100.0), None) - 600.0).abs() < 1e-9); + assert!((spine.length_near(Point::new(400.0, -26.0), None) - 726.0).abs() < 1e-9, "beyond the end"); + assert!((spine.length_near(Point::new(-50.0, 300.0), None) + 50.0).abs() < 1e-9, "before the start"); + // A path that turns back near its start: beyond its end, the pointer is nearer the first + // segment than the last one, but on the line of the last one. + let hook = [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 10.0), Point::new(100.0, 10.0)]; + let hook = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&hook, false))).unwrap(); + assert!((hook.length_near(Point::new(80.0, 10.0), None) - 330.0).abs() < 1e-9); + assert!((hook.length_near(Point::new(50.0, 10.0), None) - 360.0).abs() < 1e-9); + let closed = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap(); + assert!((closed.length_near(Point::new(-30.0, -30.0), None)).abs() < 1e-9, "a closed path has no ends"); + } + + #[test] + fn length_near_a_point_followed_stays_on_that_part_of_the_path() { + // A narrow U, open on the left: its ends are 20 pt apart, 620 pt from each other along it. + let u = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 20.0), Point::new(0.0, 20.0)]; + let u = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&u, false))).unwrap(); + assert!((u.length_near(Point::new(-26.0, 20.0), None) - 646.0).abs() < 1e-9, "beyond the end"); + // Nearer the first segment's line, the pointer stays beyond the end it was following. + assert!((u.length_near(Point::new(-26.0, 8.0), None) + 26.0).abs() < 1e-9); + assert!((u.length_near(Point::new(-26.0, 8.0), Some(646.0)) - 646.0).abs() < 1e-9); + // Following the path round, it goes on from where it was. + assert!((u.length_near(Point::new(305.0, 10.0), Some(312.0)) - 310.0).abs() < 1e-9); + // Round a closed path's first anchor: from 10 pt short of it to 20 pt past it. + let square = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap(); + let (at, _) = square.at_length(390.0); + assert!((square.length_near(at, None) - 390.0).abs() < 1e-9); + assert!((square.length_near(Point::new(20.0, -5.0), Some(390.0)) - 20.0).abs() < 1e-9); } #[test] @@ -337,7 +633,7 @@ mod tests { // The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square // around it that instances are measured by without the symbols. let mut d = crate::Document::new(400.0, 400.0); - d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) }); let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default()); let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY }); let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index 43c67d86f..114a15290 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -200,7 +200,7 @@ mod tests { fn oriented_bounds_hug_a_rotated_symbol_instances_art() { let mut d = Document::new(500.0, 500.0); let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art()); - d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) }); + d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(bar) }); let (l, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap(); let c = Point::new(120.0, 105.0); diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index a0b19b844..2a98df62c 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -522,7 +522,28 @@ pub struct RepeatSpec { /// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps. const MAX_REPEAT_INSTANCES: usize = 250_000; -/// Counts the instances Repeats make in all, following symbol instances into their symbol's art +/// Most nodes and anchors a Repeat copies in all, those of the Repeats nested in its source and +/// of the opacity masks in it included: each instance copies the whole source, so a large source +/// keeps fewer instances than [`MAX_REPEAT_INSTANCES`] allows (a 500 × 500 grid of a shape of up +/// to 39 anchors keeps them all). +const MAX_REPEAT_COPIES: usize = 10_000_000; + +/// What the Repeats in some art make in all ([`NestedCount::nodes`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Cost { + /// Instances the Repeats make (0 when there are none). + instances: usize, + /// Nodes and path anchors drawn, each Repeat's source counted once per instance it keeps. + copies: usize, +} + +impl Cost { + fn plus(self, o: Cost) -> Cost { + Cost { instances: self.instances.saturating_add(o.instances), copies: self.copies.saturating_add(o.copies) } + } +} + +/// Counts the instances Repeats make, and the nodes they copy, in all, following symbol instances into their symbol's art /// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol /// inside its own art, counts as none. /// @@ -549,21 +570,27 @@ impl<'a> NestedCount<'a> { } let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 }; self.symbols.borrow_mut().insert(name.to_owned(), None); - let n = self.nodes(std::slice::from_ref(&sym.art)); + let n = self.nodes(std::slice::from_ref(&sym.art)).instances; self.symbols.borrow_mut().insert(name.to_owned(), Some(n)); n } - /// Instances the Repeats in `nodes` make in all (0 when there are none). - fn nodes(&self, nodes: &[Arc]) -> usize { - let mut sum = 0usize; + /// What the Repeats in `nodes` make in all, in the art of their enabled opacity masks too + /// (drawn again with each copy of the object they mask). + fn nodes(&self, nodes: &[Arc]) -> Cost { + let mut sum = Cost::default(); for n in nodes { - sum = sum.saturating_add(match (&n.kind, n.children()) { - (NodeKind::Repeat(r), _) => r.instances_in_all(self), - (NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol), - (_, Some(c)) => self.nodes(c), - _ => 0, - }); + let own = match (&n.kind, n.children()) { + (NodeKind::Repeat(r), _) => r.in_all(self), + (NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 }, + (NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) }, + (_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }), + _ => Cost { instances: 0, copies: 1 }, + }; + sum = sum.plus(own); + if let Some(m) = n.extras().mask.as_ref().filter(|m| !m.disabled) { + sum = sum.plus(self.nodes(std::slice::from_ref(&m.art))); + } } sum } @@ -643,21 +670,22 @@ impl RepeatSpec { v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source))); v } - /// How many of its instances this Repeat keeps when each makes `nested` instances of the - /// Repeats in its source: as many as fit in [`MAX_REPEAT_INSTANCES`], at least one. - fn kept(&self, nested: usize) -> usize { - self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1) + /// How many of its instances this Repeat keeps when its source costs `nested`: as many as fit + /// in [`MAX_REPEAT_INSTANCES`] and [`MAX_REPEAT_COPIES`], at least one. + fn kept(&self, nested: Cost) -> usize { + self.kind.count().min(MAX_REPEAT_INSTANCES / nested.instances.max(1)).min(MAX_REPEAT_COPIES / nested.copies.max(1)).max(1) } /// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its /// options make, fewer when the Repeats in its source would take it past - /// [`MAX_REPEAT_INSTANCES`]. + /// [`MAX_REPEAT_INSTANCES`], or the copies of its source past [`MAX_REPEAT_COPIES`]. pub fn instances(&self, doc: Option<&Document>) -> usize { self.kept(NestedCount::new(doc).nodes(&self.source)) } - /// Instances made in all, those of the Repeats nested in the source included. - fn instances_in_all(&self, count: &NestedCount) -> usize { + /// Instances made and nodes copied in all, those of the Repeats nested in the source included. + fn in_all(&self, count: &NestedCount) -> Cost { let nested = count.nodes(&self.source); - self.kept(nested).saturating_mul(nested.max(1)) + let kept = self.kept(nested); + Cost { instances: kept.saturating_mul(nested.instances.max(1)), copies: kept.saturating_mul(nested.copies).saturating_add(1) } } /// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many /// as [`Self::transforms`] keeps for `doc`. @@ -1005,6 +1033,42 @@ mod tests { assert_eq!(made(&small), 18); } + #[test] + fn repeats_in_opacity_masks_share_the_instance_budget() { + let grid = |source: Arc| { + let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0); + if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind { + *rows = 500; + *cols = 500; + } + g + }; + // A shape masked by a 500 × 500 grid draws the grid again for each copy. + let inner = Node::new(NodeId(3), NodeKind::Repeat(grid(sq(0.0, 0.0, 1.0)))); + let mut masked = (*sq(0.0, 0.0, 1.0)).clone(); + masked.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true))); + let outer = grid(Arc::new(masked.clone())); + assert_eq!(outer.transforms(None).len(), 1); + // A disabled mask is not drawn, so it costs nothing. + if let Some(m) = &mut masked.extras_mut().mask { + m.disabled = true; + } + assert_eq!(grid(Arc::new(masked)).transforms(None).len(), 250_000); + } + + #[test] + fn a_large_source_keeps_fewer_copies() { + let mut g = RepeatSpec::grid((0..1000).map(|i| sq(i as f64, 0.0, 1.0)).collect(), 0.0, 0.0); + if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind { + *rows = 500; + *cols = 500; + } + // Each copy is 1000 paths of 4 anchors: far fewer than 250 000 of them fit. + let kept = g.transforms(None).len(); + assert!(kept.saturating_mul(5000) <= MAX_REPEAT_COPIES, "{kept} copies kept"); + assert!(kept > 1); + } + #[test] fn serde_roundtrip() { let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4de3823db..6ec18bcfa 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -58,12 +58,14 @@ impl Node { } } - /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). + /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An + /// invertible `a` that the projection cannot follow removes it, and the art is drawn flat. pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) - && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) + && let Some(h) = p.homography() + && a.determinant().abs() >= 1e-15 { - p.projection = Some(h.to_array()); + p.projection = h.conjugated(a).map(|h| h.to_array()); } } } diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 9b00da57e..33eccb178 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -559,6 +559,9 @@ impl Document { reads += usize::from(read_full && depth > 0); let full = if read_full { full_bytes(&p, &blob) } else { None }; if full.and_then(|b| read(&p, &b)).is_none() { + // A full read cached for another caller (one allowed to read the file) must not + // stand in for the one refused or not tried here: the preview shows instead. + cache().entries.remove(&(p.key.clone(), false)); let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; if let Some(bytes) = preview { read_preview(&p, &bytes); @@ -667,7 +670,7 @@ mod tests { pixels: None, })), ); - d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Star".into(), art: Arc::new(art) }); d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))]; if bytes.starts_with(b"nest") { @@ -702,7 +705,11 @@ mod tests { fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() { set_loader(stub_loader); let mut d = Document::new(200.0, 200.0); - d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) }); + d.symbols.push(Symbol { + dynamic: false, + name: "Star".into(), + art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })), + }); d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec())); let p = placed("doc1-1"); let layer = d.layers[0].id; diff --git a/crates/doc/src/range.rs b/crates/doc/src/range.rs index 87db16f20..7162709bc 100644 --- a/crates/doc/src/range.rs +++ b/crates/doc/src/range.rs @@ -17,7 +17,9 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { .ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) }; let mut out = vec![]; - let mut seen = std::collections::HashSet::new(); + // Past each run of indices picked so far, the index after it: an interval skips the runs it + // overlaps instead of walking them again, so repeating `1-` costs no more than writing it once. + let mut next = std::collections::HashMap::new(); for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { let (a, b) = match part.replace('\u{2013}', "-").split_once('-') { Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")), @@ -30,10 +32,11 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { if a > b { return Err(format!("range `{s}`: `{part}` runs backwards")); } - for i in a - 1..b { - if seen.insert(i) { - out.push(i); - } + let mut i = unpicked(&mut next, a - 1); + while i < b { + out.push(i); + next.insert(i, i + 1); + i = unpicked(&mut next, i + 1); } } if out.is_empty() { @@ -42,6 +45,23 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { Ok(out) } +/// The first index from `i` on not picked yet, `next` linking each picked index onward (the links +/// followed are then pointed straight at it). +fn unpicked(next: &mut std::collections::HashMap, i: usize) -> usize { + let mut end = i; + while let Some(&j) = next.get(&end) { + end = j; + } + let mut at = i; + while at != end { + match next.insert(at, end) { + Some(j) => at = j, + None => break, + } + } + end +} + #[cfg(test)] mod tests { use super::parse_range; @@ -53,4 +73,20 @@ mod tests { assert!(parse_range("-+2", 5).is_err()); assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4])); } + + #[test] + fn repeats_and_overlaps_are_dropped_in_order() { + assert_eq!(parse_range("3-5, 1-4, 2, 6-, -1", 7), Ok(vec![2, 3, 4, 0, 1, 5, 6])); + assert_eq!(parse_range("2,2,1-2", 4), Ok(vec![1, 0])); + assert_eq!(parse_range("4, 2, 1-", 5), Ok(vec![3, 1, 0, 2, 4])); + } + + #[test] + fn a_range_repeated_many_times_is_read_once() { + // 100 000 pages named 50 000 times: walking the pages for each would take billions of steps. + let s = "1-,".repeat(50_000); + let pages = parse_range(&s, 100_000).unwrap(); + assert_eq!(pages.len(), 100_000); + assert!(pages.iter().enumerate().all(|(i, p)| i == *p)); + } } diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs index a5a878710..0efa82296 100644 --- a/crates/doc/src/recolor.rs +++ b/crates/doc/src/recolor.rs @@ -18,8 +18,8 @@ pub trait ColorVisitor { fn image(&mut self, key: &str) -> Option; } -/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of -/// embedded images. +/// What a recolouring pass reaches: fills (including colorized image fills and gradient-mesh +/// points), strokes and the pixels of embedded images without a colorize paint. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Reach { pub fill: bool, @@ -33,11 +33,18 @@ impl Reach { } /// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of -/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay -/// as they are). Members that change are replaced by copies; the others stay shared. Returns the -/// number of paints, meshes and images that changed. +/// its text runs, its gradient-mesh points and an embedded image's pixels (linked and colorized +/// images keep their pixels). Members that change are replaced by copies; the others stay shared. +/// Returns the number of paints, meshes and images that changed. pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize { let mut changed = 0; + let colorized = matches!(&n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some(); + if colorized + && reach.fill + && let Some(paint) = n.extras_mut().colorize.as_deref_mut() + { + changed += v.paint(paint) as usize; + } for it in &mut n.appearance.items { match it { AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize, @@ -67,7 +74,7 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi } // An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its // colours, as the glyph does in type. - NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => { + NodeKind::Image(im) if reach.images && !colorized && im.link.is_none() && im.pixels.is_none() => { if let Some(k) = v.image(&im.key) { im.key = k; changed += 1; diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs index b13a1e955..a6988f502 100644 --- a/crates/doc/src/records.rs +++ b/crates/doc/src/records.rs @@ -1,4 +1,4 @@ -use super::{Arc, Deserialize, Node, NodeId, Point, Serialize}; +use super::{Affine, Arc, Deserialize, Node, NodeId, Point, Serialize}; /// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in /// place, so a selection change that sets it doesn't replace the document. @@ -44,6 +44,29 @@ pub struct MaskEdit { pub layer: NodeId, } +/// Symbol editing mode: the art of symbol `symbol` lives on the temporary `layer` while editing, +/// placed by `xf` (from the symbol's coordinates to the document's), and goes back into the symbol +/// after each edit, so every instance follows it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SymbolEdit { + pub symbol: String, + pub layer: NodeId, + /// The instance editing was entered from (a double-click on it), selected again on leaving after an edit; + /// `None` when it was entered from the Symbols panel. + pub instance: Option, + pub xf: Affine, + /// The symbol's art is a group whose members are on the layer: the group takes them back. + pub group: bool, + /// An edit changed the art since editing was entered. + pub changed: bool, + /// The isolated object from before editing was entered, isolated again on leaving. + #[serde(default)] + pub back_isolation: Option, + /// The current layer from before editing was entered, current again on leaving. + #[serde(default)] + pub back_layer: Option, +} + /// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SavedView { @@ -108,6 +131,10 @@ impl SavedSelection { pub struct Symbol { pub name: String, pub art: Arc, + /// Symbol Options › Symbol Type › Dynamic Symbol: each instance may change the appearance of + /// objects in the art and stay linked (see [`crate::symbol_overrides`]). Static by default. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub dynamic: bool, } /// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 875fcf3a0..c26918153 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -107,14 +107,17 @@ impl Quotes { /// Single-quote pairs offered by Document Setup. pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']]; - /// Is `c` one of these opening quotes? + /// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not + /// count; a straight quote has no direction, so it still opens. fn is_open(&self, c: char) -> bool { - c == self.double[0] || c == self.single[0] + (c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1])) } /// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote. - fn opens(&self, prev: Option) -> bool { - prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p)) + /// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether + /// it opened; that settles glyphs which serve as both opening and closing quote. + fn opens(&self, prev: Option, quote_opened: Option) -> bool { + prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p))) } /// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by @@ -122,11 +125,16 @@ impl Quotes { /// and is updated to its last character, so text can be converted in pieces. pub fn apply(&self, text: &str, prev: &mut Option) -> String { let mut out = String::with_capacity(text.len()); + let mut quote_opened = None; for c in text.chars() { - let r = match c { - '"' => self.double[usize::from(!self.opens(*prev))], - '\'' => self.single[usize::from(!self.opens(*prev))], - c => c, + quote_opened = match c { + '"' | '\'' => Some(self.opens(*prev, quote_opened)), + _ => None, + }; + let r = match (c, quote_opened) { + ('"', Some(open)) => self.double[usize::from(!open)], + ('\'', Some(open)) => self.single[usize::from(!open)], + (c, _) => c, }; out.push(r); *prev = Some(r); @@ -135,34 +143,74 @@ impl Quotes { } } -/// Document languages and the quote characters each uses (choosing a language in Document Setup -/// also picks its quotes). -pub const LANGUAGES: &[(&str, Quotes)] = &[ - ("English: USA", Quotes::ENGLISH), - ("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }), - ("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), - ("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }), - ("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }), - ("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }), - ("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }), - ("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }), - ("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }), - ("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), - ("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }), - ("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), - ("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }), - ("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }), - ("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }), - ("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }), +/// Document and character languages: the name, the BCP 47 tag text is shaped in (so that fonts use +/// their language's forms, OpenType `locl`) and the quote characters each uses (choosing a +/// language in Document Setup also picks its quotes). The first is the default. +pub const LANGUAGES: &[(&str, &str, Quotes)] = &[ + ("English: USA", "en-US", Quotes::ENGLISH), + ("English: UK", "en-GB", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("German", "de", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("German: Swiss", "de-CH", Quotes { double: ['«', '»'], single: ['‹', '›'] }), + ("French", "fr", Quotes { double: ['«', '»'], single: ['‹', '›'] }), + ("Italian", "it", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Spanish", "es", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Catalan", "ca", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Portuguese", "pt", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Dutch", "nl", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Danish", "da", Quotes { double: ['»', '«'], single: ['›', '‹'] }), + ("Swedish", "sv", Quotes { double: ['”', '”'], single: ['’', '’'] }), + ("Finnish", "fi", Quotes { double: ['”', '”'], single: ['’', '’'] }), + ("Norwegian", "nb", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }), + ("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }), + // „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN + // 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR + // (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`). + ("Serbian: Cyrillic", "sr-Cyrl", Quotes { double: ['„', '”'], single: ['’', '’'] }), + ("Serbian: Latin", "sr-Latn", Quotes { double: ['„', '”'], single: ['’', '’'] }), + // „…“ outside, ‚…‘ inside: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml, + // `delimiters`). + ("Croatian", "hr", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }), + ("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Hindi", "hi", Quotes::ENGLISH), + ("Marathi", "mr", Quotes::ENGLISH), + ("Gujarati", "gu", Quotes::ENGLISH), + ("Bengali", "bn", Quotes::ENGLISH), + ("Punjabi", "pa", Quotes::ENGLISH), + ("Tamil", "ta", Quotes::ENGLISH), + ("Telugu", "te", Quotes::ENGLISH), + ("Kannada", "kn", Quotes::ENGLISH), + ("Malayalam", "ml", Quotes::ENGLISH), + ("Odia", "or", Quotes::ENGLISH), + ("Urdu", "ur", Quotes::ENGLISH), ]; /// The quotes of a [`LANGUAGES`] entry (case-insensitive). pub fn language_quotes(language: &str) -> Option { - LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q) + LANGUAGES.iter().find(|(l, _, _)| l.eq_ignore_ascii_case(language)).map(|(_, _, q)| *q) +} + +/// The [`LANGUAGES`] entry named or tagged `language` (case-insensitive) as a character's +/// language ([`crate::CharStyle::language`]) stores it: its tag, or `None` for the default (the +/// first entry); `Err` for a language not in the list. +pub fn char_language(language: &str) -> Result, String> { + let i = LANGUAGES + .iter() + .position(|(n, t, _)| n.eq_ignore_ascii_case(language) || t.eq_ignore_ascii_case(language)) + .ok_or_else(|| format!("unknown language `{language}` (one of {})", LANGUAGES.iter().map(|(n, _, _)| *n).collect::>().join(", ")))?; + Ok(LANGUAGES.get(i).filter(|_| i > 0).map(|(_, t, _)| *t)) +} + +/// The name of the language a character's stored `language` tag stands for: the default's for +/// none, `None` for a tag not in [`LANGUAGES`] (from a file). +pub fn char_language_name(language: Option<&str>) -> Option<&'static str> { + match language { + None => LANGUAGES.first().map(|(n, _, _)| *n), + Some(tag) => LANGUAGES.iter().find(|(_, t, _)| t.eq_ignore_ascii_case(tag)).map(|(n, _, _)| *n), + } } /// The transparency flattener preset Document Setup uses unless another is chosen. @@ -300,9 +348,19 @@ impl DocSetup { pub fn keep_in_range(&mut self) { self.bleed = bleed_in_range(self.bleed); for m in [&mut self.superscript, &mut self.subscript] { + // `clamp` passes NaN through, so a non-finite value falls back to the default first. + if !m.size.is_finite() { + m.size = ScriptMetrics::DEFAULT.size; + } + if !m.position.is_finite() { + m.position = ScriptMetrics::DEFAULT.position; + } m.size = m.size.clamp(1.0, MAX_SCRIPT); m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT); } + if !self.small_caps_size.is_finite() { + self.small_caps_size = DocSetup::default().small_caps_size; + } self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS); } } @@ -311,6 +369,31 @@ impl DocSetup { mod tests { use super::*; + /// Characters store a language of the list by its tag, the default as none. + #[test] + fn char_languages_are_stored_by_tag() { + assert_eq!(char_language("Turkish"), Ok(Some("tr"))); + assert_eq!(char_language("MR"), Ok(Some("mr"))); + assert_eq!(char_language("english: usa"), Ok(None)); + assert_eq!(char_language("en-US"), Ok(None)); + assert!(char_language("Klingon").is_err() && char_language("").is_err()); + assert_eq!( + (char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))), + (Some("English: USA"), Some("Gujarati"), None) + ); + // Serbian in either script, and Croatian. + assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl"))); + assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn"))); + assert_eq!(char_language("Croatian"), Ok(Some("hr"))); + assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic")); + // Every entry has a tag of its own, and a name of its own. + for (i, (n, t, _)) in LANGUAGES.iter().enumerate() { + assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}"); + } + } + + /// Straight quotes become the language's: opening or closing by what comes before, inner + /// quotes as single ones, and the context carried across pieces converted apart. #[test] fn quotes_open_and_close_by_context() { let mut prev = None; @@ -319,6 +402,13 @@ mod tests { let mut prev = Some('('); assert_eq!(german.apply("\"a\"", &mut prev), "„a“"); assert_eq!(prev, Some('“')); + // Serbian closes high (”), with ’ on both sides of an inner quote; Croatian as German. + for name in ["Serbian: Cyrillic", "Serbian: Latin"] { + let mut prev = None; + assert_eq!(language_quotes(name).unwrap().apply("\"a 'b' c\"", &mut prev), "„a ’b’ c”", "{name}"); + } + let mut prev = None; + assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“"); // Converting in pieces keeps the context. let mut prev = None; let a = Quotes::ENGLISH.apply("\"", &mut prev); @@ -326,6 +416,18 @@ mod tests { assert_eq!((a.as_str(), b.as_str()), ("“", "“")); } + #[test] + fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() { + let q = Quotes { double: ['”', '”'], single: ['‘', '’'] }; + let mut prev = None; + assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’"); + let q = Quotes { double: ['"', '"'], single: ['‘', '’'] }; + let mut prev = None; + assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’"); + let mut prev = None; + assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\""); + } + #[test] fn bleed_grows_the_artboard() { let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() }; @@ -333,6 +435,20 @@ mod tests { assert!(s.has_bleed() && !DocSetup::default().has_bleed()); } + #[test] + fn keep_in_range_replaces_non_finite_type_values() { + let mut s = DocSetup::default(); + s.superscript.size = f64::NAN; + s.superscript.position = f64::INFINITY; + s.subscript.size = f64::NEG_INFINITY; + s.subscript.position = f64::NAN; + s.small_caps_size = f64::NAN; + s.keep_in_range(); + for v in [s.superscript.size, s.superscript.position, s.subscript.size, s.subscript.position, s.small_caps_size] { + assert!(v.is_finite(), "{v}"); + } + } + #[test] fn presets_and_defaults() { let d = DocSetup::default(); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 95978bd8d..528e33e3e 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -110,8 +110,9 @@ impl Document { /// Visit every color that can link to a swatch (solid paints and gradient stops of fills, /// strokes and text runs) in the art (opacity masks included), symbol definitions, graph - /// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles - /// and the swatches themselves (tint swatches, gradient swatches' stops). + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles, + /// character styles' fill and stroke and the swatches themselves (tint swatches, gradient + /// swatches' stops). /// `f(colour, link, tint)` may change them and returns true when it did /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. @@ -134,6 +135,11 @@ impl Document { for w in self.swatches_iter_mut() { n += usize::from(w.paint.map_links(f)); } + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, f)); + } + } n } @@ -198,6 +204,23 @@ pub fn color_name(c: Color) -> String { /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`] +/// does, and store the result in the attribute. True when it changed. +fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut LinkFn) -> bool { + let Some(value) = attrs.get_mut(key) else { return false }; + let Ok(mut paint) = serde_json::from_value::(value.clone()) else { return false }; + if !paint.map_links(f) { + return false; + } + match serde_json::to_value(&paint) { + Ok(changed) => { + *value = changed; + true + } + Err(_) => false, + } +} + /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs /// and gradient stops (with their link), then mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 7de70be38..8d7c108ab 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -1,14 +1,29 @@ -//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3 -//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the -//! centre stretch -//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). +//! Symbol Options › Enable Guides for 9-Slice Scaling: two horizontal and two vertical guides cut +//! a symbol into nine sections, and when an instance is scaled the parts of the symbol outside the +//! guides are not scaled +//! ([helpx: maintain proportions while scaling symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)). +//! New guides sit a quarter of the symbol's width and height in from each edge. +//! +//! VectorCraft's own rules: the corners keep their size, the top and bottom edges stretch only in +//! width, the left and right edges only in height, and the center in both. When the instance is +//! smaller than its two outer cells on an axis, both shrink in proportion and the center is empty. +//! The cells are sized along the symbol's own axes, and the instance's rotation, shear, reflection +//! and position then apply to them. //! //! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left -//! and top edges and from the right and bottom edges, so they survive resizing the art. Like -//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas. -//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. +//! and top edges and from the right and bottom edges, so they survive resizing the art. Stored +//! under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. +//! +//! [`NineSliceMap`] places such an instance's art for the canvas, bounds, Break Link, Expand and +//! the exporters ([`Document::place_symbol_art`]). + +use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_geom::{Affine, Anchor, CubicBez, FillRule, ParamCurve, PathData, Point, Rect, SubPath, shapes}; + +use crate::{Appearance, Document, MAX_SYMBOL_NODES, Node, NodeId, NodeKind, SymbolBoxes}; /// Document unknown key: `{ symbolName: [left, top, right, bottom] }`. pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice"; @@ -27,10 +42,10 @@ pub struct SymbolNineSlice { } impl Default for SymbolNineSlice { - /// The centre third of each axis is the stretchy cell. + /// Each guide sits a quarter of the symbol's width or height in from its edge. fn default() -> Self { - let t = 1.0 / 3.0; - Self { left: t, top: t, right: t, bottom: t } + let q = 0.25; + Self { left: q, top: q, right: q, bottom: q } } } @@ -55,3 +70,1382 @@ impl SymbolNineSlice { Self::new(n(0)?, n(1)?, n(2)?, n(3)?) } } + +/// One of the four 9-slice guides. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NineSliceGuide { + Left, + Top, + Right, + Bottom, +} + +impl NineSliceGuide { + pub const ALL: [Self; 4] = [Self::Left, Self::Top, Self::Right, Self::Bottom]; +} + +/// The narrowest center band a guide dragged toward the opposite guide leaves, as a fraction of +/// the art's width or height (VectorCraft's own rule). +pub const MIN_CENTER_BAND: f64 = 0.001; + +/// The 9-slice guides of the symbol edited in symbol editing mode, over its art on the editing +/// layer ([`Document::edited_nine_slice`]). +#[derive(Clone, Debug, PartialEq)] +pub struct EditedNineSlice { + /// The symbol's name. + pub symbol: String, + pub guides: SymbolNineSlice, + /// The art's geometric box at its natural size. + art: Rect, + /// The art at its natural size → the document. + to_doc: Affine, +} + +impl EditedNineSlice { + /// Guide `g` as a line across the art's box, from one edge to the other, in the document. + pub fn line(&self, g: NineSliceGuide) -> (Point, Point) { + let (b, s) = (self.art, self.guides); + let (a, c) = match g { + NineSliceGuide::Left | NineSliceGuide::Right => { + let x = if g == NineSliceGuide::Left { b.x0 + s.left * b.width() } else { b.x1 - s.right * b.width() }; + (Point::new(x, b.y0), Point::new(x, b.y1)) + } + NineSliceGuide::Top | NineSliceGuide::Bottom => { + let y = if g == NineSliceGuide::Top { b.y0 + s.top * b.height() } else { b.y1 - s.bottom * b.height() }; + (Point::new(b.x0, y), Point::new(b.x1, y)) + } + }; + (self.to_doc * a, self.to_doc * c) + } + + /// The four guides' lines: left, top, right, bottom. + pub fn lines(&self) -> [(NineSliceGuide, Point, Point); 4] { + NineSliceGuide::ALL.map(|g| { + let (a, b) = self.line(g); + (g, a, b) + }) + } + + /// The guides with guide `g` moved across its axis to document point `p`. The guide stays + /// within the art's box, and [`MIN_CENTER_BAND`] short of the opposite guide, or where it is + /// when the guides are already closer than that. A point that is not finite leaves the guides + /// as they are. + pub fn moved(&self, g: NineSliceGuide, p: Point) -> SymbolNineSlice { + let (b, mut out) = (self.art, self.guides); + let q = self.to_doc.inverse() * p; + let fit = |v: f64, other: f64, now: f64| if v.is_finite() { v.clamp(0.0, (1.0 - other - MIN_CENTER_BAND).max(now)) } else { now }; + match g { + NineSliceGuide::Left => out.left = fit((q.x - b.x0) / b.width(), out.right, out.left), + NineSliceGuide::Right => out.right = fit((b.x1 - q.x) / b.width(), out.left, out.right), + NineSliceGuide::Top => out.top = fit((q.y - b.y0) / b.height(), out.bottom, out.top), + NineSliceGuide::Bottom => out.bottom = fit((b.y1 - q.y) / b.height(), out.top, out.bottom), + } + out + } +} + +/// The most objects [`NineSliceMap::place`] makes for each object of the art it places: an +/// object drawn in nine cells becomes a group of nine clip groups, each a clip path and a copy. +pub const MAX_SLICE_GROWTH: u64 = 28; + +/// Places a symbol instance's art within [`MAX_SYMBOL_NODES`] objects, of which `used` are already +/// spent. `place` runs only when the budget has room for `most`, the most objects placing can make +/// (the art's object count, or [`NineSliceMap::most_placed`] when it is placed through 9-slice +/// guides), and returns the placed art and its object count, which `used` then grows by. `None`, +/// with `used` as it was, when the instance does not fit. +pub fn charge_symbol_art(used: &mut u64, most: u64, place: impl FnOnce() -> (T, u64)) -> Option { + if used.saturating_add(most) > MAX_SYMBOL_NODES { + return None; + } + let (art, placed) = place(); + let total = used.saturating_add(placed); + if total > MAX_SYMBOL_NODES { + return None; + } + *used = total; + Some(art) +} + +/// The effects that paint past the edges of what an object draws without reshaping it: drop +/// shadow, outer glow and Gaussian blur. [`NineSliceMap::place`] applies them once to all the +/// cells of art that crosses the guides. +pub const PAINTED_EFFECTS: [&str; 3] = ["stylize.dropShadow", "stylize.outerGlow", "blur.gaussian"]; + +/// How far an instance's scale on an axis may be from 1 and still leave its art unchanged. +const SCALE_EPS: f64 = 1e-9; + +/// How deep [`NineSliceMap::place`] follows groups and opacity masks. Art deeper than this is +/// placed as one piece ([`NineSliceMap::place`] says how). +const MAX_SLICE_DEPTH: u32 = 64; + +/// One axis of a 9-slice map: the three cells between the cuts and where each one goes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct AxisMap { + /// The cuts in the art at its natural size: its start, the two guides, its end. + src: [f64; 4], + /// Where the cuts go. The outer cells keep their length unless the scaled length is shorter + /// than both together; then both shrink in proportion and the middle cell is empty. + dst: [f64; 4], +} + +impl AxisMap { + /// The axis from `lo` to `hi` with guides `near` and `far` (fractions of its length in from + /// each end), scaled by `k` (above 0). + fn new(lo: f64, hi: f64, near: f64, far: f64, k: f64) -> Self { + let len = hi - lo; + let (a, b) = (near * len, far * len); + let src = [lo, lo + a, hi - b, hi]; + let (t0, t3) = (k * lo, k * hi); + let room = t3 - t0; + let (a, b) = if a + b <= room { + (a, b) + } else { + let f = (room / (a + b)).max(0.0); + (a * f, b * f) + }; + let t1 = t0 + a; + let t2 = (t3 - b).max(t1); + Self { src, dst: [t0, t1, t2, t3] } + } + + /// The cell `v` lies in; values outside the art belong to the outer cells. + fn cell(&self, v: f64) -> usize { + if v < self.src[1] { + 0 + } else if v < self.src[2] { + 1 + } else { + 2 + } + } + + /// The first and last cells that the span `lo..hi` reaches. A span that ends on a guide does + /// not reach the cell past it. + fn cells(&self, lo: f64, hi: f64) -> [usize; 2] { + let eps = 1e-9 * (self.src[3] - self.src[0]).abs().max(1.0); + let first = if lo >= self.src[2] - eps { + 2 + } else if lo >= self.src[1] - eps { + 1 + } else { + 0 + }; + let last = if hi <= self.src[1] + eps { + 0 + } else if hi <= self.src[2] + eps { + 1 + } else { + 2 + }; + [first, last.max(first)] + } + + /// The part of `lo..hi` in cell `i`, the outer cells reaching past the art; `None` when it is + /// empty. + fn part(&self, i: usize, lo: f64, hi: f64) -> Option<(f64, f64)> { + let start = if i == 0 { lo } else { lo.max(self.src[i]) }; + let end = if i == 2 { hi } else { hi.min(self.src[i + 1]) }; + (start < end).then_some((start, end)) + } + + /// Cell `i`'s map, as (scale, offset). + fn linear(&self, i: usize) -> (f64, f64) { + let (s0, s1, d0, d1) = (self.src[i], self.src[i + 1], self.dst[i], self.dst[i + 1]); + let k = if (s1 - s0).abs() > 1e-12 { (d1 - d0) / (s1 - s0) } else { 1.0 }; + (k, d0 - s0 * k) + } + + fn map(&self, v: f64) -> f64 { + let (k, o) = self.linear(self.cell(v)); + v * k + o + } +} + +/// How an instance of a symbol with 9-slice guides places its art: a map from symbol space to +/// document space that is affine within each of the nine cells and continuous across the guides. +/// The guides cut the art along the symbol's own axes; the instance's rotation, shear, reflection +/// and position apply after the cells are sized (VectorCraft's own rule). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NineSliceMap { + /// Symbol space → the art at its natural size (an axis-aligned scale). + natural: Affine, + x: AxisMap, + y: AxisMap, + /// The instance's transform without its scale along the symbol's axes. + outer: Affine, + /// Whether [`Self::place`] draws art that crosses a guide in clip groups, one per cell. + clips: bool, +} + +impl NineSliceMap { + /// The map for an instance placed by `xf` that shows art with natural-size transform + /// `natural` and geometric bounds `art` (in symbol space). `None` when the art or the + /// transform is degenerate, or when the instance is at its natural size on both axes (then + /// `xf` places the art as it is). + pub fn new(guides: SymbolNineSlice, art: Rect, natural: Affine, xf: Affine) -> Option { + let b = natural.transform_rect_bbox(art); + if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= 1e-9 || b.height() <= 1e-9 { + return None; + } + if natural.determinant().abs() < 1e-18 { + return None; + } + let m = (xf * natural.inverse()).as_coeffs(); + if !m.into_iter().all(f64::is_finite) { + return None; + } + // The linear part as (rotation or reflection) · (shear along the x axis) · (scale along the + // symbol's axes): x scales by the length of the first column, y by the extent of the second + // column across it, so a shear along x alone scales neither axis. A shear along y alone + // keeps the second column's length and the area: it is the natural size too. + let kx = m[0].hypot(m[1]); + if kx <= 1e-12 { + return None; + } + let (u, det) = ((m[0] / kx, m[1] / kx), m[0] * m[3] - m[1] * m[2]); + if (m[2].hypot(m[3]) - 1.0).abs() < SCALE_EPS && (det.abs() - 1.0).abs() < SCALE_EPS { + return None; + } + let v = if det < 0.0 { (u.1, -u.0) } else { (-u.1, u.0) }; + let ky = m[2] * v.0 + m[3] * v.1; + if ky <= 1e-12 || !ky.is_finite() || ((kx - 1.0).abs() < SCALE_EPS && (ky - 1.0).abs() < SCALE_EPS) { + return None; + } + let h = (m[2] * u.0 + m[3] * u.1) / ky; + let outer = Affine::new([u.0, u.1, v.0 + h * u.0, v.1 + h * u.1, m[4], m[5]]); + let x = AxisMap::new(b.x0, b.x1, guides.left, guides.right, kx); + let y = AxisMap::new(b.y0, b.y1, guides.top, guides.bottom, ky); + Some(Self { natural, x, y, outer, clips: true }) + } + + /// Where a point of the symbol's art draws. + pub fn map_point(&self, p: Point) -> Point { + let q = self.natural * p; + self.outer * Point::new(self.x.map(q.x), self.y.map(q.y)) + } + + /// The affine map of the cell in column `i` and row `j`, from the art at its natural size to + /// document space. + fn cell_local(&self, i: usize, j: usize) -> Affine { + let (kx, ox) = self.x.linear(i); + let (ky, oy) = self.y.linear(j); + self.outer * Affine::new([kx, 0.0, 0.0, ky, ox, oy]) + } + + /// The affine map of the cell in column `i` and row `j`: symbol space → document space. + fn cell_xf(&self, i: usize, j: usize) -> Affine { + self.cell_local(i, j) * self.natural + } + + /// The affine map of the cell that symbol-space point `p` lies in. + pub fn cell_at(&self, p: Point) -> Affine { + let q = self.natural * p; + self.cell_xf(self.x.cell(q.x), self.y.cell(q.y)) + } + + /// The guides in symbol space: the two x values and the two y values. + fn cuts(&self) -> ([f64; 2], [f64; 2]) { + let inv = self.natural.inverse(); + let a = inv * Point::new(self.x.src[1], self.y.src[1]); + let b = inv * Point::new(self.x.src[2], self.y.src[2]); + ([a.x, b.x], [a.y, b.y]) + } + + /// `path` (in symbol space) in document space. Each segment is cut where it crosses a guide + /// (at most 12 cuts a segment) and each piece is placed by its cell's map, so the path's + /// anchors and curves follow the map exactly. The result is still one path: a stroke along it + /// is drawn once, without joins or caps at the guides. + pub fn map_path(&self, path: &PathData) -> PathData { + let (xs, ys) = self.cuts(); + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + let Some(first) = sp.anchors.first() else { continue }; + let a0 = self.cell_at(first.p); + let mut res = vec![Anchor { p: a0 * first.p, h_in: a0 * first.h_in, h_out: a0 * first.h_out, kind: first.kind }]; + for i in 0..sp.segment_count() { + let line = sp.segment_is_line(i); + let pieces = split(sp.segment(i), &xs, &ys); + let count = pieces.len(); + let mut end_in = None; + for (j, piece) in pieces.into_iter().enumerate() { + let a = self.cell_at(piece.eval(0.5)); + let (p1, p2, p3) = (a * piece.p1, a * piece.p2, a * piece.p3); + if let Some(prev) = res.last_mut() { + prev.h_out = if line { prev.p } else { p1 }; + } + let h_in = if line { p3 } else { p2 }; + if j + 1 < count { + res.push(Anchor { p: p3, h_in, h_out: p3, kind: Default::default() }); + } else { + end_in = Some(h_in); + } + } + let end = (i + 1) % n; + let b = &sp.anchors[end]; + let ab = self.cell_at(b.p); + let h_in = end_in.unwrap_or(ab * b.p); + if end == 0 { + res[0].h_in = h_in; + } else { + res.push(Anchor { p: ab * b.p, h_in, h_out: ab * b.h_out, kind: b.kind }); + } + } + subs.push(SubPath::new(res, sp.closed)); + } + PathData::new(subs) + } + + /// Symbol art `art` (in symbol space) placed in document space, its instances measured with + /// the symbols of `doc`. VectorCraft's own rules: + /// + /// - An object whose geometric box lies in one cell is placed by that cell's map, so an + /// object in a corner keeps its size and stays as it is (live shapes, type and images + /// included). A stroke that reaches past a guide is drawn with its path, at its weight. + /// - A path with solid or no paint that crosses a guide is cut at the guides + /// ([`Self::map_path`]); a live shape among them becomes a plain path. + /// - Any other object that crosses a guide (type, images, live objects, paths painted with a + /// gradient or pattern) is drawn once in each cell it reaches, placed by that cell's map and + /// clipped to the cell, so its paint lines up across the guides. + /// - Such an object that draws symbol art ([`draws_symbol_art`]: a symbol instance, type with + /// an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws any + /// of these, in it or in its opacity masks) is placed by the map of the cell its center is + /// in, and is not copied into each cell. So is art nested more than 64 levels deep that + /// draws symbol art; other such art is clipped to the cells as above. A path with solid or + /// no paint is cut at the guides whatever its strokes' brushes, and stays one path. + /// + /// Strokes keep their weight, as they do for any instance. + pub fn place(&self, art: &Node, doc: &Document) -> Node { + let boxes = doc.symbol_boxes().quick(); + self.place_at(art, &boxes, 0) + } + + /// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF): + /// every path that crosses a guide is cut at the guides whatever its paint, and any other + /// object that crosses one is placed by the map of the cell its center is in. + pub fn place_unclipped(&self, art: &Node, doc: &Document) -> Node { + Self { clips: false, ..*self }.place(art, doc) + } + + /// The most objects [`Self::place`] makes of `art`, as [`Node::count_with_masks`] counts them + /// (the art of opacity masks included), found without placing it: an object whose box lies in + /// one cell counts its own objects, a path cut at the guides counts 1 and its mask, and an + /// object drawn in clip groups counts one group plus a clip path and a copy for each cell its + /// box reaches. That is never more than [`MAX_SLICE_GROWTH`] for each object of `art`. + pub fn most_placed(&self, art: &Node, doc: &Document) -> u64 { + let boxes = doc.symbol_boxes().quick(); + self.most_at(art, &boxes, 0) + } + + /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. + pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { + Self { clips: false, ..*self }.most_placed(art, doc) + } + + /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`]. + fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { + let whole = n.count_with_masks() as u64; + let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return whole; + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + if cols[0] == cols[1] && rows[0] == rows[1] { + return whole; + } + if depth >= MAX_SLICE_DEPTH { + return self.most_clipped(n, boxes, b); + } + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => self.most_container(n, boxes, depth), + NodeKind::Compound { .. } if !self.clips || plain_paint(n) => self.most_container(n, boxes, depth), + NodeKind::Path { .. } if !self.clips || plain_paint(n) => self.most_mask(n, boxes, depth).saturating_add(1), + _ => self.most_clipped(n, boxes, b), + } + } + + /// [`Self::most_placed`] for a container placed by [`Self::container`]. + fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { + let own = self.most_mask(n, boxes, depth).saturating_add(1); + n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) + } + + /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a + /// linked one through the map, an unlinked one as it is. + fn most_mask(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { + match n.extras().mask.as_ref() { + Some(m) if m.linked => self.most_at(&m.art, boxes, depth + 1), + Some(m) => m.art.count_with_masks() as u64, + None => 0, + } + } + + /// [`Self::most_placed`] for an object placed by [`Self::clipped`], whose box at the natural + /// size is `b`. + fn most_clipped(&self, n: &Node, boxes: &SymbolBoxes, b: Rect) -> u64 { + let whole = n.count_with_masks() as u64; + if !self.clips || draws_symbol_art(n, boxes.document()) { + return whole; + } + let (cols, rows) = self.clip_cells(n, boxes, b).0; + let cells = ((cols[1] - cols[0] + 1) * (rows[1] - rows[0] + 1)) as u64; + cells.saturating_mul(whole.saturating_add(2)).saturating_add(1) + } + + /// The columns and rows that [`Self::clipped`] draws `n` in, and the box it clips: `n`'s visual + /// box at the natural size joined with its geometric box `b`. + fn clip_cells(&self, n: &Node, boxes: &SymbolBoxes, b: Rect) -> (([usize; 2], [usize; 2]), Rect) { + let v = n.visual(Some(boxes)).map(|v| self.natural.transform_rect_bbox(v)).filter(|v| finite(*v)).map_or(b, |v| v.union(b)); + ((self.x.cells(v.x0, v.x1), self.y.cells(v.y0, v.y1)), v) + } + + fn place_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> Node { + let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return transformed(n, self.cell_at(Point::ZERO)); + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + if cols[0] == cols[1] && rows[0] == rows[1] { + return transformed(n, self.cell_xf(cols[0], rows[0])); + } + let center = self.cell_xf(self.x.cell(b.center().x), self.y.cell(b.center().y)); + if depth >= MAX_SLICE_DEPTH { + return self.clipped(n, boxes, b, center); + } + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => self.container(n, boxes, depth, center), + NodeKind::Compound { .. } if !self.clips || plain_paint(n) => self.container(n, boxes, depth, center), + NodeKind::Path { path: src, .. } if !self.clips || plain_paint(n) => { + let mut out = n.clone(); + out.transform(center, false); + if let NodeKind::Path { path, live, .. } = &mut out.kind { + *path = self.map_path(src); + *live = None; + } + self.place_mask(n, &mut out, boxes, depth); + out + } + _ => self.clipped(n, boxes, b, center), + } + } + + /// Group, layer or compound path `n`: its members placed one by one, the container itself + /// (its gradients and frames) by `center`. + fn container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32, center: Affine) -> Node { + let mut out = n.clone(); + let kids = out.children_mut().map(std::mem::take).unwrap_or_default(); + out.transform(center, false); + if let Some(children) = out.children_mut() { + *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); + } + self.place_mask(n, &mut out, boxes, depth); + out + } + + /// The linked opacity mask of `src` placed through the map into `out`; an unlinked one stays + /// where it is, as [`Node::transform`] leaves it. + fn place_mask(&self, src: &Node, out: &mut Node, boxes: &SymbolBoxes, depth: u32) { + if let Some(m) = src.extras().mask.as_ref().filter(|m| m.linked) + && let Some(dst) = out.extras_if_any().and_then(|e| e.mask.as_mut()) + { + dst.art = Arc::new(self.place_at(&m.art, boxes, depth + 1)); + } + } + + /// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of + /// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed + /// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`]. + /// + /// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own + /// effects, after its last other visible effect, go on that group with its opacity, blending + /// mode and isolation, and the group applies them to the copies together: they reach past the + /// outer cells instead of being clipped to them. Its other effects stay on each copy, in their + /// order before the moved ones, so effects that reshape it do not reshape the cells' clip + /// paths, and effects that follow its outline follow the copy's. + fn clipped(&self, n: &Node, boxes: &SymbolBoxes, b: Rect, center: Affine) -> Node { + if !self.clips || draws_symbol_art(n, boxes.document()) { + return transformed(n, center); + } + let mut piece = n.clone(); + let own = &mut piece.appearance.effects; + let keep = own.iter().rposition(|e| e.visible && !PAINTED_EFFECTS.contains(&e.id.as_str())).map_or(0, |i| i + 1); + let effects = own.split_off(keep); + let (opacity, blend, isolate) = (piece.opacity, piece.blend, piece.isolate); + if !effects.is_empty() { + (piece.opacity, piece.blend, piece.isolate) = (1.0, vectorcraft_color::BlendMode::Normal, false); + } + let n = &piece; + let ((cols, rows), v) = self.clip_cells(n, boxes, b); + let mut cells = Vec::with_capacity(9); + for j in rows[0]..=rows[1] { + for i in cols[0]..=cols[1] { + let (Some((x0, x1)), Some((y0, y1))) = (self.x.part(i, v.x0, v.x1), self.y.part(j, v.y0, v.y1)) else { continue }; + let local = self.cell_local(i, j); + if local.determinant().abs() < 1e-18 { + continue; + } + let mut area = shapes::rectangle(Rect::new(x0, y0, x1, y1)); + area.transform(local); + let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false }); + clip.appearance = Appearance::default().into(); + let copy = transformed(n, local * self.natural); + cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }))); + } + } + let mut out = Node::group(n.id, cells); + out.visible = n.visible; + out.locked = n.locked; + out.name = n.name.clone(); + out.knockout_shape = n.knockout_shape; + if !effects.is_empty() { + out.appearance.effects = effects; + (out.opacity, out.blend, out.isolate) = (opacity, blend, isolate); + } + out + } +} + +/// `n` moved by `a`, its strokes at their own weight. +fn transformed(n: &Node, a: Affine) -> Node { + let mut out = n.clone(); + out.transform(a, false); + out +} + +fn finite(b: Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) +} + +/// Whether `n`'s own fills and strokes are solid or empty (no gradient or pattern). +fn plain_paint(n: &Node) -> bool { + n.appearance.items.iter().all(|i| matches!(i.paint(), Paint::None | Paint::Solid { .. })) +} + +/// Whether drawing `n` draws the art of a symbol: `n`, a member of it (the members and sources of +/// live objects included) or the art of an opacity mask in it is a symbol instance, type with an +/// inline graphic, a placed document (whose art may hold instances), an object with a visible +/// brushed stroke (whose brush art may hold instances), or is painted with a pattern of `doc` +/// whose tile draws symbol art (by a visible fill or stroke, or by its characters). Each +/// pattern's tile is looked at once, without recursion. +fn draws_symbol_art(n: &Node, doc: &Document) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![n]; + while let Some(n) = stack.pop() { + let draws = match &n.kind { + NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => true, + NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), + _ => false, + }; + let brushed = n.appearance.items.iter().any(|i| matches!(i, crate::AppearanceItem::Stroke(s) if s.visible && s.brush.is_some())); + if draws || brushed { + return true; + } + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + let items = n.appearance.items.iter().filter(|i| i.visible()).map(|i| i.paint()); + for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) { + if let Paint::Pattern { pattern, .. } = p + && seen.insert(pattern.as_str()) + && let Some(def) = doc.pattern(pattern) + { + stack.extend(def.art.iter().map(|a| &**a)); + } + } + stack.extend(n.children().into_iter().flatten().map(|c| &**c)); + stack.extend(n.extras().mask.as_ref().map(|m| &*m.art)); + } + false +} + +/// The parameters in (0, 1) where `c` crosses one of the lines x = `xs` or y = `ys`, in order. +fn crossings(c: CubicBez, xs: &[f64; 2], ys: &[f64; 2]) -> Vec { + // Power-basis coefficients of one coordinate of the curve. + let coeffs = |p0: f64, p1: f64, p2: f64, p3: f64| [p0, 3.0 * (p1 - p0), 3.0 * (p2 - 2.0 * p1 + p0), p3 - 3.0 * p2 + 3.0 * p1 - p0]; + let cx = coeffs(c.p0.x, c.p1.x, c.p2.x, c.p3.x); + let cy = coeffs(c.p0.y, c.p1.y, c.p2.y, c.p3.y); + let mut ts = vec![]; + for (k, cuts) in [(cx, xs), (cy, ys)] { + for v in cuts { + for t in vectorcraft_geom::kurbo::common::solve_cubic(k[0] - v, k[1], k[2], k[3]) { + if t.is_finite() && t > 1e-9 && t < 1.0 - 1e-9 { + ts.push(t); + } + } + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + ts +} + +/// `c` cut at every crossing of a guide. +fn split(c: CubicBez, xs: &[f64; 2], ys: &[f64; 2]) -> Vec { + let ts = crossings(c, xs, ys); + if ts.is_empty() { + return vec![c]; + } + let mut out = Vec::with_capacity(ts.len() + 1); + let mut t0 = 0.0; + for t in ts.into_iter().chain([1.0]) { + out.push(c.subsegment(t0..t)); + t0 = t; + } + out +} + +impl Document { + /// The 9-slice map for an instance of symbol `name` placed by `xf` (see [`NineSliceMap::new`]): + /// `None` when the symbol has no guides or the instance is at its natural size. The guides cut + /// the art's box with the instances in it measured by their own art. + pub fn symbol_slice_map(&self, name: &str, xf: Affine) -> Option { + let guides = self.symbol_nine_slice(name)?; + let sym = self.symbols.iter().find(|s| s.name == name)?; + let art = self.symbol_boxes().quick().art_bounds(name).or_else(|| sym.art.geometric_bounds())?; + NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf) + } + + /// Art `art` of symbol `name` (stained as the caller draws it) placed by an instance's + /// transform `xf`, with strokes at their own weight: through the symbol's 9-slice guides when + /// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through + /// the guides could make more than [`MAX_SYMBOL_NODES`] objects ([`NineSliceMap::most_placed`]). + pub fn place_symbol_art(&self, name: &str, art: &Node, xf: Affine) -> Option { + match self.symbol_slice_map(name, xf) { + Some(m) if m.most_placed(art, self) > MAX_SYMBOL_NODES => None, + Some(m) => Some(m.place(art, self)), + None => Some(transformed(art, xf)), + } + } + + /// The 9-slice guides of the symbol being edited in symbol editing mode, over its art on the + /// editing layer: `None` outside the mode, when the symbol has no guides, or when its art has + /// no width or height. + pub fn edited_nine_slice(&self) -> Option { + let se = self.symbol_edit.as_ref()?; + let guides = self.symbol_nine_slice(&se.symbol)?; + let sym = self.symbols.iter().find(|s| s.name == se.symbol)?; + let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?; + let natural = self.symbol_natural_xf(&se.symbol); + let b = natural.transform_rect_bbox(art); + if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 { + return None; + } + let to_doc = se.xf * natural.inverse(); + let det = to_doc.determinant(); + let usable = to_doc.as_coeffs().into_iter().all(f64::is_finite) && det.is_finite() && det.abs() > 1e-18; + usable.then(|| EditedNineSlice { symbol: se.symbol.clone(), guides, art: b, to_doc }) + } + + /// [`Self::place_symbol_art`] for writers that do not apply clipping + /// ([`NineSliceMap::place_unclipped`]). + pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option { + match self.symbol_slice_map(name, xf) { + Some(m) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(art, self)), + None => Some(transformed(art, xf)), + } + } +} + +#[cfg(test)] +mod tests { + use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; + use vectorcraft_geom::{Vec2, shapes}; + + use super::*; + use crate::{Appearance, ImageObject, OpacityMask, PatternDef, PlacedDocument, SYMBOL_SIZES, Symbol}; + + fn square(d: &mut Document, r: Rect, stroke: f64) -> Node { + let s = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), s, stroke)) + } + + /// A 100 × 100 symbol "S": a 10 × 10 square in each corner, one in the center and one at the + /// middle of the top edge, inside a frame with no paint that gives the art its bounds. + fn markers(guides: Option) -> Document { + let mut d = Document::new(400.0, 400.0); + let rects = [ + Rect::new(0.0, 0.0, 100.0, 100.0), + Rect::new(0.0, 0.0, 10.0, 10.0), + Rect::new(90.0, 0.0, 100.0, 10.0), + Rect::new(0.0, 90.0, 10.0, 100.0), + Rect::new(90.0, 90.0, 100.0, 100.0), + Rect::new(45.0, 45.0, 55.0, 55.0), + Rect::new(45.0, 0.0, 55.0, 10.0), + ]; + let art: Vec> = rects.iter().map(|r| Arc::new(square(&mut d, *r, 0.0))).collect(); + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(group) }); + d.set_symbol_nine_slice("S", guides); + d + } + + fn placed(d: &Document, xf: Affine) -> Node { + d.place_symbol_art("S", &d.symbols[0].art, xf).unwrap() + } + + /// The geometric boxes of the placed art's members. + fn boxes(d: &Document, xf: Affine) -> Vec { + placed(d, xf).children().map(|c| c.iter().filter_map(|c| c.geometric_bounds()).collect()).unwrap_or_default() + } + + fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 1e-9) + } + + fn assert_boxes(got: &[Rect], want: &[Rect]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + assert!(near(*g, *w), "{g:?} vs {w:?} in {got:?}"); + } + } + + /// The guides sit a quarter in by default. At 200 × 150 the corners stay 10 × 10, the center + /// marker becomes 30 × 20 and the top marker stretches in width only. + #[test] + fn a_scaled_instance_keeps_its_corners_and_stretches_between_the_guides() { + assert_eq!(SymbolNineSlice::default(), SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }); + let d = markers(Some(SymbolNineSlice::default())); + let got = boxes(&d, Affine::translate((20.0, 30.0)) * Affine::scale_non_uniform(2.0, 1.5)); + let want = [ + Rect::new(0.0, 0.0, 200.0, 150.0), + Rect::new(0.0, 0.0, 10.0, 10.0), + Rect::new(190.0, 0.0, 200.0, 10.0), + Rect::new(0.0, 140.0, 10.0, 150.0), + Rect::new(190.0, 140.0, 200.0, 150.0), + Rect::new(85.0, 65.0, 115.0, 85.0), + Rect::new(85.0, 0.0, 115.0, 10.0), + ] + .map(|r| r + Vec2::new(20.0, 30.0)); + assert_boxes(&got, &want); + // Asymmetric guides: 10 % from the left, 40 % from the right. + let d = markers(SymbolNineSlice::new(0.1, 0.25, 0.4, 0.25)); + let got = boxes(&d, Affine::scale_non_uniform(2.0, 1.0)); + // The middle column runs from 10 to 60 and is stretched to 10..160 (3 times). + assert!(near(got[5], Rect::new(10.0 + 35.0 * 3.0, 45.0, 10.0 + 45.0 * 3.0, 55.0)), "{:?}", got[5]); + assert!(near(got[2], Rect::new(190.0, 0.0, 200.0, 10.0)), "{:?}", got[2]); + } + + /// Without guides, or at the natural size, the transform places the art as it is. + #[test] + fn without_guides_or_scale_the_art_is_placed_by_the_transform() { + let xf = Affine::scale_non_uniform(2.0, 1.5); + let d = markers(None); + assert!(d.symbol_slice_map("S", xf).is_none()); + assert!(near(boxes(&d, xf)[1], Rect::new(0.0, 0.0, 20.0, 15.0))); + let d = markers(Some(SymbolNineSlice::default())); + let moved = Affine::translate((5.0, 6.0)) * Affine::rotate(0.5); + assert!(d.symbol_slice_map("S", moved).is_none()); + assert!(d.symbol_slice_map("S", Affine::scale(0.0)).is_none()); + assert!(d.symbol_slice_map("S", Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])).is_none()); + assert!(d.symbol_slice_map("S", Affine::new([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0])).is_none()); + assert!(d.symbol_slice_map("Missing", xf).is_none()); + } + + /// At 30 × 30 the 25 + 25 of the outer cells do not fit: both shrink in proportion (to 15) and + /// the center is empty, never turned inside out. + #[test] + fn a_target_smaller_than_the_outer_cells_shrinks_them() { + let d = markers(Some(SymbolNineSlice::default())); + let got = boxes(&d, Affine::scale(0.3)); + assert!(near(got[1], Rect::new(0.0, 0.0, 6.0, 6.0)), "{:?}", got[1]); + assert!(near(got[4], Rect::new(24.0, 24.0, 30.0, 30.0)), "{:?}", got[4]); + let c = got[5]; + assert!(c.width().abs() < 1e-9 && c.height().abs() < 1e-9 && near(c, Rect::new(15.0, 15.0, 15.0, 15.0)), "{c:?}"); + for b in boxes(&d, Affine::scale(1e-6)) { + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() >= 0.0 && b.height() >= 0.0, "{b:?}"); + } + // Zero-width guides on one side leave that side's corners out of the outer cells. + let d = markers(SymbolNineSlice::new(0.0, 0.25, 0.25, 0.0)); + let got = boxes(&d, Affine::scale(2.0)); + assert!(near(got[0], Rect::new(0.0, 0.0, 200.0, 200.0)), "{:?}", got[0]); + assert!(got.iter().all(|b| finite(*b)), "{got:?}"); + } + + /// A negative scale reflects the sized cells; a rotation turns them, the corners keeping + /// their size along the symbol's own axes. + #[test] + fn reflection_and_rotation_apply_after_the_cells_are_sized() { + let d = markers(Some(SymbolNineSlice::default())); + let got = boxes(&d, Affine::scale_non_uniform(-2.0, 1.0)); + // The top-left corner square is now at the right, still 10 wide; the center is 30 wide. + assert!(near(got[1], Rect::new(-10.0, 0.0, 0.0, 10.0)), "{:?}", got[1]); + assert!(near(got[5], Rect::new(-115.0, 45.0, -85.0, 55.0)), "{:?}", got[5]); + // Reflected top to bottom (a negative determinant): the corner square stays 10 × 10. + let got = boxes(&d, Affine::scale_non_uniform(2.0, -1.5)); + assert!(near(got[1], Rect::new(0.0, -10.0, 10.0, 0.0)), "{:?}", got[1]); + assert!(near(got[5], Rect::new(85.0, -85.0, 115.0, -65.0)), "{:?}", got[5]); + let turn = Affine::translate((50.0, 50.0)) * Affine::rotate(std::f64::consts::FRAC_PI_6); + let m = d.symbol_slice_map("S", turn * Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let o = m.map_point(Point::ORIGIN); + assert!((o - Point::new(50.0, 50.0)).hypot() < 1e-9); + assert!(((m.map_point(Point::new(10.0, 0.0)) - o).hypot() - 10.0).abs() < 1e-9); + assert!(((m.map_point(Point::new(0.0, 10.0)) - o).hypot() - 10.0).abs() < 1e-9); + assert!(((m.map_point(Point::new(100.0, 0.0)) - o).hypot() - 200.0).abs() < 1e-9); + assert!(((m.map_point(Point::new(0.0, 100.0)) - o).hypot() - 150.0).abs() < 1e-9); + } + + /// A sheared instance sizes the cells along the symbol's axes before the shear: the corner is + /// the unscaled corner sheared, the art's outer corners land where the instance's transform + /// puts them, and the map is continuous at the guides. A shear alone scales nothing. + #[test] + fn a_sheared_instance_keeps_its_corners_along_the_symbols_axes() { + let d = markers(Some(SymbolNineSlice::default())); + let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 10.0, 20.0]); + assert!(d.symbol_slice_map("S", shear).is_none()); + assert!(d.symbol_slice_map("S", Affine::new([1.0, 1.0, 0.0, 1.0, 0.0, 0.0])).is_none(), "a shear along y"); + // A turn by -60° of the shear along y by √3: (1, 0) goes to (2, 0), (0, 1) to (√0.75, 0.5). + let turned = Affine::rotate(-std::f64::consts::FRAC_PI_3) * Affine::new([1.0, 3f64.sqrt(), 0.0, 1.0, 0.0, 0.0]); + assert!((turned * Point::new(1.0, 0.0) - Point::new(2.0, 0.0)).hypot() < 1e-9, "{turned:?}"); + assert!(d.symbol_slice_map("S", turned).is_none()); + let xf = shear * Affine::scale_non_uniform(2.0, 1.5); + let m = d.symbol_slice_map("S", xf).unwrap(); + for p in [Point::new(10.0, 0.0), Point::new(0.0, 10.0), Point::new(10.0, 10.0)] { + assert!((m.map_point(p) - shear * p).hypot() < 1e-9, "{p:?}"); + } + for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0)] { + assert!((m.map_point(p) - xf * p).hypot() < 1e-9, "{p:?}"); + } + for g in [25.0, 75.0] { + let before = m.map_point(Point::new(g - 1e-7, 40.0)); + let after = m.map_point(Point::new(g + 1e-7, 40.0)); + assert!((before - after).hypot() < 1e-5, "{g}"); + } + } + + /// A path that crosses the guides is cut where it crosses them, each piece placed by its cell; + /// its stroke keeps its weight and its live shape becomes a plain path. + #[test] + fn paths_across_the_guides_are_cut_at_them() { + let mut d = markers(Some(SymbolNineSlice::default())); + let diagonal = Node::path( + d.alloc_id(), + shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 100.0)), + Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0), + ); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let placed = m.place(&diagonal, &d); + let pts: Vec = placed.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + let want = [Point::new(0.0, 0.0), Point::new(25.0, 25.0), Point::new(175.0, 125.0), Point::new(200.0, 150.0)]; + assert_eq!(pts.len(), want.len(), "{pts:?}"); + for (p, w) in pts.iter().zip(want) { + assert!((*p - w).hypot() < 1e-9, "{pts:?}"); + } + assert_eq!(placed.appearance.stroke().map(|s| s.width), Some(4.0)); + // A closed curve crossing a guide keeps its shape on each side of it. + let circle = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::default()); + let placed = m.place(&circle, &d); + let b = placed.geometric_bounds().unwrap(); + // The middle column is stretched 3 times and the middle row 2 times past the guides at 25. + assert!(near(b, Rect::new(10.0, 10.0, 25.0 + 25.0 * 3.0, 25.0 + 15.0 * 2.0)), "{b:?}"); + // The two segments that cross the guide at x = 25 between their anchors are cut there. + assert_eq!(placed.path_data().unwrap().anchor_count(), 6, "{placed:?}"); + for (_, _, a) in placed.path_data().unwrap().anchors() { + assert!([a.p.x, a.p.y, a.h_in.x, a.h_out.y].iter().all(|v| v.is_finite())); + } + assert!(placed.path_data().unwrap().is_closed()); + // A path with points that are not finite gives a path, not a crash. + let bad = Node::path(d.alloc_id(), shapes::line(Point::new(f64::NAN, 0.0), Point::new(100.0, f64::INFINITY)), Appearance::default()); + let _ = m.place(&bad, &d); + } + + /// A linked opacity mask is placed through the guides with its object; an unlinked one stays + /// where it is, as any transform leaves it. + #[test] + fn linked_masks_follow_the_guides_and_unlinked_ones_stay() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let mask_art = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + for linked in [true, false] { + let mut mask = crate::OpacityMask::new(mask_art.clone(), true); + mask.linked = linked; + let bar = square(&mut d, Rect::new(0.0, 40.0, 100.0, 60.0), 0.0).with_mask(Some(Box::new(mask))); + let p = m.place(&bar, &d); + let got = p.extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); + let want = if linked { Rect::new(0.0, 0.0, 200.0, 150.0) } else { Rect::new(0.0, 0.0, 100.0, 100.0) }; + assert!(near(got, want), "linked {linked}: {got:?}"); + } + } + + /// An object that lies in one corner keeps its size and kind: a live rectangle stays live and + /// an image keeps its pixel scale. + #[test] + fn objects_in_one_cell_are_placed_by_that_cell() { + let mut d = markers(Some(SymbolNineSlice::default())); + let shape = crate::LiveShape::Rectangle { + w: 6.0, + h: 6.0, + radii: [1.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((92.0, 92.0)), + }; + let mut live = Node::path(d.alloc_id(), shape.to_path(), Appearance::default()); + if let NodeKind::Path { live: l, .. } = &mut live.kind { + *l = Some(Box::new(shape)); + } + let image = ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: Affine::translate((2.0, 2.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&live, &d); + assert!(matches!(p.kind, NodeKind::Path { live: Some(_), .. }), "{p:?}"); + assert!(near(p.geometric_bounds().unwrap(), Rect::new(192.0, 142.0, 198.0, 148.0))); + let p = m.place(&image, &d); + let NodeKind::Image(im) = &p.kind else { panic!("{p:?}") }; + assert_eq!(im.xf, Affine::translate((2.0, 2.0))); + } + + /// A gradient-filled path and an image that cross the guides are drawn in each cell they + /// reach, clipped to it: the pieces' clip areas tile the stretched box with no gap. + #[test] + fn other_art_across_the_guides_is_clipped_to_each_cell() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.id, bar.id); + let cells = p.children().unwrap(); + // Three columns by one row: the bar lies between the horizontal guides. + assert_eq!(cells.len(), 3, "{p:?}"); + let mut width = 0.0; + for c in cells { + assert!(matches!(c.kind, NodeKind::Group { clip: true, .. })); + let clip = c.children().unwrap()[0].geometric_bounds().unwrap(); + width += clip.width(); + let copy = &c.children().unwrap()[1]; + assert!(matches!(copy.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(_)))); + } + // The clip areas cover the 200-point stretched bar and no more. + assert!((width - 200.0).abs() < 1e-9, "{width}"); + assert!(near(p.geometric_bounds().unwrap(), Rect::new(0.0, 55.0, 200.0, 95.0)), "{:?}", p.geometric_bounds()); + // An image across the vertical guides: three pieces, each its own image. + let image = ImageObject { + key: "k".into(), + width: 100, + height: 10, + xf: Affine::translate((0.0, 45.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let p = m.place(&image, &d); + assert!(p.count() as u64 <= MAX_SLICE_GROWTH); + let kids = p.children().unwrap(); + assert_eq!(kids.len(), 3); + assert!(kids.iter().all(|c| matches!(c.children().unwrap()[1].kind, NodeKind::Image(_)))); + // For writers without clipping, the bar is one path cut at the guides and the image one + // image placed by the center cell. + let p = m.place_unclipped(&bar, &d); + assert!(matches!(p.kind, NodeKind::Path { .. }), "{p:?}"); + assert!(near(p.geometric_bounds().unwrap(), Rect::new(0.0, 55.0, 200.0, 95.0)), "{:?}", p.geometric_bounds()); + assert!(matches!(m.place_unclipped(&image, &d).kind, NodeKind::Image(_))); + } + + /// A shadowed, rounded, feathered, half-opaque, multiplied and isolated gradient bar across + /// the guides: its pieces keep the round corners and the feathering and are drawn without the + /// shadow at default transparency, and the group around them carries the shadow, the opacity, + /// the blending mode and the isolation. + #[test] + fn effects_on_art_across_the_guides_apply_to_all_its_cells() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let shadow = crate::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 7, "y": 7, "blur": 5}), visible: true }; + let round = crate::Effect { id: "stylize.roundCorners".into(), params: json!({"radius": 4}), visible: true }; + let feather = crate::Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }; + bar.appearance.effects = vec![round.clone(), feather.clone(), shadow.clone()]; + (bar.opacity, bar.blend, bar.isolate) = (0.5, vectorcraft_color::BlendMode::Multiply, true); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.appearance.effects, vec![shadow.clone()]); + assert_eq!((p.opacity, p.blend, p.isolate), (0.5, vectorcraft_color::BlendMode::Multiply, true)); + let cells = p.children().unwrap(); + assert_eq!(cells.len(), 3, "{p:?}"); + // The copies keep the effects that reshape them or follow their outline. + for c in cells { + let [clip, copy] = c.children().unwrap().as_slice() else { panic!("{c:?}") }; + assert!(clip.appearance.effects.is_empty(), "{clip:?}"); + assert_eq!(copy.appearance.effects, vec![round.clone(), feather.clone()]); + assert!(copy.has_default_transparency(), "{copy:?}"); + } + // A shadow followed by an effect that stays on the copies stays with it, in its order. + bar.appearance.effects = vec![shadow.clone(), round.clone()]; + let p = m.place(&bar, &d); + assert!(p.appearance.effects.is_empty() && p.opacity == 1.0, "{p:?}"); + assert!(p.children().unwrap().iter().all(|c| c.children().unwrap()[1].appearance.effects == vec![shadow.clone(), round.clone()])); + // A hidden effect after it does not keep it on the copies. + let hidden = crate::Effect { visible: false, ..round.clone() }; + bar.appearance.effects = vec![shadow.clone(), hidden.clone()]; + assert_eq!(m.place(&bar, &d).appearance.effects, vec![shadow.clone(), hidden]); + // Without effects the pieces keep the opacity. + bar.appearance.effects.clear(); + let p = m.place(&bar, &d); + assert_eq!(p.opacity, 1.0); + assert!(p.children().unwrap().iter().all(|c| c.children().unwrap()[1].opacity == 0.5)); + } + + /// A gradient bar across the guides with Knockout Shape set keeps it on the group that holds + /// its cells, the object a knockout group reads it from. + #[test] + fn knockout_shape_on_art_across_the_guides_stays_on_its_group() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + bar.knockout_shape = true; + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.children().map(|c| c.len()), Some(3), "{p:?}"); + assert!(p.knockout_shape, "{p:?}"); + } + + /// A locked gradient bar across the guides stays locked: the group that holds its cells is + /// locked too, so Select All does not list it. + #[test] + fn locked_art_across_the_guides_stays_locked() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + bar.locked = true; + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.children().map(|c| c.len()), Some(3), "{p:?}"); + assert!(p.locked, "{p:?}"); + } + + /// An instance that does not fit leaves the budget as it was: it needs room for the most + /// objects placing can make, and a plain one after it still fits. + #[test] + fn a_skipped_instance_leaves_room_for_the_next() { + let mut used = MAX_SYMBOL_NODES - 100; + let mut placed = false; + assert_eq!( + charge_symbol_art(&mut used, 280, || { + placed = true; + ((), 10) + }), + None + ); + assert!(!placed && used == MAX_SYMBOL_NODES - 100); + // Placed, but it made more objects than are left. + assert_eq!(charge_symbol_art(&mut used, 84, || ((), 101)), None); + assert_eq!(used, MAX_SYMBOL_NODES - 100); + assert_eq!(charge_symbol_art(&mut used, 100, || ("plain", 100)), Some("plain")); + assert_eq!(used, MAX_SYMBOL_NODES); + assert_eq!(charge_symbol_art(&mut used, 1, || ((), 1)), None); + // An instance is charged the objects placing made. + let mut used = 0; + assert_eq!(charge_symbol_art(&mut used, 280, || ((), 37)), Some(())); + assert_eq!(used, 37); + } + + /// The bound on what placing makes counts each object in one cell once and each object across + /// the guides by the cells it reaches, and is never below what placing makes. + #[test] + fn the_bound_on_placed_objects_follows_the_cells_they_reach() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + // Six squares in one cell each and a plain frame cut at the guides: the group and its seven. + let art = d.symbols[0].art.clone(); + assert_eq!(m.most_placed(&art, &d), 8); + assert_eq!(m.place(&art, &d).count(), 8); + // Many objects in one corner count one each. + let many: Vec> = (0..1_000).map(|_| Arc::new(square(&mut d, Rect::new(1.0, 1.0, 9.0, 9.0), 0.0))).collect(); + let corner = Node::group(d.alloc_id(), many); + assert_eq!(m.most_placed(&corner, &d), 1_001); + assert_eq!(m.place(&corner, &d).count(), 1_001); + // A gradient bar across three columns: a group, then a clip path and a copy in each. + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + assert_eq!(m.most_placed(&bar, &d), 10); + assert_eq!(m.place(&bar, &d).count(), 10); + // Unclipped, as DXF writes it, the bar is cut at the guides and stays one path. + assert_eq!(m.most_placed_unclipped(&bar, &d), 1); + assert_eq!(m.place_unclipped(&bar, &d).count(), 1); + // Art nested past the depth limit, clipped in all nine cells. + let mut deep = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + for _ in 0..=MAX_SLICE_DEPTH { + deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); + } + let most = m.most_placed(&deep, &d); + assert!(most >= m.place(&deep, &d).count() as u64 && most <= deep.count() as u64 * MAX_SLICE_GROWTH, "{most}"); + } + + /// The bound counts the art of opacity masks, which placing copies into each cell (unlinked) + /// or places through the guides (linked). + #[test] + fn the_bound_on_placed_objects_counts_opacity_masks() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let many: Vec> = (0..1_000).map(|_| Arc::new(square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0))).collect(); + let mask = Node::group(d.alloc_id(), many); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let mut frame = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + for linked in [false, true] { + let mut om = OpacityMask::new(mask.clone(), true); + om.linked = linked; + bar.extras_mut().mask = Some(Box::new(om.clone())); + frame.extras_mut().mask = Some(Box::new(om)); + let group = Node::group(d.alloc_id(), vec![Arc::new(bar.clone()), Arc::new(frame.clone())]); + for art in [&bar, &frame, &group] { + let most = m.most_placed(art, &d); + let placed = m.place(art, &d).count_with_masks() as u64; + assert!(most >= placed && placed > 1_000, "linked {linked}: {most} < {placed}"); + } + } + } + + /// Art that placing through the guides could make more than [`MAX_SYMBOL_NODES`] objects of + /// is not placed. Without clips the same path is cut at the guides once, with one copy of its + /// mask, and is placed; at the natural size it is moved as it is. + #[test] + fn symbol_art_past_the_object_limit_is_not_placed() { + let mut d = markers(Some(SymbolNineSlice::default())); + let leaf = Arc::new(square(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0)); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_500])); + let mut mask = OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_500]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + art.extras_mut().mask = Some(Box::new(mask)); + let xf = Affine::scale_non_uniform(2.0, 1.5); + assert!(d.place_symbol_art("S", &art, xf).is_none()); + assert!(d.place_symbol_art_unclipped("S", &art, xf).is_some()); + assert!(d.place_symbol_art("S", &art, Affine::translate((5.0, 5.0))).is_some()); + } + + /// A symbol instance that crosses the guides is placed as one piece, by the cell its center is + /// in, so copies do not multiply with nesting; one in a corner is placed by the corner. + #[test] + fn nested_instances_are_placed_as_one_piece() { + let mut d = markers(Some(SymbolNineSlice::default())); + let wide = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::new([0.8, 0.0, 0.0, 0.1, 10.0, 45.0]) }); + let corner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::new([0.05, 0.0, 0.0, 0.05, 92.0, 92.0]) }); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let NodeKind::SymbolInstance { xf, .. } = m.place(&wide, &d).kind else { panic!() }; + // Its center is in the center cell, which stretches 3 times across and 2 times down. + assert!((xf.as_coeffs()[0] - 2.4).abs() < 1e-9 && (xf.as_coeffs()[3] - 0.2).abs() < 1e-9, "{xf:?}"); + let NodeKind::SymbolInstance { xf, .. } = m.place(&corner, &d).kind else { panic!() }; + assert_eq!(xf, Affine::new([0.05, 0.0, 0.0, 0.05, 192.0, 142.0])); + } + + /// Type with an inline graphic, a placed document, a brushed stroke and a pattern whose tile + /// holds an instance draw symbol art: across the guides each is placed by its center's cell as + /// one piece, and counted as one, while the same art without them is drawn in each cell. + #[test] + fn art_that_draws_symbol_art_across_the_guides_is_placed_as_one_piece() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let across = Rect::new(0.0, 40.0, 100.0, 60.0); + let one_piece = |d: &Document, n: &Node| { + let p = m.place(n, d); + assert_eq!(std::mem::discriminant(&p.kind), std::mem::discriminant(&n.kind), "{p:?}"); + assert_eq!(m.most_placed(n, d), n.count_with_masks() as u64); + }; + let in_cells = |d: &Document, n: &Node| { + let p = m.place(n, d); + assert_eq!(p.children().map(Vec::len), Some(3), "{p:?}"); + }; + // Type. + let mut text = crate::TextObject::point(Point::ZERO, "Label", crate::CharStyle::default()); + text.kind = crate::TextKind::Area { frame: shapes::rectangle(across) }; + text.xf = Affine::IDENTITY; + let plain = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text.clone()))); + text.runs[0].inline = Some(crate::InlineArt::new("S")); + let inline = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text))); + in_cells(&d, &plain); + one_piece(&d, &inline); + // A placed document. + let doc = PlacedDocument { + link: crate::LinkInfo::new("/art/logo.vectorcraft"), + key: "k".into(), + bounding: false, + page_box: crate::PageBox::Crop, + width: 100.0, + height: 20.0, + xf: Affine::translate((0.0, 40.0)), + placement: Default::default(), + }; + let doc = Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(doc))); + one_piece(&d, &doc); + // A brushed stroke on a gradient bar, in a group so the group's member is the one placed. + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(gradient, Paint::solid(Color::BLACK), 1.0)); + in_cells(&d, &bar); + let mut brushed = bar.clone(); + brushed.appearance.stroke_mut().unwrap().brush = Some("Brush".into()); + one_piece(&d, &brushed); + let group = Node::group(d.alloc_id(), vec![Arc::new(brushed)]); + assert!(matches!(m.place(&group, &d).children().unwrap()[0].kind, NodeKind::Path { .. })); + // Patterns: one whose tile holds an instance (through a second pattern), one whose does not. + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::scale(0.1) }); + let mut tile = square(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0); + d.patterns.push(PatternDef::new("Plain", vec![Arc::new(tile.clone())])); + d.patterns.push(PatternDef::new("Inner", vec![Arc::new(instance)])); + tile.appearance = Appearance::basic(Paint::Pattern { pattern: "Inner".into(), xf: Default::default() }, Paint::None, 0.0).into(); + d.patterns.push(PatternDef::new("Outer", vec![Arc::new(tile)])); + for (name, holds) in [("Plain", false), ("Outer", true)] { + let fill = Paint::Pattern { pattern: name.into(), xf: Default::default() }; + let bar = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(fill, Paint::None, 0.0)); + if holds { one_piece(&d, &bar) } else { in_cells(&d, &bar) } + } + // A hidden stroke painted with that pattern draws nothing: the gradient bar is drawn in + // each cell. + let outer = Paint::Pattern { pattern: "Outer".into(), xf: Default::default() }; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(gradient, outer, 1.0)); + hidden.appearance.stroke_mut().unwrap().visible = false; + in_cells(&d, &hidden); + // A brushed path with solid paint is cut at the guides as one path, drawn once. + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0); + let mut solid = Node::path(d.alloc_id(), shapes::rectangle(across), black); + solid.appearance.stroke_mut().unwrap().brush = Some("Brush".into()); + assert!(matches!(m.place(&solid, &d).kind, NodeKind::Path { .. })); + } + + /// Art nested deeper than [`MAX_SLICE_DEPTH`] is clipped to the cells it reaches, so it still + /// draws with its corners unstretched; paths are not cut at the guides there. + #[test] + fn art_nested_past_the_limit_is_clipped_to_the_cells() { + let mut d = Document::new(400.0, 400.0); + let mut deep = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + for _ in 0..=MAX_SLICE_DEPTH { + deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); + } + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(deep) }); + d.set_symbol_nine_slice("S", Some(SymbolNineSlice::default())); + let mut n = placed(&d, Affine::scale_non_uniform(2.0, 1.5)); + assert!(n.count() as u64 <= d.symbols[0].art.count() as u64 * MAX_SLICE_GROWTH); + for _ in 0..MAX_SLICE_DEPTH { + n = (*n.children().unwrap()[0]).clone(); + } + let cells = n.children().unwrap(); + assert_eq!(cells.len(), 9, "{n:?}"); + assert!(cells.iter().all(|c| matches!(c.kind, NodeKind::Group { clip: true, .. }))); + // The top-left cell's copy is the path moved, at its natural size. + let copy = &cells[0].children().unwrap()[1]; + assert!(near(copy.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)), "{copy:?}"); + } + + /// Symbols made in the app keep their art in a 20 × 20 square and their size apart: the + /// guides cut the art at its natural size. + #[test] + fn the_guides_cut_the_art_at_its_natural_size() { + let mut d = Document::new(400.0, 400.0); + let corner = square(&mut d, Rect::new(-10.0, -10.0, -8.0, -8.0), 0.0); + let frame = square(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), 0.0); + let group = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(corner)]); + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(group) }); + d.unknown.insert(SYMBOL_SIZES.into(), json!({ "S": [100.0, 100.0] })); + d.set_symbol_nine_slice("S", Some(SymbolNineSlice::default())); + // 200 × 150 is 10 × 7.5 times the 20 × 20 art. + let got = boxes(&d, Affine::scale_non_uniform(10.0, 7.5)); + assert!(near(got[0], Rect::new(-100.0, -75.0, 100.0, 75.0)), "{:?}", got[0]); + assert!(near(got[1], Rect::new(-100.0, -75.0, -90.0, -65.0)), "{:?}", got[1]); + // At its natural size the guides change nothing. + assert!(d.symbol_slice_map("S", Affine::scale(5.0)).is_none()); + } + + /// A scaled instance with strokes in its art is measured by placing the art through the + /// guides only while placing it stays within the limit; past it, the art is not placed and + /// the instance is measured by its art box and the strokes' reach. + #[test] + fn a_stroked_instance_past_the_limit_is_measured_without_placing_its_art() { + let mut d = markers(Some(SymbolNineSlice::default())); + let ring = square(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), 4.0); + let mut art = (*d.symbols[0].art).clone(); + art.children_mut().unwrap().push(Arc::new(ring)); + d.symbols[0].art = Arc::new(art); + let xf = Affine::scale_non_uniform(2.0, 1.5); + let most = d.symbol_slice_map("S", xf).unwrap().most_placed(&d.symbols[0].art, &d); + let whole = Rect::new(0.0, 0.0, 200.0, 150.0); + let boxes = d.symbol_boxes(); + let placed = boxes.placed_visual_within("S", xf, most); + assert!(placed.is_some_and(|b| near(b, whole)), "{placed:?}"); + assert_eq!(boxes.placed_visual_within("S", xf, most - 1), None); + // Within the real limit the instance's visual box is the placed art's. + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + let v = inst.visual_bounds_with(&d.symbol_boxes()); + assert!(v.is_some_and(|b| near(b, whole)), "{v:?}"); + } + + /// Symbol "S" of [`markers`] edited in symbol editing mode, its art placed by `xf`. + fn editing(guides: Option, xf: Affine) -> Document { + let mut d = markers(guides); + let layer = d.layers[0].id; + d.symbol_edit = Some(crate::SymbolEdit { + symbol: "S".into(), + layer, + instance: None, + xf, + group: true, + changed: false, + back_isolation: None, + back_layer: None, + }); + d + } + + fn near_point(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-9 + } + + /// While a symbol with guides is edited, its four guides cross its art's box from edge to edge + /// where the art shows on the editing layer: here at 25 % in, the art doubled and moved. + #[test] + fn the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it() { + let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); + let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); + assert_eq!(e.symbol, "S"); + let want = [ + (NineSliceGuide::Left, Point::new(60.0, 20.0), Point::new(60.0, 220.0)), + (NineSliceGuide::Top, Point::new(10.0, 70.0), Point::new(210.0, 70.0)), + (NineSliceGuide::Right, Point::new(160.0, 20.0), Point::new(160.0, 220.0)), + (NineSliceGuide::Bottom, Point::new(10.0, 170.0), Point::new(210.0, 170.0)), + ]; + for ((g, a, b), (wg, wa, wb)) in e.lines().into_iter().zip(want) { + assert!(g == wg && near_point(a, wa) && near_point(b, wb), "{g:?} {a:?} {b:?}"); + } + // Outside the mode, without guides, or for a symbol that is gone: no guides. + assert!(markers(Some(SymbolNineSlice::default())).edited_nine_slice().is_none()); + assert!(editing(None, xf).edited_nine_slice().is_none()); + let mut d = editing(Some(SymbolNineSlice::default()), xf); + d.symbols.clear(); + assert!(d.edited_nine_slice().is_none()); + } + + /// Moving a guide changes that guide alone, follows the pointer across its axis only, stays + /// within the art's box and stops short of the opposite guide. + #[test] + fn a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide() { + let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); + let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); + let q = SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }; + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(30.0, 999.0)), SymbolNineSlice { left: 0.1, ..q }); + assert_eq!(e.moved(NineSliceGuide::Bottom, Point::new(-50.0, 180.0)), SymbolNineSlice { bottom: 0.2, ..q }); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: 0.0, ..q }); + assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: 0.0, ..q }); + let crossed = e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)); + assert!((crossed.left - (0.75 - MIN_CENTER_BAND)).abs() < 1e-12, "{crossed:?}"); + assert!(SymbolNineSlice::new(crossed.left, crossed.top, crossed.right, crossed.bottom).is_some()); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(f64::NAN, f64::NAN)), q); + // Saved guides closer than the minimum center band keep their place when a drag does not + // move them closer. + let narrow = SymbolNineSlice::new(0.00005, 0.25, 0.9999, 0.25).unwrap(); + let e = editing(Some(narrow), xf).edited_nine_slice().unwrap(); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)), narrow); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: 0.0, ..narrow }); + // A mirrored placement measures along the symbol's own axes. + let e = editing(Some(SymbolNineSlice::default()), Affine::scale_non_uniform(-1.0, 1.0)).edited_nine_slice().unwrap(); + assert!(near_point(e.line(NineSliceGuide::Left).0, Point::new(-25.0, 0.0))); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-10.0, 0.0)), SymbolNineSlice { left: 0.1, ..q }); + } +} diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs new file mode 100644 index 000000000..b4e3a726f --- /dev/null +++ b/crates/doc/src/symbol_overrides.rs @@ -0,0 +1,190 @@ +//! Dynamic symbols (Symbol Options › Symbol Type › Dynamic Symbol): an instance of a dynamic +//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays +//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops +//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and +//! applied wherever an instance is drawn or exported ([`dynamic_art`]). + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use vectorcraft_color::{BlendMode, Paint}; + +use crate::{Node, NodeKind}; + +/// Deepest object an override may name: groups nested this deep in the symbol's art. +pub const MAX_PATH_DEPTH: usize = 64; + +/// One instance's appearance changes to one object of its dynamic symbol's art. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SymbolOverride { + /// The object: child indices (paint order) from the art's root down through its groups; empty + /// for the root itself. + #[serde(deserialize_with = "short_path")] + pub path: Vec, + /// The object's (top) fill paint; type takes it as its characters' fill. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill: Option, + /// The object's (top) stroke paint; type takes it as its characters' stroke. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke: Option, + /// The object's opacity, 0..1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// The object's blending mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blend: Option, +} + +impl SymbolOverride { + /// Does this override change nothing? + pub fn is_empty(&self) -> bool { + self.fill.is_none() && self.stroke.is_none() && self.opacity.is_none() && self.blend.is_none() + } + + /// Put this override's changes on object `n`. + fn apply(&self, n: &mut Node) { + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + if let Some(p) = &self.fill { + r.style.fill = p.clone(); + } + if let Some(p) = &self.stroke { + r.style.stroke = p.clone(); + } + } + } else { + if let Some(p) = &self.fill { + n.appearance.set_fill(p.clone()); + } + if let Some(p) = &self.stroke { + n.appearance.set_stroke(p.clone()); + } + } + if let Some(o) = self.opacity.filter(|o| o.is_finite()) { + n.opacity = o.clamp(0.0, 1.0); + } + if let Some(b) = self.blend { + n.blend = b; + } + } +} + +/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file +/// of one endless path stays small once read. +fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct Path; + impl<'de> serde::de::Visitor<'de> for Path { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of child indices") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(i) = seq.next_element::()? { + if out.len() == MAX_PATH_DEPTH { + return Err(serde::de::Error::custom(format!("a symbol override path is deeper than {MAX_PATH_DEPTH}"))); + } + out.push(i); + } + Ok(out) + } + } + d.deserialize_seq(Path) +} + +/// The children a path steps through: a group's (clip groups included) or a layer's. +fn groups_children(n: &Node) -> Option<&Vec>> { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }).then(|| n.children()).flatten() +} + +/// The object `path` names in symbol art `art`, if there is one. +pub fn part<'a>(art: &'a Node, path: &[u32]) -> Option<&'a Node> { + if path.len() > MAX_PATH_DEPTH { + return None; + } + path.iter().try_fold(art, |n, &i| groups_children(n)?.get(i as usize).map(|c| &**c)) +} + +/// [`part`], to change (the nodes on the way are copied when shared). +fn part_mut<'a>(art: &'a mut Node, path: &[u32]) -> Option<&'a mut Node> { + let Some((&i, rest)) = path.split_first() else { return Some(art) }; + if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) { + return None; + } + let c = art.children_mut()?.get_mut(i as usize)?; + part_mut(Arc::make_mut(c), rest) +} + +/// Put `overrides` on symbol art `art`; an override naming no object of it is left out. +pub fn apply(art: &mut Node, overrides: &[SymbolOverride]) { + for o in overrides.iter().filter(|o| o.path.len() <= MAX_PATH_DEPTH) { + if let Some(n) = part_mut(art, &o.path) { + o.apply(n); + } + } +} + +/// Symbol art `art` as instance `inst` shows it: a copy with the instance's overrides applied. +pub fn dynamic_art(art: &Node, inst: &Node) -> Node { + let mut a = art.clone(); + apply(&mut a, &inst.extras().symbol_overrides); + a +} + +/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest. +pub fn retain_parts(overrides: &mut Vec, art: &Node) { + overrides.retain(|o| part(art, &o.path).is_some()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, NodeId}; + use vectorcraft_color::Color; + use vectorcraft_geom::{Rect, shapes}; + + fn square(id: u64) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())) + } + + fn art() -> Node { + let inner = Node::group(NodeId(3), vec![square(4), square(5)]); + Node::group(NodeId(1), vec![square(2), Arc::new(inner)]) + } + + #[test] + fn paths_name_objects_through_groups_only() { + let a = art(); + assert_eq!(part(&a, &[]).map(|n| n.id), Some(NodeId(1))); + assert_eq!(part(&a, &[1, 0]).map(|n| n.id), Some(NodeId(4))); + assert!(part(&a, &[2]).is_none()); + assert!(part(&a, &[0, 0]).is_none(), "a path has no children"); + assert!(part(&a, &[0; MAX_PATH_DEPTH + 1]).is_none()); + } + + #[test] + fn overrides_paint_their_object_and_skip_missing_ones() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let o = vec![ + SymbolOverride { path: vec![1, 1], fill: Some(red.clone()), opacity: Some(0.5), ..Default::default() }, + SymbolOverride { path: vec![9], fill: Some(red.clone()), ..Default::default() }, + ]; + let mut a = art(); + apply(&mut a, &o); + let n = part(&a, &[1, 1]).unwrap(); + assert_eq!(n.appearance.fill_paint(), red); + assert_eq!(n.opacity, 0.5); + assert_eq!(part(&a, &[1, 0]).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE), "its sibling keeps the symbol's look"); + let mut kept = o; + retain_parts(&mut kept, &a); + assert_eq!(kept.len(), 1); + } + + #[test] + fn a_path_deeper_than_the_cap_does_not_read() { + let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]); + assert!(serde_json::from_str::(&deep).is_err()); + let ok: SymbolOverride = serde_json::from_str("{\"path\":[1,0],\"opacity\":0.25}").unwrap(); + assert_eq!(ok, SymbolOverride { path: vec![1, 0], opacity: Some(0.25), ..Default::default() }); + } +} diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 8f095566b..7e371c670 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use vectorcraft_geom::{Affine, Rect}; -use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF}; +use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF}; /// The box a symbol instance is measured by when its symbol's art has no box /// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin @@ -303,17 +303,28 @@ impl<'a> SymbolBoxes<'a> { } } - /// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it, - /// its strokes at their own weight, the instances in it measured by their art boxes. `None` - /// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a - /// click's box is measured. + /// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it + /// (through its 9-slice guides when it has them, [`Document::place_symbol_art`]), its strokes + /// at their own weight, the instances in it measured by their art boxes. `None` + /// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], while symbol art or a + /// click's box is measured, and when placing the art through its guides could make more than + /// [`MAX_SYMBOL_NODES`] objects ([`crate::NineSliceMap::most_placed`]). pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option { + self.placed_visual_within(name, xf, MAX_SYMBOL_NODES) + } + + /// [`Self::placed_visual`], `None` when placing the art through its guides could make more + /// than `most` objects. + pub(crate) fn placed_visual_within(&self, name: &str, xf: Affine, most: u64) -> Option { if self.quick.get() { return None; } self.measure_art(name, |art| { - let mut art = (**art).clone(); - art.transform(xf, false); + let art = match self.doc.symbol_slice_map(name, xf) { + Some(m) if m.most_placed(art, self.doc) > most => return None, + Some(m) => m.place(art, self.doc), + None => self.doc.place_symbol_art(name, art, xf)?, + }; self.in_art(|| art.visual(Some(self))) }) .flatten() @@ -410,7 +421,7 @@ mod tests { /// Symbol `name` holding `art` in a group. fn symbol(d: &mut Document, name: &str, art: Vec) { let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect()); - d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) }); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) }); } #[test] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a13fb2168..37e2d39bc 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -55,6 +55,29 @@ fn doc_with_rects() -> (Document, NodeId, NodeId) { (d, a, b) } +/// Output draws the background colors of the artboards asked for, and only theirs, as rectangles +/// of those artboards on a layer below the art; without a color there is nothing to draw. +#[test] +fn artboard_fills_go_below_the_art() { + let (mut d, a, _) = doc_with_rects(); + assert!(d.with_artboard_fills([0]).is_none(), "no color, no fill"); + let second = Rect::new(700.0, 0.0, 800.0, 100.0); + d.artboards.push(Artboard { id: 2, rect: second, ..d.artboards[0].clone() }); + let blue = vectorcraft_color::Color::cmyk(1.0, 0.5, 0.0, 0.0); + d.artboards[1].color = Some(blue); + assert!(d.with_artboard_fills([0]).is_none(), "only the artboards asked for"); + let out = d.with_artboard_fills([0, 1]).unwrap(); + assert_eq!(out.layers.len(), d.layers.len() + 1); + assert_eq!(out.layers[1..], d.layers[..], "the art stays as it was, above the fills"); + let fills = out.layers[0].children().unwrap(); + assert_eq!(out.layers[0].display_name(), "Artboard Fill"); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].geometric_bounds(), Some(second)); + assert_eq!(fills[0].appearance.fill_paint(), vectorcraft_color::Paint::solid(blue), "in the color's own model"); + assert!(out.node(a).is_some() && out.node(fills[0].id).is_some() && fills[0].id != a); + assert_eq!(d.artboards, out.artboards); +} + #[test] fn new_document_has_layer_and_artboard() { let d = Document::new(612.0, 792.0); @@ -202,7 +225,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { fn art_on_artboard_measures_a_symbol_instance_by_its_art() { let mut d = Document::new(612.0, 792.0); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) }); let (layer, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap(); // The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't. @@ -388,6 +411,18 @@ fn char_position_round_trips() { assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); } +#[test] +fn optical_kerning_round_trips_and_old_style_defaults_to_auto() { + let st = CharStyle { optical_kerning: true, kerning: Some(120.0), ..CharStyle::default() }; + let value = serde_json::to_value(&st).unwrap(); + assert_eq!(value["opticalKerning"], true); + assert_eq!(serde_json::from_value::(value.clone()).unwrap(), st); + let mut old = value; + old.as_object_mut().unwrap().remove("opticalKerning"); + assert!(!serde_json::from_value::(old).unwrap().optical_kerning); + assert!(serde_json::to_value(CharStyle::default()).unwrap().get("opticalKerning").is_none()); +} + #[test] fn units() { assert_eq!(Unit::Inches.parse("1"), Some(72.0)); diff --git a/crates/doc/src/tests/units.rs b/crates/doc/src/tests/units.rs index 909a0adf3..5a9265c70 100644 --- a/crates/doc/src/tests/units.rs +++ b/crates/doc/src/tests/units.rs @@ -144,3 +144,87 @@ fn mixed_mul_div() { assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0)); assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0))); } + +/// Q and Ha are a quarter of a millimetre each, written back and read in any field, with or +/// without their suffix. +#[test] +fn q_and_ha_are_a_quarter_millimetre() { + for u in [Unit::Q, Unit::Ha] { + assert!((u.points() - Unit::Millimeters.points() / 4.0).abs() < 1e-12, "{u:?}"); + assert!((u.to_pt(4.0) - Unit::Millimeters.to_pt(1.0)).abs() < 1e-12, "{u:?}"); + let tol = 0.0005 * u.points() + 1e-9; + for pt in [0.0, 1.0, 12.5, 72.0, 100.0, 595.28, 1234.5678, -36.0] { + let shown = u.format(pt); + let back = u.parse(&shown).unwrap_or_else(|| panic!("{u:?} can't read back `{shown}`")); + assert!((back - pt).abs() <= tol, "{u:?}: {pt} → `{shown}` → {back}"); + let bare = u.parse(&u.number(pt)).unwrap(); + assert!((bare - pt).abs() <= tol, "{u:?}: bare `{}` → {bare}", u.number(pt)); + let pts = Unit::Points.parse(&shown).unwrap(); + assert!((pts - pt).abs() <= tol, "{u:?}: `{shown}` in a points field → {pts}"); + } + } + assert_eq!(Unit::Q.format(72.0 / 25.4), "4 Q"); + assert_eq!(Unit::Ha.format(Unit::Millimeters.to_pt(2.5)), "10 H"); + // 13 Q is 3.25 mm, however it is typed. + let want = Unit::Millimeters.to_pt(3.25); + for typed in ["13q", "13 Q", "13Q+0", "26/2 q"] { + let got = Unit::Points.parse(typed).unwrap_or_else(|| panic!("`{typed}` reads as nothing")); + assert!((got - want).abs() < 1e-9, "`{typed}` → {got}"); + } + assert!((Unit::Q.parse("13").unwrap() - want).abs() < 1e-9, "a bare number is in the field's unit"); + assert!((Unit::Millimeters.parse("13 h").unwrap() - want).abs() < 1e-9, "a typed unit wins over the field's"); + for typed in ["13h", "13 H", "13Ha", "13 ha"] { + assert!((Unit::Points.parse(typed).unwrap() - want).abs() < 1e-9, "`{typed}`"); + } + // Mixed with other units. + assert!((Unit::Points.parse("4q+1mm").unwrap() - Unit::Millimeters.to_pt(2.0)).abs() < 1e-9); +} + +/// Only Preferences › Units › East Asian Type offers Q and Ha: the list every other place shares +/// leaves them out, and `named` answers for it alone. +#[test] +fn only_the_east_asian_type_list_names_q_and_ha() { + assert_eq!(Unit::EAST_ASIAN[..10], Unit::ALL); + assert_eq!(Unit::EAST_ASIAN[10..], [Unit::Q, Unit::Ha]); + for u in Unit::EAST_ASIAN { + assert_eq!(Unit::named_east_asian(u.label()), Some(u)); + assert_eq!(Unit::named_east_asian(u.key()), Some(u)); + assert_eq!(Unit::named_east_asian(&u.label().to_uppercase()), Some(u)); + assert_eq!(Unit::named_east_asian(u.suffix()), if u == Unit::FeetInches { Some(Unit::Feet) } else { Some(u) }); + } + for name in ["q", "Q", "ha", "Ha", "H", "h"] { + assert_eq!(Unit::named(name), None, "`{name}`"); + } + assert_eq!(Unit::named_east_asian("Q"), Some(Unit::Q)); + assert_eq!(Unit::named_east_asian("h"), Some(Unit::Ha)); + assert_eq!(Unit::named_east_asian("Ha"), Some(Unit::Ha)); + assert_eq!(Unit::named_east_asian("furlongs"), None); +} + +#[test] +fn readout_has_no_negative_zero() { + assert_eq!(Unit::Points.readout(-0.001), "0.00 pt"); + assert_eq!(Unit::Points.readout(-0.0), "0.00 pt"); + assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt"); +} + +/// Q and Ha take the suffixes `q` and `h` (`ha` too) without taking anyone else's: every unit's +/// own suffix still reads as that unit in a points field, a Q or Ha value is never read as +/// another unit, and `h`, `ha` and `q` mean nothing to a unit that is not offered Q and Ha when +/// they are typed as words that are not suffixes (`1hr`, `1hq`, `1qt`). +#[test] +fn q_and_ha_suffixes_do_not_take_other_units_suffixes() { + for u in Unit::EAST_ASIAN { + if matches!(u, Unit::FeetInches) { + continue; + } + let typed = format!("3{}", u.suffix()); + let got = Unit::Points.parse(&typed).unwrap_or_else(|| panic!("`{typed}` reads as nothing")); + assert!((got - u.to_pt(3.0)).abs() < 1e-9, "`{typed}` is 3 {u:?}: {got}"); + } + for typed in ["1hr", "1hq", "1qt", "1qq", "1hh", "1hm", "1hectare"] { + assert_eq!(Unit::Points.parse(typed), None, "`{typed}`"); + } + // A suffix after math covers the whole expression, as for the other units. + assert!((Unit::Points.parse("26/2 q").unwrap() - Unit::Q.to_pt(13.0)).abs() < 1e-9); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a3ab2ccc5..064ecbda1 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -12,16 +12,18 @@ mod object; mod para_style; mod runs; mod tabs_lists; +mod wrap; -pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape}; +pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; +pub use wrap::{resolve_wrap, wrap_work}; /// A text object. `runs` split into paragraphs at `\n`. /// diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index b30afd412..515f52627 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -29,6 +29,19 @@ impl VerticalAlign { } } +/// Area Type Options › Text Flow: the order text fills the cells of area type with several rows +/// and columns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextFlow { + /// Down each column's rows, then on to the next column (the default, and what a document + /// from before the option reads as). + #[default] + Columns, + /// Across each row's columns, then on to the next row. + Rows, +} + /// Text Wrap Options of a wrap object (Object → Text Wrap). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -128,6 +141,10 @@ pub struct AreaOptions { pub columns: usize, /// Gutter between rows/columns in points. pub gutter: f64, + /// Gutter between rows in points when it differs from [`Self::gutter`], which is then the + /// gutter between columns (left out when saved while unset). + #[serde(skip_serializing_if = "Option::is_none")] + pub row_gutter: Option, /// Inset from the frame edges in points. pub inset: f64, pub first_baseline: FirstBaseline, @@ -135,6 +152,9 @@ pub struct AreaOptions { pub first_baseline_min: f64, /// Vertical alignment of the lines in each row/column. pub vertical_align: VerticalAlign, + /// Text Flow: by columns (the default, left out when saved) or by rows. + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub flow: TextFlow, /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, @@ -146,10 +166,12 @@ impl Default for AreaOptions { rows: 1, columns: 1, gutter: 18.0, + row_gutter: None, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, vertical_align: VerticalAlign::Top, + flow: TextFlow::Columns, fit: AreaFit::None, } } diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 0e3aea70e..5461f005e 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -25,9 +25,12 @@ pub struct CharStyle { /// Tracking in 1/1000 em. #[serde(default)] pub tracking: f64, - /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. + /// Manual pair spacing in 1/1000 em; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option, + /// Use glyph shapes for automatic pair spacing; false uses font metrics. + #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] + pub optical_kerning: bool, #[serde(default)] pub baseline_shift: f64, #[serde(default = "hundred")] @@ -90,6 +93,10 @@ pub struct CharStyle { /// them. #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] pub char_direction: Option, + /// Character panel menu › Kashidas: whether these characters may receive a kashida when + /// their paragraph is justified with Insert Kashidas. On by default, including old documents. + #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")] + pub kashidas: bool, /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// its proportional heights, `vpal`. @@ -101,21 +108,66 @@ pub struct CharStyle { /// #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] pub tate_chu_yoko: bool, + /// Tate-chu-yoko Settings › Up/Down (Character panel menu, East Asian options), in points: a + /// block of marked characters in vertical type moves up by a positive value and down by a + /// negative one. The block takes the value of its first character; automatic digit blocks and + /// horizontal type do not move. Saved only when not 0. + /// + #[serde(default, rename = "tcyUpDown", skip_serializing_if = "crate::skip::is_default")] + pub tcy_up_down: f64, + /// Tate-chu-yoko Settings › Left/Right, in points: a block of marked characters in vertical type + /// moves right by a positive value and left by a negative one, as [`Self::tcy_up_down`] does. + #[serde(default, rename = "tcyLeftRight", skip_serializing_if = "crate::skip::is_default")] + pub tcy_left_right: f64, + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): in vertical + /// type the half-width characters among these (Latin letters, digits and other characters that + /// would lie on their side) stand upright one below the other instead of being rotated + /// individually. Spaces and tate-chu-yoko blocks do not change, and horizontal type keeps the + /// mark and does not change. + /// + #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] + pub standard_vertical_roman: bool, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub digits: Digits, + /// Font height reference (Character panel menu › Show Font Height Options): the height of the + /// font that [`Self::size`] measures, the em box by default. + #[serde(default, rename = "fontHeightReference", skip_serializing_if = "crate::skip::is_default")] + pub font_height_reference: FontHeightReference, /// No Break (Character panel menu): these characters are not broken across lines, including /// at a space or a hyphenation point inside the run. A run longer than the line stays together /// and can overflow it. /// #[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")] pub no_break: bool, + /// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these + /// characters are shaped in, so that the font uses that language's forms (OpenType `locl`). + /// `None`: the default language, English: USA, shaped without a language. New type takes the + /// document's language (Document Setup). Saved only when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + +/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square +/// of the font, the height of its capitals or of its lowercase letters (the cap height and x +/// height of its OS/2 table), or its ideographic character face (ICF, OpenType's `icfb` and +/// `icft` baselines). Layout sets the characters at the em size at which the chosen height equals +/// the font size; with a font that has no such value, the size measures the em box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FontHeightReference { + #[default] + EmBox, + CapHeight, + XHeight, + IcfBox, } /// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of -/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps -/// the digits that were typed; only the glyphs change (like All Caps). +/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits +/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like +/// All Caps). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Digits { @@ -128,9 +180,68 @@ pub enum Digits { Hindi, /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. Farsi, + /// Devanagari digits ०–९ (U+0966–U+096F), used in Hindi, Marathi, Nepali and Sanskrit. + Devanagari, + /// Bengali digits ০–৯ (U+09E6–U+09EF), used in Bengali and Assamese. + Bengali, + /// Gurmukhi digits ੦–੯ (U+0A66–U+0A6F), used in Punjabi. + Gurmukhi, + /// Gujarati digits ૦–૯ (U+0AE6–U+0AEF). + Gujarati, + /// Odia digits ୦–୯ (U+0B66–U+0B6F). + Odia, + /// Tamil digits ௦–௯ (U+0BE6–U+0BEF). + Tamil, + /// Telugu digits ౦–౯ (U+0C66–U+0C6F). + Telugu, + /// Kannada digits ೦–೯ (U+0CE6–U+0CEF). + Kannada, + /// Malayalam digits ൦–൯ (U+0D66–U+0D6F). + Malayalam, } impl Digits { + /// Every numeral system, in menu order. + pub const ALL: [Digits; 13] = [ + Digits::Default, + Digits::Arabic, + Digits::Hindi, + Digits::Farsi, + Digits::Devanagari, + Digits::Bengali, + Digits::Gurmukhi, + Digits::Gujarati, + Digits::Odia, + Digits::Tamil, + Digits::Telugu, + Digits::Kannada, + Digits::Malayalam, + ]; + + /// The name files and commands give this numeral system (as it is saved). + pub fn id(self) -> &'static str { + match self { + Digits::Default => "default", + Digits::Arabic => "arabic", + Digits::Hindi => "hindi", + Digits::Farsi => "farsi", + Digits::Devanagari => "devanagari", + Digits::Bengali => "bengali", + Digits::Gurmukhi => "gurmukhi", + Digits::Gujarati => "gujarati", + Digits::Odia => "odia", + Digits::Tamil => "tamil", + Digits::Telugu => "telugu", + Digits::Kannada => "kannada", + Digits::Malayalam => "malayalam", + } + } + + /// The numeral system [`Digits::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + Digits::ALL.into_iter().find(|d| d.id() == id) + } + /// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c` /// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set. pub fn map(self, c: char) -> char { @@ -145,6 +256,15 @@ impl Digits { Digits::Arabic => '0' as u32, Digits::Hindi => 0x660, Digits::Farsi => 0x6F0, + Digits::Devanagari => 0x966, + Digits::Bengali => 0x9E6, + Digits::Gurmukhi => 0xA66, + Digits::Gujarati => 0xAE6, + Digits::Odia => 0xB66, + Digits::Tamil => 0xBE6, + Digits::Telugu => 0xC66, + Digits::Kannada => 0xCE6, + Digits::Malayalam => 0xD66, }; char::from_u32(base + value).unwrap_or(c) } @@ -266,6 +386,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, v_scale: 100.0, @@ -288,11 +409,17 @@ impl Default for CharStyle { position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, char_direction: None, + kashidas: true, proportional_metrics: false, tate_chu_yoko: false, + tcy_up_down: 0.0, + tcy_left_right: 0.0, + standard_vertical_roman: false, small_caps: None, digits: Digits::Default, + font_height_reference: FontHeightReference::EmBox, no_break: false, + language: None, } } } @@ -305,7 +432,8 @@ impl CharStyle { /// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading /// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way) /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a - /// baseline shift that is not a number becomes 0. + /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right + /// offsets are kept as the baseline shift is. pub fn keep_dimensions_in_range(&mut self) { let default = Self::default(); let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; @@ -313,7 +441,10 @@ impl CharStyle { self.h_scale = within(self.h_scale, default.h_scale); self.v_scale = within(self.v_scale, default.v_scale); self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD)); - self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) }; + let shift = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(-crate::MAX_COORD, crate::MAX_COORD) }; + self.baseline_shift = shift(self.baseline_shift); + self.tcy_up_down = shift(self.tcy_up_down); + self.tcy_left_right = shift(self.tcy_left_right); } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index de77da298..c6a22a29c 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -32,8 +32,13 @@ impl TextObject { style.size *= scale.points; style.leading = style.leading.map(|v| v * scale.points); style.baseline_shift *= scale.points; + style.tcy_up_down *= scale.points; + style.tcy_left_right *= scale.points; style.h_scale *= scale.horizontal; - if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) { + if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale] + .iter() + .all(|v| v.is_finite()) + { return None; } Some(style) @@ -430,12 +435,16 @@ impl crate::Document { st.h_scale = num("h_scale").unwrap_or(st.h_scale); st.v_scale = num("v_scale").unwrap_or(st.v_scale); st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); + st.tcy_up_down = num("tcyUpDown").unwrap_or(st.tcy_up_down); + st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.keep_dimensions_in_range(); let kept = [ ("size", Some(st.size)), ("h_scale", Some(st.h_scale)), ("v_scale", Some(st.v_scale)), ("baseline_shift", Some(st.baseline_shift)), + ("tcyUpDown", Some(st.tcy_up_down)), + ("tcyLeftRight", Some(st.tcy_left_right)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 47106d788..b66795e89 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -223,6 +223,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, strikethrough: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 54142efd7..04deae8ee 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -60,6 +60,69 @@ fn digits_map_between_the_numeral_systems() { assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); } +/// The font height reference is the em box by default and is saved only when it is another. +#[test] +fn font_height_reference_is_saved_only_when_not_the_em_box() { + let st = CharStyle::default(); + assert_eq!(st.font_height_reference, FontHeightReference::EmBox); + assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none()); + for (r, key) in + [(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")] + { + let set = CharStyle { font_height_reference: r, ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + // Styles written before the attribute existed read as the em box. + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().font_height_reference, FontHeightReference::EmBox); +} + +/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its +/// script's block; every system saves under its id, which reads back, and other names are refused. +#[test] +fn indic_digits_map_to_their_scripts_block() { + let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; + for (d, zero) in [ + (Digits::Devanagari, 0x966), + (Digits::Bengali, 0x9E6), + (Digits::Gurmukhi, 0xA66), + (Digits::Gujarati, 0xAE6), + (Digits::Odia, 0xB66), + (Digits::Tamil, 0xBE6), + (Digits::Telugu, 0xC66), + (Digits::Kannada, 0xCE6), + (Digits::Malayalam, 0xD66), + ] { + let block: String = (zero..zero + 10).map(|u| char::from_u32(u).unwrap()).collect(); + assert!(block.chars().all(char::is_numeric), "{d:?}: {block}"); + for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { + assert_eq!(map(d, s), block, "{d:?}"); + } + assert_eq!(map(d, "a-Z ٪"), "a-Z ٪"); + } + assert_eq!(map(Digits::Devanagari, "page 12"), "page १२"); + for d in Digits::ALL { + assert_eq!(serde_json::to_value(d).unwrap(), d.id()); + assert_eq!(serde_json::from_value::(d.id().into()).unwrap(), d); + assert_eq!(Digits::from_id(d.id()), Some(d)); + } + assert_eq!(Digits::from_id("roman"), None); + assert!(serde_json::from_value::("roman".into()).is_err()); +} + +/// Language is saved only when set; styles written before it existed read as the default. +#[test] +fn language_is_saved_only_when_set() { + let st = CharStyle::default(); + let old = serde_json::to_value(&st).unwrap(); + assert!(old.get("language").is_none()); + assert_eq!(serde_json::from_value::(old).unwrap().language, None); + let turkish = CharStyle { language: Some("tr".into()), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&turkish).unwrap()["language"], "tr"); + assert_eq!(serde_json::from_value::(serde_json::to_value(&turkish).unwrap()).unwrap(), turkish); +} + /// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. #[test] fn paragraph_effective_leading_follows_auto_leading() { @@ -248,6 +311,19 @@ fn paragraph_styles_split_merge_and_normalize() { assert_eq!(back.para_at(3).space_before, 4.0); } +/// Text Flow is saved only when by rows: area type by columns (the default) saves as it did before +/// the option, and files from before it read by columns; other values don't read. +#[test] +fn text_flow_is_saved_only_when_by_rows() { + let by_columns = serde_json::to_value(AreaOptions::default()).unwrap(); + assert!(by_columns.get("flow").is_none()); + assert_eq!(serde_json::from_value::(by_columns).unwrap().flow, TextFlow::Columns); + let by_rows = serde_json::to_value(AreaOptions { flow: TextFlow::Rows, ..AreaOptions::default() }).unwrap(); + assert_eq!(by_rows["flow"], "rows"); + assert_eq!(serde_json::from_value::(by_rows).unwrap().flow, TextFlow::Rows); + assert!(serde_json::from_value::(serde_json::json!({"flow": "diagonal"})).is_err()); +} + /// 120 × 40 area type at (40, 40), its text drawn at twice its size. fn area_type() -> TextObject { let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); diff --git a/crates/doc/src/text/wrap.rs b/crates/doc/src/text/wrap.rs new file mode 100644 index 000000000..0b5fe45dc --- /dev/null +++ b/crates/doc/src/text/wrap.rs @@ -0,0 +1,142 @@ +//! Object → Text Wrap: area type flows around the wrap objects stacked above it in the same layer. + +use std::sync::Arc; + +use vectorcraft_geom::{PathData, shapes}; + +use super::{TextKind, WrapShape}; +use crate::{Document, Node, NodeId, NodeKind}; + +/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art. +fn outline(n: &Node, out: &mut PathData) { + match &n.kind { + NodeKind::Path { path, guide: false, .. } => { + out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned()) + } + NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => { + for c in children.iter().filter(|c| c.visible) { + outline(c, out); + } + } + // A clip group wraps by its clipping path. + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + outline(c, out); + } + } + _ => { + if let Some(b) = n.geometric_bounds() { + out.subpaths.extend(shapes::rectangle(b).subpaths); + } + } + } +} + +enum Item<'a> { + Wrap(&'a Node), + Text(&'a Node), +} + +fn collect<'a>(n: &'a Node, out: &mut Vec>) { + if !n.visible { + return; + } + if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { + out.push(Item::Wrap(n)); + return; + } + match &n.kind { + NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + for c in children { + collect(c, out); + } + } + _ => {} + } +} + +/// How much [`resolve_wrap`] would copy into the wrap shapes of every area text object in `doc`: +/// each wrap object's outline (its anchors, plus one for the shape) once for each area text object +/// below it in its layer. +pub fn wrap_work(doc: &Document) -> usize { + let mut total = 0usize; + for layer in &doc.layers { + let mut items = vec![]; + collect(layer, &mut items); + let mut above = 0usize; + for it in items.iter().rev() { + match it { + Item::Wrap(n) => { + let mut pd = PathData::default(); + outline(n, &mut pd); + if !pd.is_empty() { + let anchors: usize = pd.subpaths.iter().map(|sp| sp.anchors.len()).sum(); + above = above.saturating_add(anchors.saturating_add(1)); + } + } + Item::Text(_) => total = total.saturating_add(above), + } + } + } + total +} + +/// Resolve the wrap shapes (`TextObject::wrap`, text space) of each area text object from the +/// outlines of the wrap objects above it in its layer. With `before`, the layers it holds unchanged +/// are skipped; without it, every layer is resolved. Returns the area text objects whose shapes +/// changed, whose cached bounds are then out of date. +pub fn resolve_wrap(before: Option<&Document>, doc: &mut Document) -> Vec { + let mut updates: Vec<(NodeId, Vec)> = vec![]; + for (idx, layer) in doc.layers.iter().enumerate() { + if let Some(before) = before + && (before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer)) + || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer))) + { + continue; + } + let mut items = vec![]; + collect(layer, &mut items); + if items.is_empty() { + continue; + } + // From the top down, each wrap object's outline is made once; each area text object takes + // those above it, bottom first. + let mut above: Vec = vec![]; + for it in items.iter().rev() { + match it { + Item::Wrap(n) => { + let mut pd = PathData::default(); + outline(n, &mut pd); + if !pd.is_empty() { + above.push(WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() }); + } + } + Item::Text(n) => { + let NodeKind::Text(t) = &n.kind else { continue }; + let inv = t.xf.inverse(); + let shapes: Vec = above + .iter() + .rev() + .map(|s| { + let mut path = s.path.clone(); + path.transform(inv); + WrapShape { path, wrap: s.wrap } + }) + .collect(); + if shapes != t.wrap { + updates.push((n.id, shapes)); + } + } + } + } + } + let mut changed = Vec::with_capacity(updates.len()); + for (id, shapes) in updates { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.wrap = shapes; + changed.push(id); + } + } + changed +} diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs index 2c1fc8653..234513df1 100644 --- a/crates/doc/src/units.rs +++ b/crates/doc/src/units.rs @@ -14,6 +14,11 @@ pub enum Unit { Meters, Yards, Feet, + /// Q (級), a quarter of a millimetre: the Japanese typesetting unit of type size. + Q, + /// Ha (歯), also a quarter of a millimetre: the Japanese unit of spacing, leading and + /// positions. + Ha, } impl Unit { @@ -29,6 +34,23 @@ impl Unit { Unit::Yards, Unit::Feet, ]; + /// The units Preferences › Units › East Asian Type offers: all of [`Unit::ALL`], then Q and + /// Ha. Only that preference lists Q and Ha; rulers, documents and the other Units + /// preferences stay on [`Unit::ALL`]. + pub const EAST_ASIAN: [Unit; 12] = [ + Unit::Points, + Unit::Picas, + Unit::Inches, + Unit::Millimeters, + Unit::Centimeters, + Unit::Pixels, + Unit::FeetInches, + Unit::Meters, + Unit::Yards, + Unit::Feet, + Unit::Q, + Unit::Ha, + ]; /// Points per unit. pub fn points(self) -> f64 { match self { @@ -37,6 +59,8 @@ impl Unit { Unit::Inches => 72.0, Unit::Millimeters => 72.0 / 25.4, Unit::Centimeters => 72.0 / 2.54, + // 0.25 mm each. + Unit::Q | Unit::Ha => 72.0 / 101.6, Unit::Meters => 72.0 / 0.0254, Unit::Yards => 72.0 * 36.0, Unit::Feet | Unit::FeetInches => 72.0 * 12.0, @@ -53,9 +77,11 @@ impl Unit { Unit::FeetInches | Unit::Feet => "ft", Unit::Meters => "m", Unit::Yards => "yd", + Unit::Q => "Q", + Unit::Ha => "H", } } - pub fn label(self) -> &'static str { + pub const fn label(self) -> &'static str { match self { Unit::Points => "Points", Unit::Picas => "Picas", @@ -66,6 +92,8 @@ impl Unit { Unit::FeetInches => "Feet & Inches", Unit::Meters => "Meters", Unit::Yards => "Yards", + Unit::Q => "Q", + Unit::Ha => "Ha", Unit::Feet => "Feet", } } @@ -115,12 +143,13 @@ impl Unit { pub fn number(self, pt: f64) -> String { // Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`, // `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`). - let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 }; + let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 3 } else { 4 }; let s = format!("{:.*}", decimals, self.from_pt(pt)); let s = s.trim_end_matches('0').trim_end_matches('.'); if s == "-0" { "0".into() } else { s.into() } } - /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. + /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), `6q` (Q) and `6h` (Ha), simple + /// `+ - * /` arithmetic. /// /// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so /// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term @@ -132,7 +161,7 @@ impl Unit { } /// The value naming this unit in the Units preferences (`points`, `millimeters`, /// `feetInches`). - pub fn key(self) -> &'static str { + pub const fn key(self) -> &'static str { match self { Unit::Points => "points", Unit::Picas => "picas", @@ -144,24 +173,37 @@ impl Unit { Unit::Meters => "meters", Unit::Yards => "yards", Unit::Feet => "feet", + Unit::Q => "q", + Unit::Ha => "ha", } } - /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and - /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). + /// The unit of [`Unit::ALL`] that `name` names: its label, suffix or [`Unit::key`] in any + /// case, ignoring spaces and punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). + /// Q and Ha are named by [`Unit::named_east_asian`] only. pub fn named(name: &str) -> Option { + Self::named_in(&Unit::ALL, name) + } + /// [`Unit::named`] among the units of Preferences › Units › East Asian Type + /// ([`Unit::EAST_ASIAN`]): also `Q` and `Ha` (`q`, `H`). + pub fn named_east_asian(name: &str) -> Option { + Self::named_in(&Unit::EAST_ASIAN, name) + } + fn named_in(units: &[Unit], name: &str) -> Option { let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); // Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches // is left to its label and key. - Unit::ALL - .into_iter() - .find(|u| letters(u.label()).eq(given())) - .or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given()))) + units.iter().copied().find(|u| letters(u.label()).eq(given())).or_else(|| { + units.iter().copied().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().map(|c| c.to_ascii_lowercase()).eq(given())) + }) } /// A point value as the canvas measurement labels show it: two decimals and the suffix /// (`12.50 mm`). pub fn readout(self, pt: f64) -> String { - format!("{:.2} {}", self.from_pt(pt), self.suffix()) + let s = format!("{:.2}", self.from_pt(pt)); + // A small negative value rounds to `-0.00`; show it as `0.00`. + let s = if s == "-0.00" { "0.00" } else { &s }; + format!("{} {}", s, self.suffix()) } } @@ -251,6 +293,8 @@ fn number_unit(s: &str) -> Option<(f64, Option)> { "ft" | "'" => Some(Unit::Feet), "yd" => Some(Unit::Yards), "p" | "pc" => Some(Unit::Picas), + "q" | "Q" => Some(Unit::Q), + "h" | "H" | "ha" | "Ha" => Some(Unit::Ha), _ => return None, }; Some((v, unit)) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 96bccbd0a..efe50d9df 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use super::{Document, NodeId, NodeKind}; +use super::{Document, LockedArtboards, NodeId, NodeKind, TextKind}; /// What a variable drives. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -21,11 +21,15 @@ pub enum VariableKind { Image, /// A graph object's data (CSV of categories and series, as Graph Data uses). Graph, + /// A variable a file declares without a kind (or with one VectorCraft doesn't know): it drives + /// nothing, no value fits it, and nothing can be bound to it. + Unknown, } impl VariableKind { /// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted - /// as the same thing, as the panel and the docs call it both), `image` or `graph`. + /// as the same thing, as the panel and the docs call it both), `image` or `graph`. A variable + /// of [`Self::Unknown`] kind is never defined this way. pub fn parse(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "text" => Some(Self::Text), @@ -43,6 +47,7 @@ impl VariableKind { Self::Visibility => "visibility", Self::Image => "image", Self::Graph => "graph", + Self::Unknown => "unknown", } } } @@ -117,7 +122,8 @@ pub struct Variables { #[serde(default, skip_serializing_if = "Option::is_none")] pub active_dataset: Option, /// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can - /// still be bound to them and unbound, and datasets still change and apply. + /// still be bound to them and unbound, and datasets still change and apply, except that an + /// import whose header would define a variable is refused. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub locked: bool, } @@ -189,34 +195,89 @@ impl Document { } /// Whether a variable may be bound to the object `id`: it is in the document, neither it nor - /// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't - /// count, as that is what a visibility variable drives. + /// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard + /// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a + /// visibility variable drives. pub fn variable_target(&self, id: NodeId) -> bool { + self.variable_target_with(&self.locked_artboards(), id) + } + + /// [`Document::variable_target`] with the locked artboards `locks` of this document, measured + /// once for many ids. + pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id)))) + && !locks.holds_id(id) } - /// Whether a data set reads and rewrites the bound object `id`: it is in the document and - /// neither it nor anything above it is locked. Hidden objects count, and so do objects in a - /// hidden layer or group. + /// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither + /// it nor anything above it is locked, and it is not on a locked artboard + /// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden + /// layer or group. pub fn dataset_target(&self, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) + self.dataset_target_with(&self.locked_artboards(), id) } - /// What the object `id` holds for the variable of `kind`: its characters, whether it shows, - /// or a linked image's path. `None` when the object is gone, is not of that kind, has no - /// link path yet (an embedded image), or is locked or in a locked layer or group (the - /// document's value is whatever it was left at). + /// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured + /// once for many ids. + pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id) + } + + /// The type objects of the story that the type object `id` belongs to, in thread order: the + /// frames of its threaded text (area type and type on a path), else `id` alone. + pub fn story_of(&self, id: NodeId) -> Vec { + let frame = |f: &NodeId| matches!(self.node(*f).map(|n| &n.kind), Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point)); + match self.text_threads.iter().find(|thread| thread.contains(&id)) { + Some(thread) if frame(&id) => thread.iter().filter(|f| frame(f)).copied().collect(), + _ => vec![id], + } + } + + /// The characters of the story that the type object `id` belongs to ([`Document::story_of`]): + /// every frame's text joined in thread order. + pub fn story_text(&self, id: NodeId) -> String { + self.story_of(id) + .into_iter() + .filter_map(|f| match self.node(f).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.plain_text()), + _ => None, + }) + .collect() + } + + /// Whether a data set reads and rewrites the text of the type object `id`: every frame of its + /// story ([`Document::story_of`]) is a [`Document::dataset_target`]. + pub fn story_target(&self, id: NodeId) -> bool { + self.story_target_with(&self.locked_artboards(), id) + } + + /// [`Document::story_target`] with the locked artboards `locks` of this document, measured + /// once for many ids. + pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { + self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f)) + } + + /// What the object `id` holds for the variable of `kind`: its characters (the whole story + /// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the + /// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked, + /// in a locked layer or group, or on a locked artboard, or for text when a frame of its story + /// is (the document's value is whatever it was left at). pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { - if !self.dataset_target(id) { + self.variable_value_with(&self.locked_artboards(), id, kind) + } + + /// [`Document::variable_value`] with the locked artboards `locks` of this document. + fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option { + if !self.dataset_target_with(locks, id) { return None; } let node = self.node(id)?; match (kind, &node.kind) { - (VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())), + (VariableKind::Text, NodeKind::Text(_)) if self.story_target_with(locks, id) => Some(DataValue::Text(self.story_text(id))), (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), (VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())), (VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())), - (VariableKind::Text | VariableKind::Image, _) => None, + (VariableKind::Text | VariableKind::Image | VariableKind::Unknown, _) => None, } } @@ -227,10 +288,11 @@ impl Document { /// still matches what it says. pub fn captured_values(&self) -> BTreeMap { let mut out = BTreeMap::new(); + let locks = self.locked_artboards(); for v in &self.variables.variables { // An object driven by several variables of the same kind reads from the first one // bound, so a capture is one value per variable and applies back the same way. - let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind)); + let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value_with(&locks, id, v.kind)); if let Some(value) = value { out.insert(v.name.clone(), value); } diff --git a/crates/effects/src/addons.rs b/crates/effects/src/addons.rs index 39e5bab97..10c5977bf 100644 --- a/crates/effects/src/addons.rs +++ b/crates/effects/src/addons.rs @@ -2,5 +2,14 @@ //! //! - [`color`]: Color Effects' live Exposure, Desaturate and Invert Colors, colour adjustments //! evaluated with the others ([`crate::adjust`]). +//! - [`duotone`]: Color Effects' live Duotone, a colour adjustment that prints each colour's tone +//! with one to four inks. +//! - [`halftone`]: Color Effects' live Vector Halftone, and the screens Object › Vector Halftone +//! builds its dots with. +//! - [`path`]: Path Precision Tools' live Path Effects, geometry effects built on the add-on's path +//! kernels. pub mod color; +pub mod duotone; +pub mod halftone; +pub mod path; diff --git a/crates/effects/src/addons/color.rs b/crates/effects/src/addons/color.rs index 2d5ebea91..07627dd8d 100644 --- a/crates/effects/src/addons/color.rs +++ b/crates/effects/src/addons/color.rs @@ -1,6 +1,6 @@ -//! Color Effects (a built-in add-on): live Exposure, Desaturate and Invert Colors, in Effect › Color -//! Effects. They are colour adjustments ([`crate::is_adjustment`]): they recolour what an object -//! paints as the others do, in stack order with them. +//! Color Effects (a built-in add-on): live Exposure, Desaturate, Duotone and Invert Colors, in +//! Effect › Color Effects. They are colour adjustments ([`crate::is_adjustment`]): they recolour what +//! an object paints as the others do, in stack order with them. use serde_json::{Value, json}; @@ -13,9 +13,10 @@ pub const EXPOSURE: &str = "addon.color.exposure"; pub const DESATURATE: &str = "addon.color.desaturate"; /// Invert Colors: every colour to its RGB inverse. pub const INVERT: &str = "addon.color.invert"; +pub use super::duotone::DUOTONE; /// The add-on's effects, in menu order. -pub const EFFECTS: [&str; 3] = [DESATURATE, EXPOSURE, INVERT]; +pub const EFFECTS: [&str; 4] = [DESATURATE, DUOTONE, EXPOSURE, INVERT]; /// Is `id` one of the add-on's effects? pub fn is_color_effect(id: &str) -> bool { @@ -28,6 +29,7 @@ pub(crate) fn catalog() -> Vec { let e = |id, label, params, defaults| EffectInfo { id, label, menu: MENU, params, defaults, raster: false, lengths: Lengths::default() }; vec![ e(DESATURATE, "Desaturate", "{} turns every colour the object paints to the grey of its lightness (each colour keeps its model)", json!({})), + e(DUOTONE, "Duotone…", super::duotone::PARAMS, super::duotone::defaults()), e( EXPOSURE, "Exposure…", @@ -49,6 +51,8 @@ pub(crate) enum Adjust { }, Desaturate, Invert, + /// Duotone ([`super::duotone`]). + Duotone(super::duotone::Duotone), } impl Adjust { @@ -61,6 +65,7 @@ impl Adjust { }, DESATURATE => Adjust::Desaturate, INVERT => Adjust::Invert, + DUOTONE => Adjust::Duotone(super::duotone::Duotone::parse(p)?), _ => return None, }) } @@ -73,6 +78,7 @@ impl Adjust { [(r.max(g).max(b) + r.min(g).min(b)) / 2.0; 3] } Adjust::Invert => rgb.map(|v| 1.0 - v), + Adjust::Duotone(d) => d.apply_rgb(rgb), } } } diff --git a/crates/effects/src/addons/duotone.rs b/crates/effects/src/addons/duotone.rs new file mode 100644 index 000000000..c4febd865 --- /dev/null +++ b/crates/effects/src/addons/duotone.rs @@ -0,0 +1,178 @@ +//! Color Effects' Duotone (Effect › Color Effects › Duotone…), a colour adjustment: the +//! grey-equivalent tone of each colour an object paints is printed with one to four inks (a +//! monotone, duotone, tritone or quadtone). Each ink, a tone, is a colour, typically a spot swatch, +//! with its own curve from the tone's tint to the ink's. The inks overprint, so their tints +//! multiply. +//! +//! With one tone of a swatch, a colour becomes a tint of that swatch, linked to it, so a spot tone +//! separates onto its plate. With several tones it becomes the CMYK mix of their tints: a colour +//! links to one swatch at one tint, and a mix of several spot inks has no swatch to link to. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_doc::color::{Color, cms::Model}; + +use crate::adjust::{LUT, curve_at, curve_points_on, lookup, sample}; +use crate::raster::color_param; +use crate::util::flag; + +/// Duotone (Effect › Color Effects › Duotone…). +pub const DUOTONE: &str = "addon.color.duotone"; + +/// Most tones Duotone reads (a quadtone): any past them are left out. +pub const MAX_TONES: usize = 4; + +/// Most points a tone's curve reads: any past them are left out. +pub const MAX_CURVE_POINTS: usize = 64; + +/// The parameters' description (`effect.apply`). +pub(crate) const PARAMS: &str = "{tones: [{color: \"#rrggbb\" or a colour object, swatch?: a global or spot swatch's name (with the swatch's colour as `color`), curve?: \"tint,ink tint,ink …\" or [[tint, ink], …] (0..100, at most 64 points; \"0,0 100,100\"), active?: true}] (up to four inks, overprinting; any past four are left out), skipSpot?: false (colours linked to a spot swatch keep their colour)} prints the grey-equivalent tone of each colour the object paints with the inks, each at the tint its curve gives for the tone's: with one tone of a swatch, as a tint of that swatch (a spot tone prints on its own plate); with several, as the CMYK mix of their tints"; + +/// The dialog's defaults: one black tone, its curve linear. +pub(crate) fn defaults() -> Value { + json!({"tones": [{"color": "#000000", "curve": "0,0 100,100", "active": true}], "skipSpot": false}) +} + +/// Whether swatch `name` of the document is a spot colour: `Some(false)` for a global process +/// swatch, `None` when the document has no global swatch of that name. +pub type SwatchKinds<'a> = dyn Fn(&str) -> Option + 'a; + +/// [`SwatchKinds`] of `d`'s swatch `name`. +pub fn swatch_kind(d: &Document, name: &str) -> Option { + d.swatches_iter().find(|w| w.global && w.name == name).map(|w| w.spot) +} + +/// [`swatch_kind`] of each of `d`'s global swatches. +pub fn swatch_kinds(d: &Document) -> HashMap { + d.swatches_iter().filter(|w| w.global).map(|w| (w.name.clone(), w.spot)).collect() +} + +/// A tone's curve points (0..1 each) from its `curve` (0..100). +pub fn tone_curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { + curve_points_on(v, 100.0, MAX_CURVE_POINTS) +} + +/// One ink. +#[derive(Clone, Debug, PartialEq)] +struct Tone { + color: Color, + /// `color` as CMYK inks, which several tones mix in. + cmyk: [f32; 4], + /// The swatch the colour is of, if any. + swatch: Option, + /// Its curve, sampled: the tone's tint → the ink's. + curve: Arc<[f32]>, +} + +/// Duotone's tones and options, its parameters read. +#[derive(Clone, Debug, PartialEq)] +pub struct Duotone { + tones: Vec, + skip_spot: bool, + /// The display colour at each sampled tint, for pixels. + rgb: Arc<[[f32; 3]]>, +} + +impl Duotone { + /// `None` without an active tone (the effect then leaves the colours as they are). + pub(crate) fn parse(p: &Value) -> Option { + let tones: Vec = p + .get("tones") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(MAX_TONES) + .filter(|t| t.is_object() && flag(t, "active", true)) + .map(|t| { + let color = finite(color_param(t, Color::BLACK)); + let cmyk = match color.in_model(Model::Cmyk) { + Color::Cmyk { c, m, y, k } => [c, m, y, k].map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }), + _ => [0.0, 0.0, 0.0, 1.0], + }; + let pts = tone_curve_points(t.get("curve")); + let swatch = t.get("swatch").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string); + Tone { color, cmyk, swatch, curve: sample(|x| curve_at(&pts, x)) } + }) + .collect(); + if tones.is_empty() { + return None; + } + let mut d = Duotone { tones, skip_spot: flag(p, "skipSpot", false), rgb: Arc::new([]) }; + d.rgb = (0..LUT).map(|i| d.mix(i as f32 / (LUT - 1) as f32).to_rgb()).collect(); + Some(d) + } + + /// The inks' colour for tone `t` (0 = white, 1 = black): one tone's colour at its tint, or + /// several tones' CMYK tints overprinted (each ink lets through what the others leave). + fn mix(&self, t: f32) -> Color { + if let [one] = self.tones.as_slice() { + return one.color.tinted(lookup(&one.curve, t)); + } + let mut paper = [1.0f32; 4]; + for tone in &self.tones { + let k = lookup(&tone.curve, t); + for (p, ink) in paper.iter_mut().zip(tone.cmyk) { + *p *= 1.0 - ink * k; + } + } + let [c, m, y, k] = paper.map(|p| 1.0 - p); + Color::cmyk(c, m, y, k) + } + + /// Display components 0..1 through the effect. + pub(crate) fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] { + let x = tone(rgb) * (LUT - 1) as f32; + let i = (x.floor() as usize).min(LUT - 2); + let f = x - i as f32; + match (self.rgb.get(i), self.rgb.get(i + 1)) { + (Some(a), Some(b)) => std::array::from_fn(|k| a[k] + (b[k] - a[k]) * f), + _ => rgb, + } + } + + /// A colour with its swatch link and tint through the effect ([`crate::ColorMap::apply_link`]): + /// true when it changed. With `skipSpot`, a colour linked to a spot swatch stays. + pub(crate) fn map_link(&self, c: &mut Color, link: &mut Option, tint: &mut f32, swatches: &SwatchKinds) -> bool { + if self.skip_spot && link.as_deref().and_then(swatches) == Some(true) { + return false; + } + let t = tone(c.to_rgb()); + let new = self.mix(t); + let (new_link, new_tint) = match self.tones.as_slice() { + [one] => match one.swatch.as_ref().filter(|n| swatches(n).is_some()) { + Some(name) => (Some(name.clone()), lookup(&one.curve, t)), + None => (None, 1.0), + }, + _ => (None, 1.0), + }; + if new == *c && new_link == *link && new_tint == *tint { + return false; + } + (*c, *link, *tint) = (new, new_link, new_tint); + true + } +} + +/// The grey-equivalent tone of display colour `rgb`: 0 for white, 1 for black (its luma, as +/// Convert to Grayscale reads it, so a grey's tone is its ink). +fn tone([r, g, b]: [f32; 3]) -> f32 { + let t = 1.0 - (0.299 * r + 0.587 * g + 0.114 * b); + if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 } +} + +/// `c` with its components finite and in range. +fn finite(c: Color) -> Color { + let v = |x: f32, lo: f32, hi: f32| if x.is_finite() { x.clamp(lo, hi) } else { lo }; + match c { + Color::Rgb { r, g, b } => Color::rgb(v(r, 0.0, 1.0), v(g, 0.0, 1.0), v(b, 0.0, 1.0)), + Color::Cmyk { c, m, y, k } => Color::cmyk(v(c, 0.0, 1.0), v(m, 0.0, 1.0), v(y, 0.0, 1.0), v(k, 0.0, 1.0)), + Color::Gray { k } => Color::gray(v(k, 0.0, 1.0)), + Color::Lab { l, a, b } => Color::lab(v(l, 0.0, 100.0), v(a, -128.0, 127.0), v(b, -128.0, 127.0)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/addons/duotone/tests.rs b/crates/effects/src/addons/duotone/tests.rs new file mode 100644 index 000000000..8a3255b05 --- /dev/null +++ b/crates/effects/src/addons/duotone/tests.rs @@ -0,0 +1,128 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::Effect; +use vectorcraft_doc::color::Color; + +use super::*; +use crate::{ColorMap, color_map}; + +fn map(params: Value) -> ColorMap { + color_map(&[Effect { id: DUOTONE.into(), params, visible: true }]).unwrap() +} + +/// `c` with link `link` through `m`, the document having spot swatch "Ink" and process swatch +/// "Blue": (colour, link, tint, changed). +fn linked(m: &ColorMap, c: Color, link: Option<&str>) -> (Color, Option, f32, bool) { + let (mut c, mut link, mut tint) = (c, link.map(str::to_string), 1.0); + let kinds = |n: &str| match n { + "Ink" => Some(true), + "Blue" => Some(false), + _ => None, + }; + let changed = m.apply_link(&mut c, &mut link, &mut tint, &kinds); + (c, link, tint, changed) +} + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 +} + +fn looks(c: Color, rgb: [f32; 3]) -> bool { + c.to_rgb().iter().zip(rgb).all(|(a, b)| close(*a, b)) +} + +const INK: Color = Color::Cmyk { c: 0.0, m: 0.5, y: 1.0, k: 0.0 }; + +fn ink_tone(curve: &str) -> Value { + json!({"tones": [{"color": INK, "swatch": "Ink", "curve": curve}]}) +} + +#[test] +fn a_spot_tone_turns_greys_into_tints_of_its_swatch() { + let m = map(ink_tone("0,0 100,100")); + for k in [0.0, 0.25, 0.6, 1.0] { + let (c, link, tint, _) = linked(&m, Color::gray(k), None); + assert_eq!(link.as_deref(), Some("Ink"), "linked at {k}"); + assert!(close(tint, k), "the tint is the grey's ink: {k} → {tint}"); + assert_eq!(c, INK.tinted(tint), "its colour is the swatch's at that tint"); + } + // A colour reads as its grey equivalent: white is no ink. + let (_, _, tint, _) = linked(&m, Color::rgb(1.0, 1.0, 1.0), None); + assert!(close(tint, 0.0)); + // A swatch the document hasn't: an unlinked tint of the tone's colour. + let (c, link, ..) = linked(&map(json!({"tones": [{"color": INK, "swatch": "Gone"}]})), Color::gray(0.5), None); + assert!(link.is_none() && looks(c, INK.tinted(0.5).to_rgb())); + // Without the links, colours (mesh points) take the tone's colour too. + assert!(looks(m.apply(Color::gray(0.5)), INK.tinted(0.5).to_rgb())); +} + +#[test] +fn the_curve_changes_the_mapping() { + // Inverse: dark greys get little ink. + let (_, _, tint, _) = linked(&map(ink_tone("0,100 100,0")), Color::gray(0.8), None); + assert!(close(tint, 0.2), "{tint}"); + // Through its points exactly, flat past its ends. + let lifted = map(ink_tone("0,0 50,80 100,100")); + assert!(close(linked(&lifted, Color::gray(0.5), None).2, 0.8)); + assert!(close(linked(&map(ink_tone("20,0 80,100")), Color::gray(0.1), None).2, 0.0)); + // Unreadable or missing curves are linear; points past the cap are left out. + for curve in [json!("junk"), json!(null), json!([[0, 0]])] { + let p = json!({"tones": [{"color": INK, "swatch": "Ink", "curve": curve}]}); + assert!(close(linked(&map(p), Color::gray(0.3), None).2, 0.3), "{curve}"); + } + let many: Vec<[f64; 2]> = (0..200).map(|i| [f64::from(i) / 2.0, 100.0 - f64::from(i) / 2.0]).collect(); + assert_eq!(tone_curve_points(Some(&json!(many))).len(), MAX_CURVE_POINTS); +} + +#[test] +fn several_tones_multiply_as_cmyk_inks() { + let cyan = json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}, "swatch": "Ink"}); + let half_black = json!({"color": {"model": "cmyk", "c": 0.6, "m": 0.0, "y": 0.0, "k": 1.0}, "curve": "0,0 100,50"}); + let m = map(json!({"tones": [cyan, half_black]})); + let (c, link, tint, changed) = linked(&m, Color::gray(1.0), Some("Ink")); + assert!(changed && link.is_none() && tint == 1.0, "a process mix, unlinked"); + // Cyan 100% over the second ink at 50% (60% cyan, 100% black at half): cyan 1-(1-1)(1-0.3). + let Color::Cmyk { c, m: mg, y, k } = c else { panic!("CMYK: {c:?}") }; + assert!(close(c, 1.0) && close(mg, 0.0) && close(y, 0.0) && close(k, 0.5), "{c} {mg} {y} {k}"); + let (mid, ..) = linked(&m, Color::gray(0.5), None); + let Color::Cmyk { c, k, .. } = mid else { panic!("CMYK") }; + // Cyan 50% over the second ink at 25% (15% cyan, 25% black): 1 - 0.5 * 0.85 = 0.575. + assert!(close(c, 0.575) && close(k, 0.25), "{c} {k}"); + // Inactive tones and tones past the fourth don't count; none active leaves the colours. + let one = json!({"color": "#000000"}); + let off = json!({"color": "#ff0000", "active": false}); + let only_black = map(json!({"tones": [one, off, off, off, {"color": "#ff0000"}]})); + assert!(looks(only_black.apply(Color::gray(0.4)), [0.6; 3])); + assert!(color_map(&[Effect { id: DUOTONE.into(), params: json!({"tones": [off]}), visible: true }]).is_none()); + assert!(color_map(&[Effect { id: DUOTONE.into(), params: json!({"tones": "x"}), visible: true }]).is_none()); +} + +#[test] +fn skip_spot_keeps_spot_colours() { + let mut p = json!({"tones": [{"color": "#000000"}], "skipSpot": true}); + let m = map(p.clone()); + let spot = Color::cmyk(0.0, 1.0, 0.0, 0.0); + assert_eq!(linked(&m, spot, Some("Ink")), (spot, Some("Ink".to_string()), 1.0, false)); + // A process swatch's colour, and an unlinked one, still change. + assert!(linked(&m, spot, Some("Blue")).3); + assert!(linked(&m, spot, None).3); + p["skipSpot"] = json!(false); + assert!(linked(&map(p), spot, Some("Ink")).3); +} + +#[test] +fn pixels_and_other_adjustments_compose() { + let m = map(json!({"tones": [{"color": "#ff0000"}]})); + // A grey pixel half way: half way to red from white. + let px = m.apply_rgb([0.5, 0.5, 0.5]); + assert!(close(px[0], 1.0) && close(px[1], 0.5) && close(px[2], 0.5), "{px:?}"); + // Invert, then Duotone: white becomes full red. + let both = color_map(&[ + Effect { id: crate::addons::color::INVERT.into(), params: json!({}), visible: true }, + Effect { id: DUOTONE.into(), params: json!({"tones": [{"color": "#ff0000"}]}), visible: true }, + ]) + .unwrap(); + assert!(looks(both.apply(Color::WHITE), [1.0, 0.0, 0.0])); + // Out of range colours are brought into range. + let wild = map(json!({"tones": [{"color": [1e30, -5, 0.5]}]})); + assert!(looks(wild.apply(Color::gray(1.0)), [1.0, 0.0, 0.5])); +} diff --git a/crates/effects/src/addons/halftone.rs b/crates/effects/src/addons/halftone.rs new file mode 100644 index 000000000..9b5771e7b --- /dev/null +++ b/crates/effects/src/addons/halftone.rs @@ -0,0 +1,489 @@ +//! Vector Halftone (Color Effects): the art as vector dots, lines or shapes sized by its tone, as a +//! live effect (Effect › Color Effects › Vector Halftone…, [`HALFTONE`]) and for Object › Vector +//! Halftone, which builds its paths with the same screens ([`Halftone::layers`]). +//! +//! The art is sampled from pixels ([`Samples`], drawn by the renderer) once per halftone cell on a +//! grid turned by the screen angle; each cell gets a shape whose area follows the tone there. Mono +//! screens follow lightness in one colour (or, with `fromArt`, each dot takes the art's colour under +//! it); CMYK screens follow the art's ink amounts (as CMYK output separates it), one screen per ink at +//! the classic angles, multiplied over each other. The dots can be clipped to the art's outline as +//! real geometry. +//! +//! The renderer, the exporters ([`crate::bake_document`]) and Expand Appearance draw an object with +//! the effect as [`halftone_art`]: the renderer samples the art, so it registers how ([`set_art`]). + +use std::collections::BTreeMap; +use std::sync::{Arc, OnceLock}; + +use serde_json::{Value, json}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; +use vectorcraft_pathops::{self as po, BoolOp}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::util::{flag, num, text}; + +/// Vector Halftone, the live effect. +pub const HALFTONE: &str = "addon.color.halftone"; + +/// Most halftone cells per screen. +pub const MAX_CELLS: f64 = 100_000.0; +/// Most pixels the tone is sampled from. +pub const MAX_SAMPLE_PIXELS: f64 = 16.0e6; +/// Cells lighter than this get no dot. +const MIN_TONE: f64 = 0.01; +/// Levels per channel the art's colours are rounded to with `fromArt` (one path per colour). +const COLOR_LEVELS: f64 = 15.0; + +/// The effect's catalogue entry. +pub(crate) fn catalog() -> Vec { + vec![EffectInfo { + id: HALFTONE, + label: "Vector Halftone…", + menu: &["Effect", "Color Effects"], + params: "{shape: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency: lines per inch 1..300 (20), angle: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode: \"mono\"|\"cmyk\" (mono), color: mono dot colour (\"#000000\"), fromArt: bool (false; mono: each dot takes the art's colour under it), invert: bool (false; dots for light tones), clip: bool (true; the dots clipped to the art's outline)} draws the object as vector halftone dots sized by its tone, as object.vectorHalftone makes them (CMYK screens multiply); the object is sampled as drawn with its other effects; at most 100000 cells per screen, past that the object draws as it is", + defaults: json!({"shape": "circle", "frequency": 20.0, "angle": 45.0, "mode": "mono", "color": "#000000", "fromArt": false, "invert": false, "clip": true}), + raster: false, + lengths: Lengths::default(), + }] +} + +/// Does `n` carry a visible Vector Halftone effect? +pub fn has_halftone(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible && e.id == HALFTONE) +} + +/// The shape of a halftone dot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DotShape { + Circle, + Ellipse, + Square, + Diamond, + Line, +} + +impl DotShape { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "circle" | "round" | "dot" => DotShape::Circle, + "ellipse" => DotShape::Ellipse, + "square" => DotShape::Square, + "diamond" => DotShape::Diamond, + "line" | "lines" => DotShape::Line, + _ => return None, + }) + } + + /// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen + /// running along x. + fn path(self, t: f64, s: f64) -> PathData { + let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side))); + match self { + DotShape::Circle => { + let r = s * (t / std::f64::consts::PI).sqrt(); + shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r))) + } + DotShape::Ellipse => { + let r = s * (t / std::f64::consts::PI).sqrt(); + shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3))) + } + DotShape::Square => square(s * t.sqrt()), + DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)), + // Cells along a row join into a line as thick as the tone. + DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))), + } + } +} + +/// One screen: its ink and angle, and how to read its tone from a sampled pixel. +struct Screen { + paint: Color, + angle: f64, + /// Index into the sampled pixel (unused for mono, CMYK inks for cmyk). + channel: usize, +} + +/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt. +pub struct Samples { + px: Vec, + w: usize, + h: usize, + region: Rect, + scale: f64, + /// Mono: tones are 1 − lightness of RGBA pixels drawn on white; CMYK: ink amounts. + mono: bool, + invert: bool, +} + +impl Samples { + /// Pixels `px` (`w`×`h`, 4 bytes each) over `region` at `scale` px/pt; `None` when there are + /// fewer bytes than that. + pub fn new(px: Vec, (w, h): (usize, usize), region: Rect, scale: f64, mono: bool, invert: bool) -> Option { + (px.len() >= w.checked_mul(h)?.checked_mul(4)?).then_some(Self { px, w, h, region, scale, mono, invert }) + } + + /// The index of the pixel at document point `p`, `None` outside the samples. + fn index(&self, p: Point) -> Option { + let x = ((p.x - self.region.x0) * self.scale).floor(); + let y = ((p.y - self.region.y0) * self.scale).floor(); + (x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h).then(|| (y as usize * self.w + x as usize) * 4) + } + + fn byte(&self, i: usize, k: usize) -> f64 { + self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0 + } + + /// The tone (0..1) of `channel` at document point `p`. + fn tone(&self, p: Point, channel: usize) -> f64 { + let Some(i) = self.index(p) else { return if self.invert { 1.0 } else { 0.0 } }; + let t = + if self.mono { 1.0 - (0.2126 * self.byte(i, 0) + 0.7152 * self.byte(i, 1) + 0.0722 * self.byte(i, 2)) } else { self.byte(i, channel) }; + if self.invert { 1.0 - t } else { t } + } + + /// The 3 × 3 sample points of a cell of side `s` at `c` turned by `rot`. + fn cell_points(c: Point, s: f64, rot: Affine) -> impl Iterator { + (0..9).map(move |k| c + (rot * Point::new((f64::from(k / 3) - 1.0) * s / 3.0, (f64::from(k % 3) - 1.0) * s / 3.0)).to_vec2()) + } + + /// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples). + fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 { + Self::cell_points(c, s, rot).map(|p| self.tone(p, channel)).sum::() / 9.0 + } + + /// The mean colour over a cell (as [`Self::cell_tone`]; white outside the samples), each channel + /// rounded to [`COLOR_LEVELS`] steps. + fn cell_color(&self, c: Point, s: f64, rot: Affine) -> [u8; 3] { + let mut sum = [0.0; 3]; + for p in Self::cell_points(c, s, rot) { + for (k, v) in sum.iter_mut().enumerate() { + *v += self.index(p).map_or(1.0, |i| self.byte(i, k)); + } + } + sum.map(|v| (v / 9.0 * COLOR_LEVELS).round().clamp(0.0, COLOR_LEVELS) as u8) + } +} + +/// A halftone's settings. +#[derive(Clone, Debug, PartialEq)] +pub struct Halftone { + pub shape: DotShape, + /// Lines per inch. + pub frequency: f64, + /// Degrees. + pub angle: f64, + /// One screen in `color` (else four CMYK screens). + pub mono: bool, + pub color: Color, + /// Mono: each dot in the art's colour under it instead of `color`. + pub from_art: bool, + /// Dots for light tones. + pub invert: bool, + /// The dots clipped to the art's outline. + pub clip: bool, +} + +impl Halftone { + /// The settings of `n`'s last visible Vector Halftone effect, its values brought into range. + pub fn of(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.visible && e.id == HALFTONE)?; + Some(Self::from_params(&e.params)) + } + + fn from_params(p: &Value) -> Self { + Self { + shape: DotShape::parse(text(p, "shape", "circle")).unwrap_or(DotShape::Circle), + frequency: num(p, "frequency", 20.0).clamp(1.0, 300.0), + angle: num(p, "angle", 45.0), + mono: !text(p, "mode", "mono").eq_ignore_ascii_case("cmyk"), + color: crate::raster::color_param(p, Color::BLACK), + from_art: flag(p, "fromArt", false), + invert: flag(p, "invert", false), + clip: flag(p, "clip", true), + } + } + + /// The side of a cell in points. + pub fn cell(&self) -> f64 { + 72.0 / self.frequency + } + + /// Where the art over `bounds` is sampled and at how many pixels per point: about four pixels per + /// cell, at most [`MAX_SAMPLE_PIXELS`]. `None` when `bounds` have no area or the grid turned by + /// the screen angle would have more than [`MAX_CELLS`] cells. + pub fn plan(&self, bounds: Rect) -> Option<(Rect, f64)> { + let cell = self.cell(); + if !(bounds.width() > 0.0 && bounds.height() > 0.0 && bounds.area().is_finite()) { + return None; + } + // The grid turned by the screen angle covers at most (w + h)² / 2. + if (bounds.width() + bounds.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS { + return None; + } + let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / bounds.area()).sqrt()).clamp(0.01, 16.0); + Some((bounds.inflate(cell, cell), scale)) + } + + /// The screens, bottom to top. + fn screens(&self) -> Vec { + let angle = self.angle; + if self.mono { + return vec![Screen { paint: self.color, angle, channel: 0 }]; + } + // Bottom to top: yellow, magenta, cyan, black. + vec![ + Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 }, + Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 }, + Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 }, + Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 }, + ] + } + + /// The dots of the art `samples` show over `bounds`, one path per ink (per colour with + /// `fromArt`) in paint order, each clipped to `outline` when given, and how many dots there are. + pub fn layers(&self, samples: &Samples, bounds: Rect, outline: Option<&(BezPath, FillRule)>) -> (Vec<(PathData, Color)>, usize) { + let cell = self.cell(); + let mut layers = vec![]; + let mut count = 0; + for sc in self.screens() { + let dots = screen_dots(samples, bounds, cell, &sc, self.shape); + count += dots.len(); + if self.mono && self.from_art { + let rot = Affine::rotate(sc.angle.to_radians()); + let mut by_color: BTreeMap<[u8; 3], Vec> = BTreeMap::new(); + for (dot, c) in dots { + by_color.entry(samples.cell_color(c, cell, rot)).or_default().push(dot); + } + for (rgb, dots) in by_color { + let [r, g, b] = rgb.map(|v| (f64::from(v) / COLOR_LEVELS) as f32); + layers.push((merged(dots, outline), Color::rgb(r, g, b))); + } + } else { + layers.push((merged(dots.into_iter().map(|d| d.0).collect(), outline), sc.paint)); + } + } + layers.retain(|(path, _)| !path.is_empty()); + (layers, count) + } +} + +/// The dots of one screen over `bounds`, each with its cell's centre. +fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape) -> Vec<(PathData, Point)> { + let rot = Affine::rotate(sc.angle.to_radians()); + let inv = rot.inverse(); + // The grid covers the bounds in screen space. + let corners = + [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q); + let (mut lo, mut hi) = (corners[0], corners[0]); + for q in &corners[1..] { + lo = Point::new(lo.x.min(q.x), lo.y.min(q.y)); + hi = Point::new(hi.x.max(q.x), hi.y.max(q.y)); + } + let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1); + let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1); + let near = bounds.inflate(cell, cell); + let mut dots = vec![]; + for j in j0..=j1 { + for i in i0..=i1 { + let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell); + if !near.contains(c) { + continue; + } + let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0); + if t < MIN_TONE { + continue; + } + let place = Affine::translate(c.to_vec2()) * rot; + dots.push((shape.path(t, cell).transformed(place), c)); + } + } + dots +} + +/// The outline Clip to Art clips the dots of `nodes` to: their clip outline taken together. +pub fn art_outline(nodes: &[Node]) -> Option<(BezPath, FillRule)> { + crate::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())) +} + +/// `dots` as one path, clipped to `outline` when given (dots wholly inside it need no clipping). +fn merged(dots: Vec, outline: Option<&(BezPath, FillRule)>) -> PathData { + let (mut inside, mut edge) = (vec![], vec![]); + for dot in dots { + let whole = match (outline, dot.control_bounds()) { + (Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)] + .iter() + .all(|q| fill_contains(bp, *rule, *q)), + _ => true, + }; + if whole { inside.push(dot) } else { edge.push(dot) } + } + let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect(); + if let Some((bp, rule)) = outline + && !edge.is_empty() + { + let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect()); + let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect); + subs.extend(clipped.subpaths); + } else { + subs.extend(edge.into_iter().flat_map(|p| p.subpaths)); + } + PathData::new(subs) +} + +/// The paths of `layers` ([`Halftone::layers`]), each filled with its ink and ids from `id`; CMYK +/// screens (`mono` false) multiply over each other. +pub fn dot_nodes(layers: Vec<(PathData, Color)>, mono: bool, id: &mut dyn FnMut() -> NodeId) -> Vec> { + layers + .into_iter() + .map(|(path, ink)| { + let mut n = Node::path(id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0)); + if !mono { + n.blend = BlendMode::Multiply; + } + Arc::new(n) + }) + .collect() +} + +/// `n` split for its halftone: the object the effect samples (`n` visible, without its Vector +/// Halftone effects, transparency, mask and link) and an empty group that takes over its identity, +/// transparency, mask and link, for the dots. +pub fn split(n: &Node) -> (Node, Node) { + let mut object = n.clone(); + object.appearance.effects.retain(|e| e.id != HALFTONE); + object.visible = true; + let mut g = Node::group(n.id, vec![]); + (g.visible, g.locked) = (n.visible, n.locked); + g.name = object.name.take(); + g.opacity = std::mem::replace(&mut object.opacity, 1.0); + g.blend = std::mem::take(&mut object.blend); + g.isolate = std::mem::take(&mut object.isolate); + g.extras_mut().mask = object.extras_mut().mask.take(); + g.extras_mut().attrs = object.extras_mut().attrs.take(); + (object, g) +} + +/// How far the dots of `n`'s halftone reach past the bounds of the object it samples: nothing when +/// they are clipped to its outline, else up to two cells (a dot's centre lies within a cell of the +/// bounds, and the dot within another). +pub fn reach(n: &Node) -> f64 { + Halftone::of(n).filter(|h| !h.clip).map_or(0.0, |h| 2.0 * h.cell()) +} + +/// Makes the art of an object with a visible Vector Halftone effect ([`halftone_art`]). +pub type ArtHook = fn(&Document, &Node) -> Option; + +static ART: OnceLock = OnceLock::new(); + +/// Register how the art of an object with Vector Halftone is made (the renderer samples it, above +/// this crate; the first registration stays). +pub fn set_art(f: ArtHook) { + // Already set: the same renderer, registered by another session. + let _ = ART.set(f); +} + +/// The art of `n` with a visible Vector Halftone effect in `d`: the group [`split`] makes, holding +/// the dot paths ([`dot_nodes`], ids `n`'s). `None` without the effect, while nothing is registered +/// ([`set_art`]), and when the art has no area or would take too many cells ([`Halftone::plan`]). +pub fn halftone_art(d: &Document, n: &Node) -> Option { + if !has_halftone(n) { + return None; + } + ART.get()?(d, n) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::Effect; + + /// A black square's samples: `side` points of black from (10, 10), white around it. + fn black_square(side: f64, h: &Halftone) -> (Samples, Rect) { + let b = Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side); + let (region, scale) = h.plan(b).unwrap(); + let (w, hh) = ((region.width() * scale).round() as usize, (region.height() * scale).round() as usize); + let mut px = vec![255u8; w * hh * 4]; + for y in 0..hh { + for x in 0..w { + let p = Point::new(region.x0 + (x as f64 + 0.5) / scale, region.y0 + (y as f64 + 0.5) / scale); + if b.contains(p) { + px[(y * w + x) * 4..][..3].copy_from_slice(&[200, 0, 0]); + } + } + } + (Samples::new(px, (w, hh), region, scale, true, h.invert).unwrap(), b) + } + + fn effect(params: Value) -> Node { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + n.appearance.effects.push(Effect { id: HALFTONE.into(), params, visible: true }); + n + } + + #[test] + fn the_effect_is_in_color_effects_with_its_defaults() { + let info = crate::effect_info(HALFTONE).unwrap(); + assert_eq!(info.menu, ["Effect", "Color Effects"]); + assert!(!crate::is_geometry(HALFTONE) && !crate::is_raster(HALFTONE) && !crate::is_adjustment(HALFTONE)); + let h = Halftone::of(&effect(info.defaults)).unwrap(); + assert_eq!((h.shape, h.frequency, h.angle, h.mono, h.from_art, h.invert, h.clip), (DotShape::Circle, 20.0, 45.0, true, false, false, true)); + assert_eq!(h.color, Color::BLACK); + } + + #[test] + fn values_out_of_range_are_brought_in() { + let h = Halftone::of(&effect(json!({"frequency": 1e308, "angle": "NaN", "shape": 7, "mode": "CMYK", "color": "nope"}))).unwrap(); + assert_eq!((h.frequency, h.angle, h.shape, h.mono, h.color), (300.0, 45.0, DotShape::Circle, false, Color::BLACK)); + assert_eq!(Halftone::of(&effect(json!({"frequency": -5}))).unwrap().frequency, 1.0); + let mut hidden = effect(json!({})); + hidden.appearance.effects[0].visible = false; + assert!(Halftone::of(&hidden).is_none() && !has_halftone(&hidden)); + } + + #[test] + fn dots_follow_the_tone_and_the_settings() { + let h = Halftone::of(&effect(json!({"frequency": 18}))).unwrap(); + let (s, b) = black_square(60.0, &h); + let (layers, dots) = h.layers(&s, b, None); + assert_eq!(layers.len(), 1); + assert!((150..=320).contains(&dots), "{dots}"); + // Inverted, the dark square gets small dots and the white around it full ones. + let inv = Halftone { invert: true, ..h.clone() }; + let (s2, _) = black_square(60.0, &inv); + assert!(inv.layers(&s2, b, None).1 > dots); + // The colour of the art under each dot. + let art = Halftone { from_art: true, ..h.clone() }; + let (layers, _) = art.layers(&s, b, None); + assert!(layers.iter().any(|(_, c)| c.to_hex() == "#cc0000"), "{:?}", layers.iter().map(|l| l.1.to_hex()).collect::>()); + // CMYK screens: one layer per ink with dots (red: magenta and yellow). + let cmyk = Halftone { mono: false, ..h }; + let one = Samples::new([0u8, 255, 255, 0].repeat(400), (20, 20), Rect::new(0.0, 0.0, 20.0, 20.0), 1.0, false, false).unwrap(); + assert_eq!(cmyk.layers(&one, Rect::new(2.0, 2.0, 18.0, 18.0), None).0.len(), 2); + } + + #[test] + fn the_caps_refuse_too_many_cells() { + let h = Halftone::of(&effect(json!({"frequency": 300}))).unwrap(); + assert!(h.plan(Rect::new(0.0, 0.0, 10_000.0, 10_000.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, f64::INFINITY, 10.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, 0.0, 10.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, 40.0, 40.0)).is_some()); + assert!(Samples::new(vec![0; 3], (1, 1), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none()); + assert!(Samples::new(vec![], (usize::MAX, 2), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none()); + } + + #[test] + fn the_group_takes_the_objects_identity() { + let mut n = effect(json!({"clip": false, "frequency": 36})); + n.opacity = 0.5; + n.name = Some("art".into()); + let (object, g) = split(&n); + assert!(!has_halftone(&object)); + assert_eq!((object.opacity, g.opacity, g.id, g.name.as_deref()), (1.0, 0.5, n.id, Some("art"))); + assert_eq!(reach(&n), 4.0); + assert_eq!(reach(&effect(json!({}))), 0.0); + } +} diff --git a/crates/effects/src/addons/path.rs b/crates/effects/src/addons/path.rs new file mode 100644 index 000000000..556122f63 --- /dev/null +++ b/crates/effects/src/addons/path.rs @@ -0,0 +1,243 @@ +//! Path Effects (Path Precision Tools): live geometry effects that rewrite an object's path with the +//! add-on's path kernels (Effect › Path Effects). Each is evaluated with the other geometry effects +//! ([`crate::apply_geometry`]), so it draws, measures, exports and expands as they do; the geometry +//! is written only by Expand Appearance. +//! +//! Parameters come from documents and scripts, so each is clamped to its documented range, and a +//! path with a coordinate past [`MAX_COORDINATE`] or not finite is left as it is. + +use serde_json::{Value, json}; +use vectorcraft_doc::Dash; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_pathops::addons::pathtools::{self as tools, AnchorSpacing, CloseMode, ExtendMode, SMOOTH_RATIO, SegmentKind}; +use vectorcraft_pathops::addons::{PT_PER_TOLERANCE, retract_tiny_handles, simplify_keep_shape}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::stroke; +use crate::util::{flag, num, text}; + +/// What every Path Effect's id starts with. +pub const PREFIX: &str = "addon.path."; +pub const DASHES: &str = "addon.path.dashes"; +pub const ADD_ANCHORS: &str = "addon.path.addAnchors"; +pub const EXTEND: &str = "addon.path.extend"; +pub const CLOSE: &str = "addon.path.close"; +pub const SMOOTH: &str = "addon.path.smooth"; +pub const RETRACT: &str = "addon.path.retract"; +pub const REVERSE: &str = "addon.path.reverse"; +pub const SIMPLIFY: &str = "addon.path.simplify"; +pub const SPLIT: &str = "addon.path.split"; + +const MENU: &[&str] = &["Effect", "Path Effects"]; + +/// The dash and gap parameters of [`DASHES`], pair by pair. +const DASH_PAIRS: [(&str, &str); 5] = [("dash1", "gap1"), ("dash2", "gap2"), ("dash3", "gap3"), ("dash4", "gap4"), ("dash5", "gap5")]; +/// Longest dash or gap, and the farthest offset (pt). +const MAX_DASH: f64 = 10_000.0; +/// Longest stretch [`EXTEND`] adds or takes off an end, in points and in percent of the path. +const MAX_EXTEND: f64 = 100_000.0; +const MAX_EXTEND_PERCENT: f64 = 1000.0; +/// Most anchors [`ADD_ANCHORS`] adds to one segment. +const MAX_PER_SEGMENT: f64 = 1000.0; +/// Closest and farthest apart [`ADD_ANCHORS`]' anchors are (pt). +const DISTANCE_RANGE: (f64, f64) = (0.01, 100_000.0); +/// The range of [`SMOOTH`]'s ratio. +const RATIO_RANGE: (f64, f64) = (0.01, 0.5); +/// Longest handle length [`RETRACT`] takes (pt). +const MAX_HANDLE: f64 = 100_000.0; +/// Highest tolerance of [`SIMPLIFY`]. +const MAX_TOLERANCE: f64 = 200.0; +// shortcut: simplifying measures every anchor on every draw, so a larger path draws as it is; lift +// this if the kernel gets an incremental pass. +/// Most anchors [`SIMPLIFY`] takes in one path. +const MAX_SIMPLIFY_ANCHORS: usize = 50_000; +/// A path with a coordinate past this, or one that isn't finite, is left as it is: lengths measured +/// on it would overflow. +const MAX_COORDINATE: f64 = 1e12; + +/// The effects' catalogue entries. +pub(crate) fn catalog() -> Vec { + let entry = |id, label, params, defaults, always| EffectInfo { + id, + label, + menu: MENU, + params, + defaults, + raster: false, + lengths: Lengths { always, absolute: &[] }, + }; + vec![ + entry( + DASHES, + "Dashes to Geometry…", + "{dash1: pt 0..10000 (12), gap1: pt 0..10000 (6), dash2: pt 0..10000 (0), gap2: pt 0..10000 (0), dash3: pt 0..10000 (0), gap3: pt 0..10000 (0), dash4: pt 0..10000 (0), gap4: pt 0..10000 (0), dash5: pt 0..10000 (0), gap5: pt 0..10000 (0), offset: pt -10000..10000 (0; how far into the pattern the path starts; not with alignCorners), alignCorners: bool (false; every run between corners and path ends holds whole periods, stretched or squeezed to fit, with a dash centred on each corner and end)} cuts the path into its dashes, open subpaths along it that the stroke paints as it paints any path. A dash of 0 is a point its stroke's round or projecting cap paints; a pair of two zeros is left out, and the pattern repeats. A pattern of nothing, or of more than 200000 dashes, leaves the path as it is; a dash longer than a closed subpath keeps it closed", + json!({"dash1": 12.0, "gap1": 6.0, "dash2": 0.0, "gap2": 0.0, "dash3": 0.0, "gap3": 0.0, "dash4": 0.0, "gap4": 0.0, "dash5": 0.0, "gap5": 0.0, "offset": 0.0, "alignCorners": false}), + &["dash1", "gap1", "dash2", "gap2", "dash3", "gap3", "dash4", "gap4", "dash5", "gap5", "offset"], + ), + entry( + ADD_ANCHORS, + "Add Anchors…", + "{target: \"all\"|\"curved\"|\"straight\" (all; the segments that get anchors), spacing: \"count\"|\"distance\" (count), count: 1..1000 (1; anchors on each segment, evenly spaced by length), distance: pt 0.01..100000 (20; an anchor every so far along each segment from its start), maxCount: 1..1000 (100; most anchors added to one segment)} adds anchors without changing the shape; past 2 million anchors in all the path stays as it is", + json!({"target": "all", "spacing": "count", "count": 1, "distance": 20.0, "maxCount": 100}), + &["distance"], + ), + entry( + EXTEND, + "Extend Path…", + "{startLength: pt -100000..100000 (10), startPercent: % of the path's length -1000..1000 (0), startShape: \"straight\"|\"arc\" (straight), endLength: pt -100000..100000 (10), endPercent: % of the path's length -1000..1000 (0), endShape: \"straight\"|\"arc\" (straight)} lengthens each end of an open subpath by its length and percent together, or shortens it back along itself when they come to less than 0. Straight runs on along the end's direction; arc follows the circle of the end's curvature, at most half way round. Closed subpaths stay as they are, and so does an end that would be shortened to nothing", + json!({"startLength": 10.0, "startPercent": 0.0, "startShape": "straight", "endLength": 10.0, "endPercent": 0.0, "endShape": "straight"}), + &["startLength", "endLength"], + ), + entry( + CLOSE, + "Close Path…", + "{mode: \"straight\"|\"smooth\" (straight; smooth: the closing segment leaves both ends smoothly)} closes every open subpath of two anchors or more", + json!({"mode": "straight"}), + &[], + ), + entry( + SMOOTH, + "Smooth Anchors…", + "{ratio: 0.01..0.5 (0.3905; a handle's length as a share of the distance to the nearer neighbour; 0.3905 makes a square's corners a circle)} makes every anchor smooth: a lone handle is mirrored, two are brought into line keeping their lengths, and an anchor without handles gets them at right angles to the bisector of its neighbours", + json!({"ratio": SMOOTH_RATIO}), + &[], + ), + entry( + RETRACT, + "Retract Handles…", + "{handles: \"all\"|\"shorter\" (all; shorter: only those shorter than length), length: pt 0..100000 (5)} takes the handles back to their anchors; an anchor that loses one becomes a corner", + json!({"handles": "all", "length": 5.0}), + &["length"], + ), + entry( + REVERSE, + "Reverse Direction", + "{} runs every subpath the other way: the shape stays, and arrowheads, dashes and the effects after it swap ends", + json!({}), + &[], + ), + entry( + SIMPLIFY, + "Simplify Anchors…", + "{tolerance: 0..200 (10; higher removes more anchors and lets the outline move further, up to tolerance × 0.02 pt), protectCorners: bool (true; anchors where the path turns by more than 30° stay)} removes the anchors a path can do without, refitting the handles beside each; open ends stay; a path of more than 50000 anchors stays as it is", + json!({"tolerance": 10.0, "protectCorners": true}), + &[], + ), + entry( + SPLIT, + "Split at Anchors…", + "{anchors: \"corner\"|\"smooth\"|\"both\" (both; smooth: where the handles stand in line, corner: the others)} cuts subpaths into open pieces at those anchors; the shape stays, and an open path's ends aren't cuts", + json!({"anchors": "both"}), + &[], + ), + ] +} + +/// Path Effect `id` with parameters `p` on `path`, which keeps to `max` anchors where anchors are +/// added one by one. `path` as it is when `id` is no such effect or the path can't be measured. +pub(crate) fn apply(id: &str, p: &Value, path: &PathData, max: usize) -> PathData { + if !measurable(path) { + return path.clone(); + } + let edit = |change: &dyn Fn(&mut SubPath)| { + let mut out = path.clone(); + out.subpaths.iter_mut().for_each(change); + out + }; + match id { + DASHES => dashes(path, p), + ADD_ANCHORS => add_anchors(path, p, max).unwrap_or_else(|| path.clone()), + EXTEND => PathData::new(path.subpaths.iter().map(|sp| extend(sp, p)).collect()), + CLOSE => { + let mode = if text(p, "mode", "straight") == "smooth" { CloseMode::Smooth } else { CloseMode::Straight }; + edit(&|sp| { + tools::close(sp, mode, SMOOTH_RATIO); + }) + } + SMOOTH => { + let ratio = num(p, "ratio", SMOOTH_RATIO).clamp(RATIO_RANGE.0, RATIO_RANGE.1); + edit(&|sp| tools::smooth_anchors(sp, ratio)) + } + RETRACT if text(p, "handles", "all") == "shorter" => { + let length = num(p, "length", 5.0).clamp(0.0, MAX_HANDLE); + edit(&|sp| { + retract_tiny_handles(sp, length); + }) + } + RETRACT => edit(&|sp| sp.anchors.iter_mut().for_each(|a| a.retract())), + REVERSE => edit(&SubPath::reverse), + SIMPLIFY if path.anchor_count() <= MAX_SIMPLIFY_ANCHORS => { + let tolerance = num(p, "tolerance", 10.0).clamp(0.0, MAX_TOLERANCE) * PT_PER_TOLERANCE; + simplify_keep_shape(path, tolerance, flag(p, "protectCorners", true)) + } + SPLIT => { + let at = text(p, "anchors", "both"); + let (corners, smooth) = (at != "smooth", at != "corner"); + PathData::new(path.subpaths.iter().flat_map(|sp| tools::split_at_anchors(sp, corners, smooth)).collect()) + } + _ => path.clone(), + } +} + +/// Is every coordinate of `path` within [`MAX_COORDINATE`]? +fn measurable(path: &PathData) -> bool { + let near = |q: Point| q.x.abs() <= MAX_COORDINATE && q.y.abs() <= MAX_COORDINATE; + path.anchors().all(|(_, _, a)| near(a.p) && near(a.h_in) && near(a.h_out)) +} + +/// [`DASHES`]: the dashes of `path` as open subpaths, those of no length as subpaths that stay on +/// their point; `path` as it is when the pattern draws no dashes. +fn dashes(path: &PathData, p: &Value) -> PathData { + let length = |key| num(p, key, 0.0).clamp(0.0, MAX_DASH); + let pairs = DASH_PAIRS.iter().map(|(dash, gap)| (length(dash), length(gap))); + let pattern = pairs.filter(|(d, g)| *d > 0.0 || *g > 0.0).flat_map(|(d, g)| [d, g]).collect(); + let dash = Dash { pattern, offset: num(p, "offset", 0.0).clamp(-MAX_DASH, MAX_DASH), align_corners: flag(p, "alignCorners", false) }; + let Some(dashed) = stroke::dash(&path.to_bezpath(), &dash) else { return path.clone() }; + let mut out = PathData::from_bezpath(&dashed.path); + out.subpaths.extend(dashed.dots.iter().map(|d| SubPath::polyline(&[d.at, d.at], false))); + out +} + +/// [`ADD_ANCHORS`] within `max` anchors in all; `None` past them. +fn add_anchors(path: &PathData, p: &Value, max: usize) -> Option { + let which = match text(p, "target", "all") { + "curved" => SegmentKind::Curved, + "straight" => SegmentKind::Straight, + _ => SegmentKind::All, + }; + let spacing = match text(p, "spacing", "count") { + "distance" => AnchorSpacing::Distance(num(p, "distance", 20.0).clamp(DISTANCE_RANGE.0, DISTANCE_RANGE.1)), + _ => AnchorSpacing::Count(num(p, "count", 1.0).clamp(1.0, MAX_PER_SEGMENT) as usize), + }; + let cap = num(p, "maxCount", 100.0).clamp(1.0, MAX_PER_SEGMENT) as usize; + let mut room = max.saturating_sub(path.anchor_count()); + let mut out = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let added = tools::add_anchors(sp, which, spacing, cap, room)?; + room = room.saturating_sub(added.anchors.len().saturating_sub(sp.anchors.len())); + out.push(added); + } + Some(PathData::new(out)) +} + +/// [`EXTEND`] on one subpath, the start then the end; what can't be done stays as it is. +fn extend(sp: &SubPath, p: &Value) -> SubPath { + let total = sp.anchor_arc_positions().last().copied().unwrap_or(0.0); + let by = |length, percent| { + num(p, length, 0.0).clamp(-MAX_EXTEND, MAX_EXTEND) + num(p, percent, 0.0).clamp(-MAX_EXTEND_PERCENT, MAX_EXTEND_PERCENT) / 100.0 * total + }; + let shape = |key| if text(p, key, "straight") == "arc" { ExtendMode::Arc } else { ExtendMode::Straight }; + let ends = [(true, by("startLength", "startPercent"), shape("startShape")), (false, by("endLength", "endPercent"), shape("endShape"))]; + let mut out = sp.clone(); + for (at_start, by, shape) in ends { + if by != 0.0 + && let Some(changed) = tools::extend_open(&out, at_start, by, shape) + { + out = changed; + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/addons/path/tests.rs b/crates/effects/src/addons/path/tests.rs new file mode 100644 index 000000000..ef99706cc --- /dev/null +++ b/crates/effects/src/addons/path/tests.rs @@ -0,0 +1,278 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::Effect; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; + +use super::*; +use crate::{apply_geometry, effect_info, is_geometry, is_length, is_raster, length_range}; + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +fn line(pts: &[(f64, f64)], closed: bool) -> PathData { + PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| pt(x, y)).collect::>(), closed)) +} + +fn square() -> PathData { + line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true) +} + +/// A curve: a smooth anchor between two ends. +fn curve() -> PathData { + let mid = Anchor::smooth(pt(50.0, -30.0), pt(70.0, -30.0)); + PathData::single(SubPath::new( + vec![ + Anchor { h_out: pt(10.0, -20.0), ..Anchor::corner(pt(0.0, 0.0)) }, + mid, + Anchor { h_in: pt(90.0, -20.0), ..Anchor::corner(pt(100.0, 0.0)) }, + ], + false, + )) +} + +fn run(id: &str, params: Value, path: &PathData) -> PathData { + let e = Effect { id: id.into(), params, visible: true }; + apply_geometry(&[e], path, path.bounds().unwrap_or(Rect::ZERO)) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-3 +} + +const ALL: [&str; 9] = [DASHES, ADD_ANCHORS, EXTEND, CLOSE, SMOOTH, RETRACT, REVERSE, SIMPLIFY, SPLIT]; + +#[test] +fn the_effects_are_geometry_effects_of_the_path_effects_menu() { + for id in ALL { + let info = effect_info(id).unwrap(); + assert!(is_geometry(id) && !is_raster(id), "{id}"); + assert_eq!(info.menu, ["Effect", "Path Effects"], "{id}"); + assert!(id.starts_with(PREFIX)); + } + assert!(is_length(DASHES, "gap5", false) && is_length(EXTEND, "endLength", false) && !is_length(EXTEND, "endPercent", false)); + assert_eq!(length_range(DASHES, "offset"), Some(-10_000.0..=10_000.0)); +} + +#[test] +fn dashes_become_open_subpaths_along_the_path() { + let out = run(DASHES, json!({"dash1": 10, "gap1": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert_eq!(out.subpaths.len(), 7, "periods of 15 on 100"); + assert!(out.subpaths.iter().all(|s| !s.closed)); + assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 25.0)); + // Two pairs, and a pair of zeros between them left out. + let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "dash3": 20, "gap3": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 35.0)); + assert!(near(out.subpaths[2].anchors[0].p.x, 40.0)); + // The offset starts part way into the pattern. + let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "offset": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert!(near(out.subpaths[0].anchors[1].p.x, 5.0)); +} + +#[test] +fn aligned_dashes_centre_on_corners_and_ends() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let out = run(DASHES, json!({"dash1": 10, "gap1": 10, "alignCorners": true}), &open); + let on = |p: Point| out.subpaths.iter().any(|s| (0..s.segment_count()).any(|i| s.anchors[i].p == p || s.anchors[i + 1].p == p)); + assert!(on(pt(0.0, 0.0)) && on(pt(100.0, 100.0)), "a dash on each end"); + let corner = out.subpaths.iter().find(|s| s.anchors.iter().any(|a| a.p == pt(100.0, 0.0))).expect("a dash over the corner"); + assert!(near(corner.anchors[0].p.distance(pt(100.0, 0.0)), corner.anchors[2].p.distance(pt(100.0, 0.0))), "centred: {corner:?}"); +} + +#[test] +fn zero_dashes_are_points_and_patterns_of_nothing_leave_the_path() { + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + let dots = run(DASHES, json!({"dash1": 0, "gap1": 25}), &path); + assert_eq!(dots.subpaths.len(), 5); + assert!(dots.subpaths.iter().all(|s| s.anchors.len() == 2 && s.anchors[0].p == s.anchors[1].p)); + for params in [ + json!({"dash1": 0, "gap1": 0}), + json!({"dash1": -5, "gap1": -5}), + json!({"dash1": 0.0001, "gap1": 0.0001}), + json!({"dash1": "x", "gap1": null}), + ] { + assert_eq!(run(DASHES, params.clone(), &path), path, "{params}"); + } +} + +#[test] +fn add_anchors_spaces_them_and_keeps_to_the_caps() { + let square = square(); + assert_eq!(run(ADD_ANCHORS, json!({}), &square).anchor_count(), 8, "one on each segment"); + assert_eq!(run(ADD_ANCHORS, json!({"count": 3}), &square).anchor_count(), 16); + assert_eq!(run(ADD_ANCHORS, json!({"count": 3, "maxCount": 2}), &square).anchor_count(), 12); + let out = run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 30}), &square); + assert_eq!(out.anchor_count(), 16, "at 30, 60 and 90 on each side"); + assert!(near(out.subpaths[0].anchors[2].p.x, 60.0)); + assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 25}), &square).anchor_count(), 16, "none on the segment's end"); + assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 1e-9, "maxCount": 2}), &square).anchor_count(), 12); + // Only the segments asked for. + let mixed = PathData::new(vec![curve().subpaths[0].clone(), square.subpaths[0].clone()]); + assert_eq!(run(ADD_ANCHORS, json!({"target": "curved"}), &mixed).anchor_count(), 7 + 2); + assert_eq!(run(ADD_ANCHORS, json!({"target": "straight"}), &mixed).anchor_count(), 7 + 4); + // The shape stays. + let out = run(ADD_ANCHORS, json!({"count": 4}), &curve()); + assert_eq!(out.anchor_count(), 3 + 8); + assert!(near(out.length(), curve().length())); + assert_eq!(out.bounds().map(|b| (b.y0 * 100.0).round()), curve().bounds().map(|b| (b.y0 * 100.0).round())); +} + +#[test] +fn add_anchors_past_the_budget_leaves_the_path() { + let path = square(); + let most = json!({"count": 1000, "maxCount": 1000}); + assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4003), path); + assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4004).anchor_count(), 4004); + // Many segments, each with the most anchors: refused before they are built. + let long = line(&(0..4000).map(|i| (i as f64, 0.0)).collect::>(), false); + assert_eq!(run(ADD_ANCHORS, json!({"count": 1000, "maxCount": 1000}), &long).anchor_count(), 4000, "4 million would pass the budget"); +} + +#[test] +fn extend_lengthens_and_shortens_each_end() { + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + let out = run(EXTEND, json!({"startLength": 20, "endLength": 10}), &path); + let xs: Vec = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, [-20.0, 0.0, 100.0, 110.0]); + // Percent of the path's length, adding to the length; negative shortens. + let out = run(EXTEND, json!({"startLength": 0, "endLength": 5, "endPercent": 10}), &path); + assert!(out.subpaths[0].anchors.last().is_some_and(|a| near(a.p.x, 115.0))); + let out = run(EXTEND, json!({"startLength": -10, "endLength": 0, "endPercent": -50}), &path); + let xs: Vec = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert!(near(xs[0], 10.0) && near(xs[1], 50.0), "{xs:?}"); + // An arc carries on round the end's curve. + let arc = run(EXTEND, json!({"startLength": 0, "endLength": 40, "endShape": "arc"}), &curve()); + let straight = run(EXTEND, json!({"startLength": 0, "endLength": 40}), &curve()); + assert!(arc.anchor_count() >= straight.anchor_count()); + assert!((arc.length() - straight.length()).abs() < 0.1); + assert_ne!(arc.subpaths[0].anchors.last().map(|a| a.p), straight.subpaths[0].anchors.last().map(|a| a.p)); +} + +#[test] +fn extend_leaves_what_it_cannot_change() { + let closed = square(); + assert_eq!(run(EXTEND, json!({"endLength": 50}), &closed), closed); + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": -500}), &path), path, "nothing would be left"); + assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": 0, "endPercent": -100}), &path), path); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false)); + assert_eq!(run(EXTEND, json!({}), &one), one); +} + +#[test] +fn close_joins_open_subpaths_straight_or_smooth() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let out = run(CLOSE, json!({}), &open); + assert!(out.subpaths[0].closed && out.subpaths[0].segment_is_line(2)); + let out = run(CLOSE, json!({"mode": "smooth"}), &open); + assert!(out.subpaths[0].closed && !out.subpaths[0].segment_is_line(2)); + assert_eq!(run(CLOSE, json!({}), &square()), square()); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false)); + assert_eq!(run(CLOSE, json!({}), &one), one); +} + +#[test] +fn smooth_makes_a_square_round_and_clamps_its_ratio() { + let out = run(SMOOTH, json!({}), &square()); + assert!(out.subpaths[0].anchors.iter().all(|a| near(a.h_out.distance(a.p), 39.05) && a.has_in())); + let at = |ratio: f64| run(SMOOTH, json!({ "ratio": ratio }), &square()).subpaths[0].anchors[0]; + assert!(near(at(9.0).h_out.distance(pt(0.0, 0.0)), 50.0)); + assert!(near(at(-9.0).h_out.distance(pt(0.0, 0.0)), 1.0)); +} + +#[test] +fn retract_takes_all_handles_or_the_short_ones() { + let out = run(RETRACT, json!({}), &curve()); + assert!(out.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out())); + // The ends' handles are about 22 pt long and the middle anchor's 20. + let out = run(RETRACT, json!({"handles": "shorter", "length": 21}), &curve()); + let a = &out.subpaths[0].anchors; + assert!(!a[1].has_in() && !a[1].has_out() && a[0].has_out() && a[2].has_in()); + assert_eq!(run(RETRACT, json!({"handles": "shorter", "length": 1}), &curve()), curve()); +} + +#[test] +fn reverse_runs_the_other_way_and_twice_is_as_it_was() { + let out = run(REVERSE, json!({}), &curve()); + assert_eq!(out.subpaths[0].anchors[0].p, pt(100.0, 0.0)); + assert_eq!(run(REVERSE, json!({}), &out), curve()); +} + +#[test] +fn simplify_takes_anchors_within_the_tolerance_and_stops_at_its_cap() { + let pts: Vec<(f64, f64)> = (0..=40).map(|i| (i as f64, (i as f64 / 40.0 * std::f64::consts::PI).sin() * 3.0)).collect(); + let path = line(&pts, false); + let out = run(SIMPLIFY, json!({"tolerance": 200}), &path); + assert!(out.anchor_count() < 20, "{}", out.anchor_count()); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 0}), &path), path); + // Corners stay unless told otherwise. + let corner = line(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], false); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 200}), &corner), corner); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 200, "protectCorners": false}), &corner).anchor_count(), 2); + let long = line(&(0..=MAX_SIMPLIFY_ANCHORS).map(|i| (i as f64, 0.0)).collect::>(), false); + assert_eq!(run(SIMPLIFY, json!({}), &long).anchor_count(), long.anchor_count()); +} + +#[test] +fn split_cuts_at_the_anchors_asked_for() { + let mixed = PathData::single(SubPath::new( + vec![ + Anchor::corner(pt(0.0, 0.0)), + Anchor::smooth(pt(50.0, 0.0), pt(60.0, 0.0)), + Anchor::corner(pt(100.0, 0.0)), + Anchor::corner(pt(100.0, 50.0)), + ], + false, + )); + assert_eq!(run(SPLIT, json!({}), &mixed).subpaths.len(), 3); + assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &mixed).subpaths.len(), 2); + assert_eq!(run(SPLIT, json!({"anchors": "corner"}), &mixed).subpaths.len(), 2); + let out = run(SPLIT, json!({}), &square()); + assert_eq!(out.subpaths.len(), 4); + assert!(out.subpaths.iter().all(|s| !s.closed && s.anchors.len() == 2)); + assert!(near(out.length(), 400.0)); + assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &square()), square(), "no smooth anchors"); +} + +/// Whatever a file holds: every effect takes wrong types, huge numbers and paths of nothing, of one +/// anchor or past measuring, and keeps to the budget. +#[test] +fn junk_params_and_paths_are_harmless() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!("x"), + json!({"dash1": 1e308, "gap1": 1e308, "dash2": -1e308, "gap2": "x", "offset": 1e308, "alignCorners": "yes"}), + json!({"dash1": 1e-300, "gap1": 1e-300, "offset": -1e308, "alignCorners": true}), + json!({"target": 3, "spacing": ["distance"], "count": 1e308, "distance": 1e-308, "maxCount": -1e308}), + json!({"spacing": "distance", "distance": -1e308, "maxCount": 1e308}), + json!({"startLength": 1e308, "startPercent": 1e308, "startShape": 7, "endLength": -1e308, "endPercent": -1e308, "endShape": "arc"}), + json!({"mode": {}, "ratio": 1e308, "handles": "shorter", "length": 1e308, "tolerance": 1e308, "protectCorners": "no", "anchors": 9}), + json!({"ratio": -1e308, "length": -1e308, "tolerance": -1e308, "anchors": "none"}), + ]; + let far = line(&[(1e308, -1e308), (-1e308, 1e308), (1e300, 0.0)], false); + let nan = line(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)], false); + let tiny = line(&[(0.0, 0.0), (1e-300, 0.0), (1e-300, 1e-300)], true); + let same = line(&[(5.0, 5.0), (5.0, 5.0), (5.0, 5.0)], true); + let empty = PathData::single(SubPath::default()); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], true)); + let paths = [square(), curve(), far.clone(), nan.clone(), tiny, same, empty, one, PathData::default()]; + for id in ALL { + for params in &junk { + for path in &paths { + let out = super::apply(id, &crate::merged_params(id, params), path, 100_000); + assert!(out.anchor_count() <= 100_000.max(path.anchor_count()) + 8 * path.subpaths.len(), "{id} {params}"); + } + } + // Compared as text: NaN never equals itself. + assert_eq!(format!("{:?}", super::apply(id, &json!({}), &nan, 100)), format!("{nan:?}"), "{id}"); + assert_eq!(super::apply(id, &json!({}), &far, 100), far, "{id}"); + } +} + +/// The dash generator's limit holds: a pattern too fine for the path leaves it solid. +#[test] +fn too_many_dashes_leave_the_path() { + let long = line(&[(0.0, 0.0), (1e9, 0.0)], false); + assert_eq!(run(DASHES, json!({"dash1": 1, "gap1": 1}), &long), long); +} diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 7350958cc..a656f11e8 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -14,9 +14,10 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint, keep_model}; -use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node}; +use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; use vectorcraft_doc::{Document, Effect, Node, NodeKind}; +use crate::addons::duotone::{Duotone, SwatchKinds}; use crate::merged_params; use crate::raster::color_param; use crate::util::{flag, num, text}; @@ -72,7 +73,7 @@ pub fn is_channel(s: &str) -> bool { } /// Entries of a sampled tone curve. -const LUT: usize = 256; +pub(crate) const LUT: usize = 256; /// One adjustment, its parameters normalised. #[derive(Clone, Debug, PartialEq)] @@ -92,6 +93,16 @@ enum Adjust { Addon(crate::addons::color::Adjust), } +impl Adjust { + /// Duotone, which maps a colour with its swatch link ([`ColorMap::apply_link`]). + fn duotone(&self) -> Option<&Duotone> { + match self { + Adjust::Addon(crate::addons::color::Adjust::Duotone(d)) => Some(d), + _ => None, + } + } +} + /// The colour adjustments of an effect list, composed in order. #[derive(Clone, Debug, PartialEq)] pub struct ColorMap(Vec); @@ -104,17 +115,44 @@ impl ColorMap { /// Adjust display (sRGB) components 0..1. pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] { - self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })) + apply_rgb(&self.0, rgb) } /// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is). + /// Duotone gives its inks' colour instead ([`Duotone`]). pub fn apply(&self, c: Color) -> Color { - let rgb = c.to_rgb(); - let out = self.apply_rgb(rgb); - if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) { - return c; + let (mut c, mut link, mut tint) = (c, None, 1.0); + self.apply_link(&mut c, &mut link, &mut tint, &|_| None); + c + } + + /// Adjust a colour with its swatch link and tint (`link`, `tint`), as a paint holds them; + /// true when it changed. A colour that changes loses its link, except that Duotone with one + /// tone of a swatch the document has (`swatches`, [`SwatchKinds`]) links it to that swatch at + /// the tone's tint. The adjustments between Duotones work in display RGB, each run keeping + /// the colour's model. + pub fn apply_link(&self, c: &mut Color, link: &mut Option, tint: &mut f32, swatches: &SwatchKinds) -> bool { + let mut changed = false; + let mut rest: &[Adjust] = &self.0; + while !rest.is_empty() { + let (run, after) = rest.split_at(rest.iter().position(|a| a.duotone().is_some()).unwrap_or(rest.len())); + if !run.is_empty() { + let rgb = c.to_rgb(); + let out = apply_rgb(run, rgb); + if !out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) { + *c = keep_model(*c, Color::rgb(out[0], out[1], out[2])); + *link = None; + *tint = 1.0; + changed = true; + } + } + let Some((duotone, tail)) = after.split_first() else { break }; + if let Some(d) = duotone.duotone() { + changed |= d.map_link(c, link, tint, swatches); + } + rest = tail; } - keep_model(c, Color::rgb(out[0], out[1], out[2])) + changed } /// Adjust an 8-bit sRGB pixel. @@ -124,6 +162,11 @@ impl ColorMap { } } +/// Display (sRGB) components 0..1 through `adjustments`, in order. +fn apply_rgb(adjustments: &[Adjust], rgb: [f32; 3]) -> [f32; 3] { + adjustments.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })) +} + /// The visible colour adjustments of `effects` as one map, `None` when there are none. pub fn color_map(effects: &[Effect]) -> Option { let v: Vec = @@ -233,12 +276,12 @@ impl Adjust { } /// `f` sampled at [`LUT`] inputs 0..1. -fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> { +pub(crate) fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> { (0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect() } /// The tone curve `lut` at `v` (linear between samples). -fn lookup(lut: &[f32], v: f32) -> f32 { +pub(crate) fn lookup(lut: &[f32], v: f32) -> f32 { let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32; let i = (x.floor() as usize).min(LUT - 2); let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v)); @@ -248,6 +291,12 @@ fn lookup(lut: &[f32], v: f32) -> f32 { /// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter: /// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line. pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { + // At most a point per level is meaningful. + curve_points_on(v, 255.0, 256) +} + +/// [`curve_points`] on a scale of 0..`max`, reading at most `cap` points. +pub fn curve_points_on(v: Option<&Value>, max: f64, cap: usize) -> Vec<(f32, f32)> { let mut pts: Vec<(f64, f64)> = match v { Some(Value::String(s)) => s .split(|c: char| c.is_whitespace() || c == ';') @@ -260,10 +309,9 @@ pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { _ => vec![], }; pts.retain(|(x, y)| x.is_finite() && y.is_finite()); - // At most a point per level is meaningful. - pts.truncate(256); - let mut pts: Vec<(f32, f32)> = - pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect(); + pts.truncate(cap); + let unit = |v: f64| (v.clamp(0.0, max) / max) as f32; + let mut pts: Vec<(f32, f32)> = pts.into_iter().map(|(x, y)| (unit(x), unit(y))).collect(); pts.sort_by(|a, b| a.0.total_cmp(&b.0)); pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); if pts.len() < 2 { @@ -357,15 +405,22 @@ fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] { /// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is). pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option + 'a; -/// The live visitor: every colour through `map`, embedded images through the hook. +/// The live visitor: every colour through `map` (paints with their swatch links, the document's +/// swatches being `swatches`), embedded images through the hook. struct Live<'a, 'b> { map: &'a ColorMap, + swatches: &'a SwatchKinds<'b>, images: &'a mut ImageHook<'b>, } +/// `p`'s colours through `map` with their swatch links ([`ColorMap::apply_link`]). +fn map_paint(p: &mut Paint, map: &ColorMap, swatches: &SwatchKinds) -> bool { + map_links(p, &|c, link, tint| map.apply_link(c, link, tint, swatches)) +} + impl ColorVisitor for Live<'_, '_> { fn paint(&mut self, p: &mut Paint) -> bool { - map_paint(p, &|c| self.map.apply(c)) + map_paint(p, self.map, self.swatches) } fn color(&mut self, c: Color) -> Color { self.map.apply(c) @@ -378,12 +433,13 @@ impl ColorVisitor for Live<'_, '_> { /// `n` with the colour adjustments in it applied, `None` when it has none: its members' first, /// then those of its fills and strokes on their paints, then its own followed by `outer` (the /// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects -/// are gone from the result; embedded images take the keys `images` gives. -pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option { +/// are gone from the result; embedded images take the keys `images` gives. `swatches` tells the +/// document's global and spot swatches apart ([`crate::addons::duotone::swatch_kind`]). +pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images: &mut ImageHook<'_>) -> Option { let mut m: Option = None; if let Some(ch) = n.children() { for (i, c) in ch.iter().enumerate() { - if let Some(new) = adjust(c, None, images) + if let Some(new) = adjust(c, None, swatches, images) && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) { *slot = Arc::new(new); @@ -394,7 +450,7 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> if let Some(map) = color_map(item.effects()) && let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i) { - map_paint(it.paint_mut(), &|c| map.apply(c)); + map_paint(it.paint_mut(), &map, swatches); it.effects_mut().retain(|e| !is_adjustment(&e.id)); } } @@ -406,7 +462,7 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> if let Some(map) = map { let mm = m.get_or_insert_with(|| n.clone()); mm.appearance.effects.retain(|e| !is_adjustment(&e.id)); - recolor_node(mm, Reach::ALL, &mut Live { map: &map, images }); + recolor_node(mm, Reach::ALL, &mut Live { map: &map, swatches, images }); } m } @@ -417,19 +473,20 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { - let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()); - art = crate::reshape::as_art(Some(d), n, symbol.as_deref())?; + let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)); + art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } _ => n, }; + let kinds = crate::addons::duotone::swatch_kinds(d); let mut hook = |key: &str, map: &ColorMap| -> Option { let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?; let k = blob.content_key(); d.images.entry(k.clone()).or_insert(blob); Some(k) }; - adjust(src, None, &mut hook) + adjust(src, None, &|name| kinds.get(name).copied(), &mut hook) } #[cfg(test)] @@ -492,17 +549,17 @@ mod tests { let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]); g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true }); assert!(has_adjustment(&g)); - let out = adjust(&g, None, &mut |_, _| None).unwrap(); + let out = adjust(&g, None, &|_| None, &mut |_, _| None).unwrap(); assert!(out.appearance.effects.is_empty()); for c in out.children().unwrap() { assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); } // A hidden effect does nothing; a stroke's own effect recolours just that stroke. g.appearance.effects[0].visible = false; - assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none()); + assert!(!has_adjustment(&g) && adjust(&g, None, &|_| None, &mut |_, _| None).is_none()); let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true }); - let out = adjust(&p, None, &mut |_, _| None).unwrap(); + let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap(); assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff"); assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint()); assert!(out.appearance.items[1].effects().is_empty()); @@ -517,7 +574,7 @@ mod tests { let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill); p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true }); - let out = adjust(&p, None, &mut |_, _| None).unwrap(); + let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap(); let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") }; // The points are what a freeform gradient draws from, not its stops. let hexes: Vec = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect(); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index c92947ac3..cdde7d135 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -52,6 +52,7 @@ pub fn needs_bake(n: &Node) -> bool { || crate::has_solid(n) || has_adjustment(n) || has_crop_marks(n) + || crate::addons::halftone::has_halftone(n) || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) @@ -104,6 +105,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } + // Vector Halftone: the object as its dots. + if let Some(m) = crate::addons::halftone::halftone_art(d, n) { + return Some(bake_pieces(d, m)); + } if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); @@ -117,10 +122,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(bake_pieces(d, m)); } let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)) + } _ => None, }; - let m = reshape(Some(d), n, symbol.as_deref())?; + let m = reshape(Some(d), n, symbol.as_ref())?; return Some(bake_node(d, &m).unwrap_or(m)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its @@ -521,8 +528,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// painted by that fill and every visible stroke without a brush its filled outline in the /// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke: /// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as -/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` -/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. +/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path +/// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`] +/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor +/// a geometry effect, so bending first changes nothing. pub fn bake_appearance(n: &Node) -> Option { if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { return Some(m); @@ -530,6 +539,15 @@ pub fn bake_appearance(n: &Node) -> Option { if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { return None; } + // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its + // patterns: the art Expand Appearance makes of it (each copy an object with its own strokes, + // patterns and gradients), each piece baked in turn. + if crate::has_transform_art(n) + && let Some(mut m) = reshape(None, n, None) + { + bake_appearance_pieces(&mut m); + return Some(m); + } let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))); if !geometric && !n.appearance.items.iter().any(outlined) { @@ -582,6 +600,19 @@ pub fn bake_appearance(n: &Node) -> Option { Some(m) } +/// [`bake_appearance`] every path and compound path in `n` that it changes, in place. +fn bake_appearance_pieces(n: &mut Node) { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + if let Some(m) = bake_appearance(n) { + *n = m; + } + return; + } + for c in n.children_mut().into_iter().flatten() { + bake_appearance_pieces(Arc::make_mut(c)); + } +} + /// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index b150f2004..e16742270 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -106,6 +106,63 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline assert_eq!(ch[1].opacity, 0.5); } +/// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform +/// effect `params`. +fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, stroke, width)); + n.appearance.effects.push(fx("distort.transform", params)); + n +} + +/// The paths under `n`, in document order. +fn baked_paths(n: &Node) -> Vec<&Node> { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().flat_map(|c| baked_paths(c)).collect(), + _ => vec![n], + } +} + +fn widths(n: &Node) -> Vec { + baked_paths(n).iter().map(|p| p.geometric_bounds().unwrap().width()).collect() +} + +#[test] +fn a_baked_transform_copy_keeps_its_scaled_stroke() { + // Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline + // is 204 pt wide. + let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); + let m = bake_appearance(&n).unwrap(); + let w = widths(&m); + assert_eq!(w.len(), 2, "{w:?}"); + assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); + assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined"); +} + +#[test] +fn a_baked_transform_copy_keeps_its_moved_pattern() { + let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true})); + let m = bake_appearance(&n).unwrap(); + let xs: Vec = baked_paths(&m) + .iter() + .map(|p| match p.appearance.fill_paint() { + Paint::Pattern { xf, .. } => xf.as_coeffs()[4], + p => panic!("not a pattern: {p:?}"), + }) + .collect(); + assert_eq!(xs, vec![0.0, 30.0]); +} + +#[test] +fn a_baked_transform_without_its_options_outlines_the_copies_together() { + // Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares. + let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1})); + let m = bake_appearance(&n).unwrap(); + let w = widths(&m); + assert_eq!(w.len(), 1, "{w:?}"); + assert!((w[0] - 202.0).abs() < 0.5, "{w:?}"); +} + /// Black 24 pt "Type" with its baseline at (20, 60). fn type_node(id: u64) -> Node { let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index b060bed1b..d1560ec7c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -803,6 +803,8 @@ pub fn effect_catalog() -> Vec { } } v.extend(crate::addons::color::catalog()); + v.extend(crate::addons::halftone::catalog()); + v.extend(crate::addons::path::catalog()); v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); v.push(g( CROP_MARKS, diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 5b0c14c62..1b496a9c5 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -298,7 +298,8 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData { } /// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle, -/// making at most `max` anchors ([`map_nonlinear_within`]). +/// clockwise on the page for a positive `angle`, making at most `max` anchors +/// ([`map_nonlinear_within`]). pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData { let ang = angle.clamp(-3600.0, 3600.0).to_radians(); if ang == 0.0 { @@ -309,7 +310,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData { let f = move |q: Point| { let d = q - c; let t = (1.0 - d.hypot() / r).max(0.0); - let a = -ang * t; + // y runs down, so a positive turn is clockwise on the page. + let a = ang * t; let (s, co) = a.sin_cos(); c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) }; diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 542098201..f6f007b5c 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -265,6 +265,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), "stylize.scribble" => stylize::scribble(path, b, p), + _ if id.starts_with(crate::addons::path::PREFIX) => crate::addons::path::apply(id, p, path, max), _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { Some(style) => warp::warp(path, b, style, p, max), None => path.clone(), diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index cad97ca56..ce8c38077 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -19,11 +19,13 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops::{self as po, PathfinderOp}; +use crate::reshape::SymbolNodes; + /// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order. pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [ ("pathfinder.add", "Add", PathfinderOp::Unite), @@ -61,35 +63,188 @@ pub fn has_pathfinder(n: &Node) -> bool { /// with the object's opacity and blend mode. pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option; -fn leaves(n: &Node, hook: Option, out: &mut Vec) { +/// The paths and compound paths `n` takes part in a compound shape or a Pathfinder operation as, +/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as +/// the art it draws, and type and symbol instances as their art through `hook`. Past +/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further +/// instances add nothing. +pub fn member_leaves(n: &Node, hook: Option) -> Vec { + let mut out = vec![]; + leaves(n, hook, &SymbolNodes::default(), &mut out); + out +} + +/// [`member_leaves`], with the symbol art given so far counted in `nodes`. +fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { if !n.visible { return; } + // A blend, an envelope or an object with a warp takes part as the art it draws. + if let Some(art) = shape_art_in(n, hook, nodes) { + let first = out.len(); + leaves(&art, hook, nodes, out); + // Every path of a live object's, type's or symbol instance's art is drawn inside the + // object's opacity and blend mode. + if crate::needs_outline(n) { + for l in out.iter_mut().skip(first) { + within(l, n); + } + } + return; + } match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()), // A compound shape takes part as its area, styled like it. - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art(n, hook) { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) { Some(art) => out.push(art), None => { for c in children { - leaves(c, hook, out); + leaves(c, hook, nodes, out); } } }, _ => { - // Every path of the outline art, at any depth, takes the object's opacity and blend - // mode. - if let Some(art) = hook.and_then(|h| h(n)) { + // Every path of the outline art, at any depth, is drawn inside the object's opacity + // and blend mode. A symbol's art comes with the live objects and the instances of + // other symbols inside it given as their art, and the type inside it outlined, so the + // art has nothing left for `hook` to give. + if let Some(h) = hook + && let Some(art) = h(n) + { + let art = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => { + let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); + crate::reshape::outline_nested_text(&mut art, false); + transparency_into_members(&mut art); + art + } + NodeKind::SymbolInstance { .. } => return, + _ => art, + }; let first = out.len(); - leaves(&art, None, out); + leaves(&art, None, nodes, out); for l in out.iter_mut().skip(first) { - (l.opacity, l.blend) = (n.opacity, n.blend); + within(l, n); } } } } } +/// `l`, a path drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend mode when it is +/// not Normal. +fn within(l: &mut Node, n: &Node) { + drawn_in(l, n.opacity, n.blend); +} + +/// `l` drawn inside a group of `opacity` and `blend`: its opacity multiplied by `opacity`, and +/// `blend` when it is not Normal. +fn drawn_in(l: &mut Node, opacity: f32, blend: BlendMode) { + l.opacity *= opacity; + if blend != BlendMode::Normal { + l.blend = blend; + } +} + +/// Move the opacity and blend mode of every group and layer inside `n` (not `n` itself, and not +/// compound shapes, which take part with their own) onto the objects it holds ([`drawn_in`]), so +/// that each path of `n` carries the transparency it is drawn with. +fn transparency_into_members(n: &mut Node) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut() { + if !is_container(c) || c.is_compound_shape() { + continue; + } + let c = Arc::make_mut(c); + let (opacity, blend) = (c.opacity, c.blend); + if let Some(inner) = c.children_mut() { + for g in inner.iter_mut() { + drawn_in(Arc::make_mut(g), opacity, blend); + } + } + (c.opacity, c.blend) = (1.0, BlendMode::Normal); + transparency_into_members(c); + } +} + +/// `n` as the art its visible geometry gives a compound shape or a Pathfinder operation, where that +/// is not its own paths: a live object evaluated (a blend's steps, an envelope's distorted art, +/// type inside it outlined), and a path, compound path, group, compound shape, live object, type or +/// symbol instance with visible geometry effects (such as a warp) reshaped by them, as the renderer +/// draws it. Type and symbol instances inside the art are given as their art by `hook` (without +/// one, type is outlined and symbol instances stay as they are); +/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art +/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their +/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images, +/// for symbol instances without geometry effects, and for one without art from `hook`. +pub fn shape_art(n: &Node, hook: Option) -> Option { + shape_art_in(n, hook, &SymbolNodes::default()) +} + +/// [`shape_art`], with the symbol art given so far counted in `nodes`. +fn shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let mut art = reshaped_art(n, hook, nodes)?; + transparency_into_members(&mut art); + Some(art) +} + +/// [`shape_art_in`] before the groups inside the art pass on their transparency. +fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let fx = crate::reshape::geometry_effects(n); + let outline = hook.unwrap_or(&crate::outline_text); + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)), + NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) { + Some(area) => Some(reshaped(&area, n)), + None => { + let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes); + crate::reshape::reshape_leaves(&mut m, &fx); + without_geometry_effects(&mut m); + Some(m) + } + }, + // A symbol instance with a warp: its symbol's art placed by the instance, with the type + // inside it outlined, reshaped as one piece, as the renderer draws it. + NodeKind::SymbolInstance { symbol, .. } if !fx.is_empty() => { + let h = hook?; + let art = h(n).filter(|art| nodes.charge(art))?; + let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); + crate::reshape::outline_nested_text(&mut art, false); + crate::reshape::reshape_leaves(&mut art, &fx); + (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate); + art.appearance.effects = n.appearance.effects.clone(); + without_geometry_effects(&mut art); + Some(art) + } + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None, + _ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => { + crate::reshape::as_art_with(None, n, None, outline, nodes) + } + _ => None, + } +} + +/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible +/// geometry effects of `n`, without those effects. +fn reshaped(src: &Node, n: &Node) -> Node { + let mut m = src.clone(); + if let Some((path, rule)) = geometry(src) { + let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); + let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); + m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false }; + } + without_geometry_effects(&mut m); + m +} + +fn without_geometry_effects(n: &mut Node) { + if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { + let mut ap = (*n.appearance).clone(); + ap.effects.retain(|e| !crate::is_geometry(&e.id)); + n.appearance = ap.into(); + } +} + fn geometry(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), @@ -114,8 +269,13 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp { /// as its outlines through `hook`, open paths closed) combined with the area of the members below /// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a /// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`], -/// [`PATHFINDER_MAX_OVERLAPS`]). +/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)). pub fn compound_shape_area(n: &Node, hook: Option) -> Option { + compound_shape_area_in(n, hook, &SymbolNodes::default()) +} + +/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`. +fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { if !n.is_compound_shape() { return None; } @@ -124,12 +284,12 @@ pub fn compound_shape_area(n: &Node, hook: Option) -> Option = vec![]; for c in n.children()? { let before = all.len(); - leaves(c, hook, &mut all); + leaves(c, hook, nodes, &mut all); if all.len() > before { members.push((shape_op(c.extras().shape_mode), all.len() - before)); } } - if !within_budget(&all) { + if nodes.spent() || !within_budget(&all) { return None; } if all.is_empty() { @@ -151,7 +311,12 @@ pub fn compound_shape_area(n: &Node, hook: Option) -> Option) -> Option { - let area = compound_shape_area(n, hook)?; + compound_shape_art_in(n, hook, &SymbolNodes::default()) +} + +/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`. +fn compound_shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let area = compound_shape_area_in(n, hook, nodes)?; let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() }); (art.opacity, art.blend) = (n.opacity, n.blend); Some(art) @@ -256,7 +421,8 @@ fn within_budget<'a>(leaves: impl IntoIterator) -> bool { /// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects. /// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`], -/// [`PATHFINDER_MAX_OVERLAPS`]) it returns the children as they are. +/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for +/// each effect) it returns the children as they are. pub fn pathfinder_children(group: &Node, hook: Option) -> Option>> { if !has_pathfinder(group) { return None; @@ -274,11 +440,11 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option, Vec) = lv @@ -295,15 +461,16 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option> = stack .iter() .map(|c| { let mut lv = vec![]; - leaves(c, hook, &mut lv); + leaves(c, hook, &nodes, &mut lv); lv }) .collect(); - if !within_budget(all.iter().flatten()) { + if nodes.spent() || !within_budget(all.iter().flatten()) { return Some(source.to_vec()); } // (style source, shape) per stack entry; keys index `sources`. @@ -440,9 +607,10 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live -/// objects evaluated; the members of a clip group or clipping layer without its clipping path. -/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of -/// `doc`'s symbols that its instances show toward its instance budget. +/// objects evaluated; a compound shape as its area, with the symbol instances among its members +/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its +/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the +/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { if !n.visible { @@ -451,8 +619,8 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)), NodeKind::Group { .. } if n.is_compound_shape() => { - let hook: &dyn Fn(&Node) -> Option = &crate::outline_text; - match compound_shape_area(n, Some(hook)) { + let hook = |c: &Node| crate::outline_object(doc, c); + match compound_shape_area(n, Some(&hook)) { Some(area) => out.push((area, FillRule::NonZero)), None => members(doc, n, out), } @@ -518,7 +686,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its /// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live -/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]). +/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]), and +/// a Pathfinder operation or compound shape takes the symbol instances among its members as their +/// symbols' art from `doc`. pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { if !is_container(n) { return None; @@ -531,8 +701,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } let mut m = n.clone(); if pathfinder { - let hook: &dyn Fn(&Node) -> Option = &crate::outline_text; - let result = pathfinder_children(n, Some(hook)).unwrap_or_default(); + let hook = |c: &Node| crate::outline_object(doc, c); + let result = pathfinder_children(n, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 7c9f649c7..b67279bbb 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -103,6 +103,23 @@ fn shape_modes_follow_the_pathfinder_panel() { assert_eq!(div.len(), 3); } +/// Divide and Outline draw no piece of a member without fill or stroke, the same as when such +/// pieces are left out. +#[test] +fn divide_and_outline_draw_nothing_for_unpainted_members() { + let unpainted = + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0))); + for effect in ["pathfinder.divide", "pathfinder.outline"] { + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}"); + for n in out.iter().filter(|n| n.id == NodeId(2)) { + assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}"); + } + } +} + /// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either /// side of x = 100. fn type_h() -> Node { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c080784c8..00c587eab 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -71,16 +71,16 @@ pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_lea pub use clip::clip_outline; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, - is_pathfinder, member_shapes, paints, pathfinder_children, + is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, }; pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset}; -pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; +pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; pub use reshape::{ - expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art, - outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, + needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index aad8d8edd..ee88f485c 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -19,13 +19,17 @@ pub fn text_outliner() -> Outliner<'static> { Some(&outline_text) } -/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance. -fn outline(doc: Option<&Document>, n: &Node) -> Option { +/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance, +/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None` +/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and +/// Pathfinder operations the symbol instances among their members. +pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { - let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?; - let mut art = (*sym.art).clone(); - art.transform(*xf, false); + let doc = doc?; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = doc.place_symbol_art(symbol, &art, *xf)?; Some(Node::group(n.id, vec![Arc::new(art)])) } _ => outline_text(n), @@ -35,7 +39,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option { /// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a /// document, symbol instances and pattern tiles stay as they are). fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { - let outline = |n: &Node| outline(doc, n); + let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) } diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index e4b478423..fbf22ff6b 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -121,14 +121,16 @@ pub fn is_raster(id: &str) -> bool { || PIXEL_EFFECTS.contains(&id) } -/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour -/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in +/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead, +/// [`crop_marks_art`] and [`crate::addons::halftone::halftone_art`]; colour adjustments recolour, +/// [`adjust`].) Plug-in effects do, installed or not (a missing plug-in /// leaves the geometry as it is). pub fn is_geometry(id: &str) -> bool { !is_raster(id) && !is_pathfinder(id) && !is_adjustment(id) && id != CROP_MARKS + && id != crate::addons::halftone::HALFTONE && !is_three_d(id) && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index a1b8eed98..5a619f8ac 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -29,8 +29,8 @@ use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt}; use crate::group::has_own_paint; use crate::reshape::{geometry_effects, leaf_geometry}; use crate::{ - GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline, - outline_art, paints, reshape, + GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, has_transform_art, + needs_outline, outline_art, paints, reshape, }; /// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects: @@ -187,6 +187,9 @@ impl<'s, 'd> Drawn<'s, 'd> { let painted = || has_own_paint(n) && inner(); match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n), + // A Transform effect that copies with a gradient, scales strokes or moves patterns makes + // objects of their own, as the renderer draws them. + NodeKind::Path { .. } | NodeKind::Compound { .. } if has_transform_art(n) => reshape(Some(self.symbols.document()), n, None), NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n), NodeKind::SymbolInstance { symbol, .. } => { if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0d912b531..fa77c5702 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::live::Hooks; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; -use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params}; +use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; /// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is /// neither a path nor a container)? @@ -47,16 +47,28 @@ pub fn has_gradient_copies(n: &Node) -> bool { } /// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or -/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as -/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of -/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]). +/// Scale Strokes & Effects is on (#885), its own or one of a fill or stroke that paints +/// ([`item_transform_options`])? Such an effect changes the fills and strokes as well as the +/// geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of its +/// own with its strokes scaled and its patterns moved ([`transform_leaf`], [`transform_items`]). pub fn has_transform_options(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) - && geometry_effects(n) + && (geometry_effects(n) .iter() // Both options default to off, so the stored params tell without merging the defaults // (this runs for every object drawn). .any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance()) + || n.appearance.items.iter().any(item_transform_options)) +} + +/// Does `item` paint and carry a Transform effect of its own that [`reaches_appearance`]? +fn item_transform_options(item: &AppearanceItem) -> bool { + crate::paints(item) && item.effects().iter().any(reaches_appearance) +} + +/// Is `e` a visible Transform effect whose Transform Patterns or Scale Strokes & Effects is on? +fn reaches_appearance(e: &Effect) -> bool { + e.visible && e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance() } /// Is `n` a path or compound path that its Transform effects make into art of separate objects @@ -232,14 +244,27 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts -/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them). +/// `None` for other kinds (images, paths, containers), and for an instance whose art placed +/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of +/// `doc`'s symbols that its instances show toward its instance budget. pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { + outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default()) +} + +/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]). +fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, - NodeKind::SymbolInstance { xf, .. } => { - let mut a = symbol_art?.clone(); - a.transform(*xf, false); + NodeKind::SymbolInstance { symbol, xf } => { + let a = match doc { + Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?, + None => { + let mut a = symbol_art?.clone(); + a.transform(*xf, false); + a + } + }; Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. @@ -252,7 +277,7 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) one.appearance.effects.clear(); Node::group(n.id, vec![Arc::new(one)]) } - _ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n), + _ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes), _ => return None, }; outline_nested_text(&mut art, false); @@ -270,8 +295,93 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) /// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show /// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]). pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node { - let hook: &dyn Fn(&Node) -> Option = &outline_text; - vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() }) + evaluated_with(doc, n, &outline_text, &SymbolNodes::default()) +} + +/// Most objects of symbol art that one compound shape or live Pathfinder operation gives its +/// members in all ([`SymbolNodes`]): four for each segment the operation takes in +/// ([`crate::group::PATHFINDER_MAX_SEGMENTS`]). +pub const SYMBOL_ART_MAX_NODES: u64 = 4 * crate::group::PATHFINDER_MAX_SEGMENTS as u64; + +/// The objects of symbol art given to one compound shape or live Pathfinder operation so far, +/// counted against [`SYMBOL_ART_MAX_NODES`]. Instances of symbols that hold several instances of +/// others multiply at each level, which the depth limit does not stop. +#[derive(Debug, Default)] +pub(crate) struct SymbolNodes(std::cell::Cell); + +impl SymbolNodes { + /// Count `art`'s objects: `false` once they would pass [`SYMBOL_ART_MAX_NODES`], and from then + /// on for all art. + pub(crate) fn charge(&self, art: &Node) -> bool { + let total = self.0.get().saturating_add(art.count() as u64); + let fits = total <= SYMBOL_ART_MAX_NODES; + self.0.set(if fits { total } else { u64::MAX }); + fits + } + + /// Has some symbol art been left out for passing [`SYMBOL_ART_MAX_NODES`]? + pub(crate) fn spent(&self) -> bool { + self.0.get() > SYMBOL_ART_MAX_NODES + } +} + +/// [`evaluated`] with type and symbol instances inside live objects given as their art by +/// `outline`, and every symbol instance left in the result (a blend's steps, an instance in a +/// plain group) replaced by its art from `outline`, with the instance's name, visibility and +/// transparency. The live objects and symbol instances inside that art are given as their art in +/// turn, except an instance of a symbol that holds an instance of itself, of a symbol nested +/// more than [`vectorcraft_doc::depth::MAX_DEPTH`] deep, or whose art would take `nodes` past +/// [`SYMBOL_ART_MAX_NODES`], which stays as it is. With [`outline_text`], symbol instances stay as +/// they are. +pub(crate) fn evaluated_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node { + resolved(doc, n, outline, nodes, &[]) +} + +/// `art`, the art `outline` gave an instance of the symbol named `symbol`, with the live objects +/// and symbol instances inside it given as their art as [`evaluated_with`] does. +pub(crate) fn symbol_art_with(art: &Node, outline: OutlineHook, nodes: &SymbolNodes, symbol: &str) -> Node { + resolved(None, art, outline, nodes, &[symbol.to_string()]) +} + +/// [`evaluated_with`] inside the art of the symbols named in `shown`, outermost first. +fn resolved(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Node { + let inner = |m: &Node| art_of(doc, m, outline, nodes, shown); + let mut art = vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(&inner), symbols: doc, ..Default::default() }); + instances_as_art(doc, &mut art, outline, nodes, shown); + art +} + +/// `n`'s art from `outline`, inside the art of the symbols named in `shown`. For a symbol instance +/// it is the symbol's art with the live objects and instances inside it given as their art in turn +/// ([`resolved`]); `None` for an instance of a symbol in `shown` (one that holds an instance of +/// itself), for every instance once `shown` holds [`vectorcraft_doc::depth::MAX_DEPTH`] symbols, +/// and for an instance whose symbol's art `nodes` cannot take ([`SymbolNodes::charge`]). +fn art_of(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Option { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return outline(n) }; + if shown.len() >= vectorcraft_doc::depth::MAX_DEPTH || shown.contains(symbol) { + return None; + } + let art = outline(n).filter(|art| nodes.charge(art))?; + let mut inside = shown.to_vec(); + inside.push(symbol.clone()); + Some(resolved(doc, &art, outline, nodes, &inside)) +} + +/// Replace every symbol instance under `n` for which [`art_of`] gives art by that art. `shown` +/// names the symbols whose art `n` is in, outermost first. +fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut() { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) { + if let Some(mut o) = art_of(doc, c, outline, nodes, shown) { + o.name = c.name.clone(); + (o.visible, o.opacity, o.blend, o.isolate) = (c.visible, c.opacity, c.blend, c.isolate); + *c = Arc::new(o); + } + } else if c.children().is_some() { + instances_as_art(doc, Arc::make_mut(c), outline, nodes, shown); + } + } } /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look @@ -304,9 +414,11 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, -/// or has no art (a symbol instance without `symbol_art`). +/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is +/// always reshaped. The fills and strokes in the art whose own Transform effects reach their +/// appearance become objects of their own ([`transform_items`]). pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) { + if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) { return None; } as_art(doc, n, symbol_art) @@ -334,6 +446,11 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod /// [`reshape`] without its checks (no geometry effects: just the art). pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { + as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default()) +} + +/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). +pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -361,9 +478,10 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node> Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect()) } } - _ => outline_art(doc, n, symbol_art)?, + _ => outline_art_with(doc, n, symbol_art, outline, nodes)?, }; reshape_leaves(&mut art, &fx); + transform_items(&mut art); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { project_leaves(&mut art, &h); @@ -577,6 +695,158 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling made } +/// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose +/// own Transform effect has Transform Patterns or Scale Strokes & Effects on +/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]). +/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is +/// more). +fn transform_items(art: &mut Node) { + let mut used = art_cost(art); + let max = Budget::EFFECTS.at_least(used); + for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used)); +} + +/// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform +/// Patterns or Scale Strokes & Effects on, as a group with its id, name, transparency and object +/// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after +/// the geometry effects of that fill or stroke before its first such Transform effect, transformed +/// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the +/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the +/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer +/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then +/// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other +/// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's +/// geometry between them, with their own effects. `max` is the budget of the whole art and `used` +/// what it has spent. +fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { + if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) { + return; + } + let Some((path, rule)) = leaf_geometry(leaf) else { return }; + let original = leaf.clone(); + let ctx = GeomContext::of(&original); + let bounds = path.to_bezpath().bounding_box(); + // A path of the leaf's geometry painted by `items` alone, without the leaf's transparency and + // object effects (the group keeps them). + let piece = |items: Vec| { + let mut p = original.clone(); + p.appearance.items = items; + p.appearance.effects.clear(); + p.appearance.contents_index = None; + p.opacity = 1.0; + p.blend = Default::default(); + p.isolate = false; + p.knockout = Default::default(); + p.extras_mut().mask = None; + p + }; + // The pieces replace the leaf's own geometry (counted in `used`): each plain piece costs that + // geometry again, and each transformed fill or stroke what its paths cost. + let cost = Budget::of(&path); + *used = used.minus(cost); + let mut pieces: Vec> = vec![]; + let mut plain: Vec = vec![]; + // Which items become a transformed piece of their own; every other run of items is one plain piece. + let transformed: Vec = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect(); + // The number of pieces after item `i` in paint order: each costs at least the leaf's geometry. + let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count(); + for (i, item) in original.appearance.items.iter().enumerate() { + let fx = item.effects(); + let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else { + plain.push(item.clone()); + continue; + }; + if !plain.is_empty() { + pieces.push(Arc::new(piece(std::mem::take(&mut plain)))); + *used = used.plus(cost); + } + let before = &fx[..at]; + // What the art's other paths leave of the budget, less the leaf's geometry for each piece + // still to come: the geometry before the Transform effect and its copies keep to it. + let reserved = (0..later(i)).fold(Budget { anchors: 0, lines: 0 }, |r, _| r.plus(cost)); + let limit = max.minus(reserved); + let room = limit.minus(*used); + let g = if crate::has_geometry(before) && !path.is_empty() { + crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room) + } else { + path.clone() + }; + let Some(tb) = g.bounds() else { continue }; + let params = merged_params(&fx[at].id, &fx[at].params); + let (_, copies) = crate::distort::transform_map(tb, ¶ms, ctx.seed); + let options = crate::distort::TransformOptions::of(¶ms); + let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); + let mut it = item.clone(); + let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest; + let mut p = piece(vec![it]); + set_geometry(&mut p, g.clone(), rule); + let made = transform_leaf(&mut p, &g, map, copies, options, room); + *used = used.plus(made); + transform_piece(&mut p, ctx.seed, limit, used); + pieces.push(Arc::new(p)); + } + if !plain.is_empty() { + pieces.push(Arc::new(piece(plain))); + *used = used.plus(cost); + } + leaf.appearance.items.clear(); + leaf.appearance.contents_index = None; + leaf.knockout = vectorcraft_doc::Knockout::Off; + leaf.kind = NodeKind::Group { children: pieces, clip: false }; +} + +/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a +/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: +/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's +/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of +/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with +/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. +/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep +/// to `max` together with the rest of the art (`used`). +fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { + let mut count = 0; + for_each_leaf(p, &mut |l| { + if let Some(item) = l.appearance.items.first() { + count = count.max(item.effects().len()); + } + }); + for j in 0..count { + let Some(bounds) = p.geometric_bounds() else { break }; + for_each_leaf(p, &mut |leaf| { + let Some(item) = leaf.appearance.items.first().cloned() else { return }; + let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; + let Some((path, rule)) = leaf_geometry(leaf) else { return }; + // What the other paths leave of the budget. + let before = Budget::of(&path); + let room = max.minus(used.minus(before)); + if reaches_appearance(&e) { + let params = merged_params(&e.id, &e.params); + let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); + let options = crate::distort::TransformOptions::of(¶ms); + let map = crate::distort::transform_affine(bounds, ¶ms, seed); + let made = transform_leaf(leaf, &path, map, copies, options, room); + *used = used.minus(before).plus(made); + return; + } + let appearance = leaf.appearance.clone(); + let stroke = match &item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) }; + let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room); + *used = used.minus(before).plus(Budget::of(&out)); + set_geometry(leaf, out, rule); + }); + } + for_each_leaf(p, &mut |leaf| { + for item in &mut leaf.appearance.items { + item.effects_mut().retain(|e| !(e.visible && is_geometry(&e.id))); + } + }); +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { @@ -722,7 +992,13 @@ fn placed_count(n: &Node) -> usize { /// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on /// it reach every member. pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { - let mut art = evaluated(doc, n); + outlined_members_with(doc, n, &outline_text, &SymbolNodes::default()) +} + +/// [`outlined_members`] with live objects and symbol instances given as art through `outline` +/// ([`evaluated_with`]). +pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node { + let mut art = evaluated_with(doc, n, outline, nodes); outline_nested_text(&mut art, false); art } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index a68b7f579..1165340bc 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -114,10 +114,34 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { NodeKind::Compound { children, rule } => { Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) } + // Type extrudes as its glyph outlines and stays type: editing it reshapes the solid (#4715). + NodeKind::Text(_) => { + let mut subpaths = vec![]; + crate::outline_text(n)?.walk(&mut |c| subpaths.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().cloned()))); + (!subpaths.is_empty()).then(|| (PathData::new(subpaths), FillRule::NonZero)) + } _ => None, } } +/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the +/// fill and stroke of its first painted run (its characters' colours). +fn solid_paints(n: &Node) -> Vec { + let own: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); + if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) { + return own; + } + let mut runs = vec![]; + if let Some(outlined) = crate::outline_text(n) { + outlined.walk(&mut |c| { + if runs.is_empty() && c.path_data().is_some() { + runs = c.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); + } + }); + } + runs +} + fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { let view = view_options(p); match id { @@ -320,7 +344,8 @@ pub fn solid_art(n: &Node) -> Option { } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); - let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect(); + let paints = solid_paints(n); + let painted: Vec<&AppearanceItem> = paints.iter().collect(); let mut children = vec![]; let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 5c47c0743..689c88dae 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -57,6 +57,10 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option { pat.extend_from_within(..); } let period: f64 = pat.iter().sum(); + // Finite entries can still sum to infinity, which would never leave the phase loop below. + if !period.is_finite() { + return None; + } let plans: Vec = subpaths(bp) .into_iter() .map(|(r, closed)| { diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 067064950..1da14b334 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -21,6 +21,11 @@ fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { st } +#[test] +fn dash_period_overflowing_to_infinity_is_solid() { + assert!(dash(&line(0.0, 60.0), &dashed(&[f64::MAX, f64::MAX], -1.0)).is_none()); +} + fn dashed(pattern: &[f64], offset: f64) -> Dash { Dash { pattern: pattern.to_vec(), offset, align_corners: false } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index fba045763..ea3843f0e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -133,6 +133,15 @@ fn geometry_effects_are_deterministic() { } } +/// The effects that symbol art across 9-slice guides draws once around all its cells are raster +/// effects that do not reshape it. +#[test] +fn nine_slice_painted_effects_are_raster_effects() { + for id in vectorcraft_doc::PAINTED_EFFECTS { + assert!(is_raster(id) && !is_geometry(id), "{id}"); + } +} + #[test] fn open_paths_stay_open() { let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false)); @@ -933,6 +942,18 @@ fn round_corners_keeps_where_a_closed_path_starts() { assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9)); } +#[test] +fn twist_turns_clockwise_for_positive_angles() { + // A positive angle turns clockwise, a negative one counterclockwise + // (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). + // The document's y runs down, so clockwise on the page takes a point right of the center down. + let right = PathData::single(SubPath::polyline(&[Point::new(55.0, 50.0), Point::new(56.0, 50.0)], false)); + let bounds = Rect::new(0.0, 0.0, 100.0, 100.0); + let turned = |angle: f64| apply_geometry(&[fx("distort.twist", json!({"angle": angle}))], &right, bounds).subpaths[0].anchors[0].p; + assert!(turned(45.0).y > 50.5, "{:?}", turned(45.0)); + assert!(turned(-45.0).y < 49.5, "{:?}", turned(-45.0)); +} + #[test] fn twist_fixes_the_outer_circle_and_rotates_inside() { let out = run("distort.twist", json!({"angle": 0}), &square()); @@ -1191,10 +1212,10 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { let instance = |name: &str| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })); let mut d = Document::new(100.0, 100.0); // A 500 × 500 grid as a symbol's art, directly and inside a group; a symbol holding itself. - d.symbols.push(Symbol { name: "Grid".into(), art: repeat(grid(unit(), 500)) }); - d.symbols.push(Symbol { name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); - d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); - d.symbols.push(Symbol { name: "Small".into(), art: repeat(grid(unit(), 3)) }); + d.symbols.push(Symbol { dynamic: false, name: "Grid".into(), art: repeat(grid(unit(), 500)) }); + d.symbols.push(Symbol { dynamic: false, name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); + d.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); + d.symbols.push(Symbol { dynamic: false, name: "Small".into(), art: repeat(grid(unit(), 3)) }); // A 3 × 3 grid of an instance of each: each copy draws 250,000 instances, so one copy fits. for name in ["Grid", "Grouped"] { let outer = Node::new(NodeId(0), NodeKind::Repeat(grid(instance(name), 3))); diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index a5fdd674c..bdeb9d8cf 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -3,11 +3,11 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Dash, Effect, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Effect, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; use crate::distort::{TransformOptions, transform_map, transform_move}; -use crate::{has_transform_art, has_transform_options, reshape}; +use crate::{bake_document, expand_outlined, has_transform_art, has_transform_options, reshape}; fn transform(params: Value) -> Effect { Effect { id: "distort.transform".into(), params, visible: true } @@ -157,3 +157,230 @@ fn stroke_scaling_across_copies_stays_finite() { } } } + +/// A 100 pt square at the origin with a solid gray fill and a 2 pt black stroke (dashed 4/2) that +/// carries the Transform effect `params` itself. +fn stroke_with_effect(params: Value) -> Node { + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let mut n = + Node::path(NodeId(10), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gray, Paint::solid(Color::BLACK), 2.0)); + if let Some(s) = n.appearance.stroke_mut() { + s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }); + s.effects.push(transform(params)); + } + n +} + +/// A 100 pt square at the origin filled with pattern "p" placed at the identity, the fill carrying +/// the Transform effect `params` itself. +fn fill_with_effect(params: Value) -> Node { + let mut n = patterned(json!({})); + n.appearance.effects.clear(); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.effects.push(transform(params)); + } + n +} + +/// The paths under `n` that a stroke paints. +fn stroked_leaves(n: &Node) -> Vec<&Node> { + leaves(n).into_iter().filter(|p| p.appearance.stroke().is_some()).collect() +} + +/// The paths under `n` that a pattern fills. +fn pattern_leaves(n: &Node) -> Vec<&Node> { + leaves(n).into_iter().filter(|p| matches!(p.appearance.fill_paint(), Paint::Pattern { .. })).collect() +} + +#[test] +fn a_strokes_own_transform_scales_its_weight_and_dashes_with_scale_strokes_and_effects() { + let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + assert!(has_transform_options(&n)); + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 1); + let (w, dash) = stroke_of(paths[0]); + assert!(close(w, 4.0), "{w}"); + assert_eq!(dash, vec![8.0, 4.0]); + assert!(close(paths[0].geometric_bounds().unwrap().width(), 200.0)); + // The fill stays on the path as it was, below the stroke. + let all = leaves(&art); + assert_eq!(all.len(), 2); + assert!(all[0].appearance.stroke().is_none()); + assert_eq!(all[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + // Off, the stroke keeps its weight and dashes: the effect stays geometry alone. + let plain = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "strokes": false})); + assert!(!has_transform_art(&plain)); +} + +#[test] +fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { + let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); + let art = reshape(None, &n, None).unwrap(); + let widths: Vec = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect(); + assert_eq!(widths, vec![2.0, 4.0, 8.0]); +} + +#[test] +fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { + let mut n = stroke_with_effect(json!({})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); + f.effects.push(Effect { id: "path.offsetPath".into(), params: json!({"offset": 5.0}), visible: true }); + } + let art = reshape(None, &n, None).unwrap(); + let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); + let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; + // The Offset Path, scaled from 5 to 15, applies to the transformed fill's path. + assert!(f.effects.is_empty(), "{:?}", f.effects); + let w = fill.geometric_bounds().unwrap().width(); + assert!(close(w, 330.0), "{w}"); +} + +#[test] +fn a_later_transform_on_a_stroke_turns_its_copies_about_their_combined_center() { + for later in [json!({"rotate": 90}), json!({"rotate": 90, "strokes": true})] { + let mut n = stroke_with_effect(json!({"moveH": 200, "copies": 1, "strokes": true})); + if let Some(s) = n.appearance.stroke_mut() { + s.effects.push(transform(later.clone())); + } + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 2, "{later}"); + // The copies span 0 to 300 by 0 to 100 before the turn; about the center (150, 50) both + // go to x 100 to 200, one above the other. + let boxes: Vec = paths.iter().map(|p| p.geometric_bounds().unwrap()).collect(); + for b in &boxes { + assert!(close(b.x0, 100.0) && close(b.x1, 200.0), "{later}: {boxes:?}"); + } + let top = boxes.iter().map(|b| b.y0).fold(f64::INFINITY, f64::min); + let bottom = boxes.iter().map(|b| b.y1).fold(f64::NEG_INFINITY, f64::max); + assert!(close(top, -100.0) && close(bottom, 200.0), "{later}: {boxes:?}"); + for p in &paths { + assert!(p.appearance.stroke().unwrap().effects.is_empty(), "{later}"); + } + } +} + +#[test] +fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() { + let n = fill_with_effect(json!({"moveH": 30, "copies": 2, "patterns": true})); + assert!(has_transform_options(&n)); + let art = reshape(None, &n, None).unwrap(); + let xs: Vec = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); + assert_eq!(xs, vec![0.0, 30.0, 60.0]); + // Off, the pattern stays put. + assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2})))); +} + +#[test] +fn a_strokes_own_transform_with_transform_objects_off_keeps_its_geometry() { + let n = stroke_with_effect(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true})); + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 1, "no copies"); + assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(close(stroke_of(paths[0]).0, 1.0)); + let n = fill_with_effect(json!({"rotate": 45, "objects": false, "patterns": true})); + let art = reshape(None, &n, None).unwrap(); + let paths = pattern_leaves(&art); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_ne!(pattern_xf(paths[0]), Affine::IDENTITY); +} + +#[test] +fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() { + let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); + let drawn = reshape(None, &n, None).unwrap(); + let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::>(); + assert_eq!(widths(&drawn), vec![2.0, 4.0]); + // Expand Appearance. + assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn)); + // Export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n.clone()).unwrap(); + let baked = bake_document(&d).unwrap(); + assert_eq!(widths(baked.node(NodeId(10)).unwrap()), widths(&drawn)); + // Use Preview Bounds: the copy's 4 pt stroke (on -50..150) reaches 2 pt past it. + let symbols = d.symbol_boxes(); + let b = crate::preview::visual_bounds(&symbols, &n).unwrap(); + assert!(close(b.width(), 204.0), "{b:?}"); + assert_eq!(Some(b), drawn.visual_bounds_with(&symbols)); +} + +/// Three strokes, each with three max-detail Roughens (#3578: nearly one effect budget on their +/// own) before its own Transform effect: their paths keep to one effect budget together, not one +/// each. +#[test] +fn strokes_with_their_own_transform_keep_to_one_effect_budget() { + use crate::geometry::Budget; + let rough = Effect { id: "distort.roughen".into(), params: json!({"size": 10000, "relative": false, "detail": 100}), visible: true }; + let mut n = Node::path( + NodeId(11), + shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), + Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0), + ); + let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else { + panic!("no stroke") + }; + n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_))); + for _ in 0..3 { + let mut s = s.clone(); + s.effects = vec![rough.clone(); 3]; + s.effects.push(transform(json!({"scaleH": 50, "scaleV": 50, "strokes": true}))); + n.appearance.items.push(AppearanceItem::Stroke(s)); + } + let art = reshape(None, &n, None).unwrap(); + let paths = stroked_leaves(&art); + assert_eq!(paths.len(), 3); + let cost = + paths.iter().filter_map(|p| crate::reshape::leaf_geometry(p)).fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g))); + assert!(Budget::EFFECTS.fits(cost) && cost.lines > crate::MAX_EFFECT_LINES / 2, "{cost:?}"); +} + +/// A path of 600,000 anchors with a fill below a stroke whose own Transform effect makes five +/// copies: the fill's path counts toward the effect budget, so the stroke's copies stop where the +/// whole art would pass it. +#[test] +fn a_fill_below_a_strokes_own_transform_counts_toward_the_effect_budget() { + use crate::geometry::Budget; + use vectorcraft_geom::{PathData, Point, SubPath}; + let points: Vec = (0..600_000).map(|i| Point::new(f64::from(i) * 1.0e-4, f64::from(i % 2))).collect(); + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let mut n = Node::path(NodeId(12), PathData::single(SubPath::polyline(&points, false)), Appearance::basic(gray, Paint::solid(Color::BLACK), 1.0)); + if let Some(s) = n.appearance.stroke_mut() { + s.effects.push(transform(json!({"moveH": 100, "copies": 5, "strokes": true}))); + } + let art = reshape(None, &n, None).unwrap(); + let cost = leaves(&art) + .iter() + .filter_map(|p| crate::reshape::leaf_geometry(p)) + .fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g))); + assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}"); + assert!(Budget::EFFECTS.fits(cost), "{cost:?}"); +} + +/// A path of 600,000 anchors with a stroke whose own Transform effect makes two copies, below a +/// fill: the stroke's copies leave room for the fill painted after them. +#[test] +fn a_strokes_own_transform_leaves_room_for_a_fill_above_it() { + use crate::geometry::Budget; + use vectorcraft_geom::{PathData, Point, SubPath}; + let points: Vec = (0..600_000).map(|i| Point::new(f64::from(i) * 1.0e-4, f64::from(i % 2))).collect(); + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let mut n = Node::path(NodeId(13), PathData::single(SubPath::polyline(&points, false)), Appearance::basic(gray, Paint::solid(Color::BLACK), 1.0)); + if let Some(s) = n.appearance.stroke_mut() { + s.effects.push(transform(json!({"moveH": 100, "copies": 2, "strokes": true}))); + } + n.appearance.items.reverse(); + assert!(matches!(n.appearance.items.first(), Some(AppearanceItem::Stroke(_)))); + let art = reshape(None, &n, None).unwrap(); + let cost = leaves(&art) + .iter() + .filter_map(|p| crate::reshape::leaf_geometry(p)) + .fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g))); + assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}"); + assert!(Budget::EFFECTS.fits(cost), "{cost:?}"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f495697ce..87e2d48be 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,9 +36,13 @@ vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } +hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } flate2 = { workspace = true } +chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" +# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). +cfb = "0.15" kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index dc047d9d6..1af3693ca 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -62,6 +62,7 @@ pub mod params; mod path; pub(crate) mod pathops; mod patterncmds; +pub mod patternlib; pub mod pdfcmds; pub mod perspgrid; pub mod place; @@ -84,9 +85,11 @@ mod style; pub mod stylelib; mod swatch; pub mod swatchlib; +pub(crate) mod symboledit; pub mod symbollib; pub(crate) mod tabs; mod textedit; +pub use textedit::effective_kerning; pub mod textstyles; pub(crate) mod textwrap; pub(crate) mod threads; @@ -182,20 +185,26 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } } -/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items, -/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask -/// editing isolate their art too, and do not count as isolation mode here while their own layer -/// is the isolated one. An object isolated inside them counts. -pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; - let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l); - if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) } +/// The isolated group, layer or object in isolation mode. Pattern editing mode, opacity mask +/// editing and symbol editing mode isolate their own layer, which does not count here; an object +/// isolated inside them counts. +pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option { + st.isolation.filter(|l| { + !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) + && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l) + && !st.doc.symbol_edit.as_ref().is_some_and(|e| e.layer == *l) + }) } -/// Objects are selected ([`has_selection`]) and the document is not in isolation mode -/// ([`outside_isolation`]). -pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> { - outside_isolation(s)?; - has_selection(s) +/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at +/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items. +pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + let Some(root) = ordinary_isolation(st) else { return has_selection(s) }; + if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) } +} +/// Whether `id` lies inside the isolated object `root` (the root itself does not). +pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool { + id != root && d.ancestry(id).is_some_and(|a| a.contains(&root)) } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) @@ -503,11 +512,44 @@ pub fn color_value(v: &Value) -> Option { } } +/// `v` as an object id: an error unless it is a non-negative integer. +fn object_id(v: &Value) -> Result { + v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}"))) +} + +/// The error for `id` given as step reference `r`, which only a batch resolves. +fn unresolved_reference(r: &str) -> EngineError { + EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")) +} + +/// The objects `p` names for a command whose `id` can only be an object id: its `ids`, else its +/// `id` as `ids` of one. A value that isn't an object id is an error, never the selection (#785). +/// `None` when `p` gives neither (or gives them as `null`). The ids are not looked up in the +/// document. +pub(crate) fn named_ids(p: &Value) -> Result>> { + match p.get("ids").filter(|v| !v.is_null()) { + Some(Value::Array(a)) => return a.iter().map(object_id).collect::>>().map(Some), + Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))), + None => {} + } + Ok(named_id(p)?.map(|id| vec![id])) +} + +/// The object `p`'s `id` names, for a command whose `id` can only be an object id: a value that +/// isn't an object id is an error, never the selection (#785). `None` when `p` leaves `id` out (or +/// gives it as `null`). The id is not looked up in the document. +pub(crate) fn named_id(p: &Value) -> Result> { + match p.get("id").filter(|v| !v.is_null()) { + None => Ok(None), + Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)), + Some(v) => object_id(v).map(Some), + } +} + /// Objects a command targets: explicit `ids` param or the selection. pub(crate) fn targets(s: &Session, p: &Value) -> Result> { // Objects given are used as given: a value that isn't an object id fails rather than the // command acting on the selection instead (#785). - let object_id = |v: &Value| v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}"))); match p.get("ids").filter(|v| !v.is_null()) { Some(Value::Array(a)) => return a.iter().map(object_id).collect(), Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))), @@ -517,9 +559,7 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result> { Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]), // A step reference a batch couldn't resolve (other strings are other ids, such as an // effect's). - Some(Value::String(r)) if r.starts_with('$') => { - Err(EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) - } + Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)), _ => Ok(s.doc()?.selection.objects.clone()), } } @@ -571,6 +611,26 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +/// [`unique_name`] for names given one after another (an import): `next` keeps, per base (in +/// any case), the number after the last one tried, so a run of one name doesn't count up from 2 +/// again for each copy. Names only become taken meanwhile, so the result is the same. +pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_string(); + } + let i = next.entry(base.to_lowercase()).or_insert(2); + loop { + let n = format!("{base} {i}"); + *i += 1; + if !taken(&n) { + return n; + } + } +} + +/// The most presets one presets file imports: each is looked up among the saved ones. +pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; + pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use vectorcraft_doc::range::parse_range; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index ca6625540..02f886442 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -1,18 +1,24 @@ //! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own //! with commands under `addon..*` (see `docs/mcp.md`). +mod arrange; mod cleanup; mod color; +mod document; mod pathtools; mod pen; +mod typetools; use super::CommandSpec; /// The commands of every built-in add-on. pub fn specs() -> Vec { - let mut v = cleanup::specs(); + let mut v = arrange::specs(); + v.extend(cleanup::specs()); v.extend(color::specs()); + v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); + v.extend(typetools::specs()); v } diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs new file mode 100644 index 000000000..735d72133 --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -0,0 +1,535 @@ +//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects +//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two; +//! Replace with Top Object puts a copy of the topmost selected object in place of each of the +//! others; and Set Size of Each gives every selected object an exact width and height. Places and +//! sizes are measured and set as `object.setBounds` does. Group Overlapping Objects groups the +//! objects whose visual bounds overlap, Randomize Opacity gives each selected object a random +//! opacity, and Sort Objects sorts the selected objects by one attribute and gives them another in +//! that order. Its submenu (Object › Plug-ins › Arrange Tools), its dialogs and Zoom to Selection +//! (a view command) are the UI's. + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::recolor::Rng; +use vectorcraft_doc::{Node, NodeId, OrientedBox}; +use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_render::effects; + +use super::super::edit::selected_roots; +use super::super::menucmds::whole_or; +use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; +use super::super::typecmd::refresh_bounds_of; +use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// The largest gap, width or height (pt) the commands take. +const LIMIT: f64 = 1e6; +/// The smallest width or height (pt) Set Size of Each takes. +const LEAST: f64 = 0.001; +/// The most columns Arrange in Grid takes. +const MAX_COLUMNS: u64 = 10_000; +/// The most objects Group Overlapping Objects and Sort Objects look at. +const MAX_OBJECTS: usize = 20_000; +/// The attributes Sort Objects sorts by and gives (`addon.arrange.sort`). +pub const SORT_KEYS: [&str; 7] = ["width", "height", "area", "x", "y", "opacity", "stacking"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.arrange.grid", + "Arrange in Grid", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}", + has_selection, + grid + ), + cmd!( + "addon.arrange.swap", + "Swap Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected", + exactly_two, + swap + ), + cmd!( + "addon.arrange.replace", + "Replace with Top Object", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}", + has_multi, + replace + ), + cmd!( + "addon.arrange.size", + "Set Size of Each", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}", + has_selection, + size + ), + cmd!( + "addon.arrange.groupOverlapping", + "Group Overlapping Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{} group the selected objects (the top-level ones, at most 20000) wherever their visual bounds overlap or touch, chains included (when A overlaps B and B overlaps C, all three go in one group), as one undo step. Only objects in the same layer or group are grouped together, objects that overlap nothing stay as they are, and each new group goes where its frontmost member was in the stacking order; the new groups and the objects left alone stay selected → {groups: [id…], grouped (how many objects went into groups)}", + has_multi, + group_overlapping + ), + cmd!( + "addon.arrange.randomizeOpacity", + "Randomize Opacity", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + has_selection, + randomize_opacity + ), + cmd!( + "addon.arrange.sort", + "Sort Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + has_multi, + sort + ), + ] +} + +/// Exactly two objects are selected (Swap Objects). +fn exactly_two(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let n = selected_roots(s).map_err(|e| e.to_string())?.len(); + if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) } +} + +/// Number param `key` (`default` when left out) within `lo..=hi`. +fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result { + let v = number_or(p, key, default, cmd)?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// Whole-number param `key` within `lo..=hi`, `None` when left out. +pub(super) fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?; + if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// A width or height param of Set Size of Each, `None` when left out. +fn size_param(p: &Value, key: &str, cmd: &str) -> Result> { + p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose() +} + +/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes` +/// and, when given, a width and height. +fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value { + let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes }); + if let Some((w, h)) = size { + q["width"] = json!(w); + q["height"] = json!(h); + } + q +} + +/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects +/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says. +/// When every transform is (within rounding) the identity, nothing changes. +fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> { + let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9); + if xfs.iter().all(|(_, xf)| still(xf)) { + return Ok(()); + } + let p = json!({ "strokes": strokes }); + let ids: Vec = xfs.iter().map(|(id, _)| *id).collect(); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &ids)?; + s.edit(label, |d, _| { + for (id, xf) in xfs { + if let Some(n) = d.node_mut(*id) { + transform_node(n, *xf, sc, true); + } + } + Ok(()) + }) +} + +/// The box of object `id` as `object.setBounds` measures it. +fn box_of(s: &Session, id: NodeId) -> Result { + s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into())) +} + +fn grid(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.grid"; + let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid"); + let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?; + let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?; + let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?; + let center = switch_or(p, "centerOnArtboard", false, C)?; + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect(); + let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) }; + let n = items.len(); + let cols = match layout { + "row" => n, + "column" => 1, + _ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)), + } + .clamp(1, n); + let mut widths = vec![0.0_f64; cols]; + let mut heights = vec![0.0_f64; n.div_ceil(cols)]; + for (i, (_, b)) in items.iter().enumerate() { + if let Some(w) = widths.get_mut(i % cols) { + *w = w.max(b.width()); + } + if let Some(h) = heights.get_mut(i / cols) { + *h = h.max(b.height()); + } + } + let total = |sizes: &[f64], gap: f64| sizes.iter().sum::() + gap * sizes.len().saturating_sub(1) as f64; + let origin = if center { + let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect); + Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0) + } else { + Point::new(all.x0, all.y0) + }; + // The middle of each column (row): the sizes and gaps before it, and half its own size. + let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec { + sizes + .iter() + .scan(from, |at, size| { + let mid = *at + size / 2.0; + *at += size + gap; + Some(mid) + }) + .collect() + }; + let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y)); + let xfs: Vec<(NodeId, Affine)> = items + .iter() + .enumerate() + .filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center())))) + .collect(); + transform_each(s, "Arrange in Grid", false, &xfs)?; + Ok(json!({ "count": n })) +} + +fn swap(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.swap"; + let size = switch_or(p, "size", false, C)?; + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let roots = selected_roots(s)?; + let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) }; + let (ba, bb) = (box_of(s, a)?, box_of(s, b)?); + let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes); + let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)]; + transform_each(s, "Swap Objects", strokes, &xfs)?; + Ok(json!({})) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.replace"; + let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep"); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let keep = switch_or(p, "keep", false, C)?; + let roots = selected_roots(s)?; + let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else { + return Err(bad(C, "select at least two objects")); + }; + let tb = box_of(s, top)?; + let (tw, th) = (tb.rect.width(), tb.rect.height()); + let mut xfs = Vec::with_capacity(others.len()); + for &id in others { + let Some(b) = s.transform_box(&[id]) else { continue }; + let (w, h) = (b.rect.width(), b.rect.height()); + let size = match fit { + "bounds" => Some((w, h)), + // The smaller of the two scales that fit; a side the copy doesn't have doesn't count. + "proportional" => { + [(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k)) + } + _ => None, + }; + xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?)); + } + let p = json!({ "strokes": strokes }); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &[top])?; + let ids = s.edit("Replace with Top Object", |d, sel| { + let Some(n) = d.node(top).cloned() else { return Ok(vec![]) }; + let mut items = Vec::with_capacity(xfs.len()); + for (id, xf) in &xfs { + let mut c = d.reid(&n); + transform_node(&mut c, *xf, sc, true); + items.push((*id, vec![c])); + } + let out: Vec = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect(); + d.insert_beside(items, !keep)?; + refresh_bounds_of(d, &out); + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn size(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.size"; + let width = size_param(p, "width", C)?; + let height = size_param(p, "height", C)?; + if width.is_none() && height.is_none() { + return Err(bad(C, "give `width` or `height`")); + } + let proportional = switch_or(p, "proportional", false, C)?; + let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) }); + if let Some(w) = width { + q["width"] = json!(w); + } + if let Some(h) = height { + q["height"] = json!(h); + } + let mut xfs = vec![]; + for id in selected_roots(s)? { + if s.transform_box(&[id]).is_some() { + xfs.push((id, object_bounds_transform(s, &q, id)?)); + } + } + transform_each(s, "Set Size of Each", strokes, &xfs)?; + Ok(json!({ "count": xfs.len() })) +} + +/// The sets of two or more of `items` (each in the order given) whose rects overlap or touch, +/// chains included, among items of the same key (parent). A sweep along x: only the rects still +/// open where each one starts are compared with it. +fn overlapping(items: &[(K, Rect)]) -> Vec> { + fn root(up: &mut [usize], mut i: usize) -> usize { + while up[i] != i { + up[i] = up[up[i]]; + i = up[i]; + } + i + } + let mut up: Vec = (0..items.len()).collect(); + let mut order: Vec = (0..items.len()).collect(); + order.sort_by(|a, b| items[*a].1.x0.total_cmp(&items[*b].1.x0)); + let mut open: Vec = vec![]; + for i in order { + let (key, r) = (&items[i].0, items[i].1); + open.retain(|&j| items[j].1.x1 >= r.x0); + for &j in &open { + let (k, o) = (&items[j].0, items[j].1); + if k == key && o.y0 <= r.y1 && r.y0 <= o.y1 { + let (a, b) = (root(&mut up, i), root(&mut up, j)); + up[a] = b; + } + } + open.push(i); + } + let mut sets: Vec> = vec![vec![]; items.len()]; + for i in 0..items.len() { + let r = root(&mut up, i); + sets[r].push(i); + } + sets.retain(|s| s.len() > 1); + sets.sort_by_key(|s| s.first().copied()); + sets +} + +fn group_overlapping(s: &mut Session, _: &Value) -> Result { + const C: &str = "addon.arrange.groupOverlapping"; + let roots = selected_roots(s)?; + if roots.len() > MAX_OBJECTS { + return Err(bad(C, "too many objects selected (at most 20000)")); + } + let d = &s.doc()?.doc; + let items: Vec<(NodeId, (Option, Rect))> = + roots.iter().filter_map(|id| Some((*id, (d.parent_of(*id), effects::preview::bounds_of(d, &[*id], true)?)))).collect(); + let keyed: Vec<(Option, Rect)> = items.iter().map(|(_, k)| *k).collect(); + let sets: Vec> = overlapping(&keyed).into_iter().map(|set| set.iter().filter_map(|i| Some(items.get(*i)?.0)).collect()).collect(); + if sets.is_empty() { + return Ok(json!({ "groups": [], "grouped": 0 })); + } + let grouped: std::collections::HashSet = sets.iter().flatten().copied().collect(); + let groups = s.edit("Group Overlapping Objects", |d, sel| { + // Every group at once: the members behind each front one go, then each front one is + // replaced by its group (one pass over the tree each, however many groups). + let (mut places, mut behind, mut groups) = (vec![], vec![], vec![]); + for set in &sets { + let Some((&front, rest)) = set.split_last() else { continue }; + let children: Vec> = set.iter().filter_map(|id| d.node(*id).cloned().map(Arc::new)).collect(); + let gid = d.alloc_id(); + places.push((front, vec![Node::group(gid, children)])); + behind.extend_from_slice(rest); + groups.push(gid); + } + d.remove_many(&behind)?; + d.insert_beside(places, true)?; + sel.set(groups.iter().copied().chain(roots.iter().copied().filter(|id| !grouped.contains(id)))); + Ok(groups) + })?; + Ok(json!({ "groups": groups.iter().map(|i| i.0).collect::>(), "grouped": grouped.len() })) +} + +fn randomize_opacity(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.randomizeOpacity"; + let min = number_in(p, "min", 30.0, (0.0, 100.0), C)?; + let max = number_in(p, "max", 100.0, (0.0, 100.0), C)?; + if min > max { + return Err(bad(C, "`min` is greater than `max`")); + } + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let ids = checked_ids_or_selection(s, p, C)?; + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } + let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); + s.edit("Randomize Opacity", |d, _| { + for id in &ids { + let o = rng.range(lo, hi); + if let Some(n) = d.node_mut(*id) { + n.opacity = o; + } + } + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +/// An object Sort Objects sorts: its layer or group, its place in it, its box as +/// `object.setBounds` measures it and its opacity (%). +struct Sorted { + id: NodeId, + parent: Option, + slot: usize, + b: OrientedBox, + opacity: f64, +} + +/// Attribute `key` (one of [`SORT_KEYS`]) of `o`, the `i`th of the objects in stacking order. +fn attribute(key: &str, o: &Sorted, i: usize) -> f64 { + let r = o.b.rect; + // The top-left corner where `object.setBounds` places it (reference point 0). + let corner = o.b.to_doc() * Point::new(r.x0, r.y0); + match key { + "width" => r.width(), + "height" => r.height(), + "area" => r.width() * r.height(), + "x" => corner.x, + "y" => corner.y, + "opacity" => o.opacity, + _ => i as f64, + } +} + +fn sort(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.sort"; + let by = choice(p, C, "by", &SORT_KEYS)?.unwrap_or("area"); + let apply = choice(p, C, "apply", &SORT_KEYS)?.unwrap_or("stacking"); + let order = choice(p, C, "order", &["ascending", "descending", "random"])?.unwrap_or("ascending"); + let distribute = switch_or(p, "distribute", false, C)?; + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let ids = checked_ids_or_selection(s, p, C)?; + if ids.len() > MAX_OBJECTS { + return Err(bad(C, "too many objects selected (at most 20000)")); + } + let d = &s.doc()?.doc; + // The objects in stacking order, from the back (their index paths, found in one walk). + let paths = d.index_paths(ids.iter().copied()); + let mut placed: Vec<(&Vec, NodeId)> = ids.iter().filter_map(|id| Some((paths.get(id)?, *id))).collect(); + placed.sort_by(|a, b| a.0.cmp(b.0)); + let items: Vec = placed + .into_iter() + .filter_map(|(path, id)| { + let (&slot, up) = path.split_last()?; + let parent = if up.is_empty() { None } else { Some(d.node_at(up)?.id) }; + Some(Sorted { id, parent, slot, b: s.transform_box(&[id])?, opacity: f64::from(d.node(id)?.opacity) * 100.0 }) + }) + .collect(); + let keys: Vec = items.iter().enumerate().map(|(i, o)| attribute(by, o, i)).collect(); + // The objects in sorted order; equal keys keep their stacking order. + let mut rank: Vec = (0..items.len()).collect(); + match order { + "random" => rng.shuffle(&mut rank), + "descending" => rank.sort_by(|a, b| keys[*b].total_cmp(&keys[*a])), + _ => rank.sort_by(|a, b| keys[*a].total_cmp(&keys[*b])), + } + let count = json!({ "count": items.len() }); + if apply == "stacking" { + // Each layer or group's objects take its objects' places, from the back, in sorted order. + let mut groups: HashMap, Vec> = HashMap::new(); + for o in rank.iter().filter_map(|i| items.get(*i)) { + groups.entry(o.parent).or_default().push(o.slot); + } + if groups.values().all(|slots| slots.is_sorted()) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (parent, from) in groups { + let ch = d.children_mut(parent)?; + let nodes: Vec> = from.iter().filter_map(|i| ch.get(*i).cloned()).collect(); + let mut to = from; + to.sort_unstable(); + for (slot, n) in to.into_iter().zip(nodes) { + if let Some(c) = ch.get_mut(slot) { + *c = n; + } + } + } + Ok(()) + })?; + return Ok(count); + } + // The values the objects get, smallest first: theirs, or evenly from the smallest to the largest. + let mut values: Vec = items.iter().enumerate().map(|(i, o)| attribute(apply, o, i)).collect(); + values.sort_by(f64::total_cmp); + if let (true, Some(&lo), Some(&hi)) = (distribute && values.len() > 1, values.first(), values.last()) { + let steps = (values.len() - 1) as f64; + values = (0..values.len()).map(|j| lo + (hi - lo) * j as f64 / steps).collect(); + } + let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); + if apply == "opacity" { + if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (o, v) in &wanted { + if let Some(n) = d.node_mut(o.id) { + n.opacity = (v / 100.0) as f32; + } + } + Ok(()) + })?; + return Ok(count); + } + let mut xfs = Vec::with_capacity(wanted.len()); + for (o, v) in wanted { + let q = match apply { + "x" => json!({ "x": v, "reference": 0 }), + "y" => json!({ "y": v, "reference": 0 }), + "width" => json!({ "width": v, "strokes": strokes }), + "height" => json!({ "height": v, "strokes": strokes }), + // An area: both sides in proportion; an object without one keeps its size. + _ => { + let (w, h) = (o.b.rect.width(), o.b.rect.height()); + let k = if w * h > 1e-12 { (v / (w * h)).sqrt() } else { 1.0 }; + json!({ "width": w * k, "height": h * k, "strokes": strokes }) + } + }; + xfs.push((o.id, object_bounds_transform(s, &q, o.id)?)); + } + transform_each(s, "Sort Objects", strokes, &xfs)?; + Ok(count) +} diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs new file mode 100644 index 000000000..85191935b --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -0,0 +1,564 @@ +//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off, +//! and bad params refused without a change. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Rect; + +use crate::Session; + +/// A new document whose new art has no stroke, so visual and geometric bounds agree. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool { + [(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Each of `bad` is refused and leaves the document as it was. +fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) { + let (before, n) = (s.doc().unwrap().doc.clone(), steps(s)); + for p in bad { + assert!(s.execute(cmd, p).is_err(), "{cmd} {p}"); + } + assert_eq!(steps(s), n, "{cmd}"); + assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document"); +} + +fn enabled(s: &Session, cmd: &str) -> bool { + s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled +} + +#[test] +fn grid_lays_objects_out_in_rows_and_columns_from_the_back() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0); + let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0); + let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0); + let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0); + select(&mut s, &[d, c, b, a]); + let n = steps(&s); + // Four objects: two columns. The grid starts at the selection's top-left (10, 5). + let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap(); + assert_eq!(r["count"], json!(4)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred. + assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0))); +} + +#[test] +fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap(); + assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10"); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap(); + assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX"); + // 20 × 44 centred on the 400 × 300 artboard. + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a)); + // In place already: no step. + let n = steps(&s); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert_eq!(steps(&s), n); + // More columns than objects: one row. + s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap(); + assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn grid_measures_visual_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap(); + // Visual boxes 24 wide, touching. + assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_trades_places_and_optionally_sizes() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.swap")); + select(&mut s, &[a, b, c]); + assert!(!enabled(&s, "addon.arrange.swap")); + refuses(&mut s, "addon.arrange.swap", &[json!({})]); + select(&mut s, &[a, b]); + assert!(enabled(&s, "addon.arrange.swap")); + let n = steps(&s); + s.execute("addon.arrange.swap", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + // Centres (10, 5) and (120, 120) swap; sizes stay. + assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_with_sizes_scales_strokes_as_asked() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap(); + assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b)); +} + +#[test] +fn replace_puts_a_copy_of_the_top_object_in_each_place() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[top]); + assert!(!enabled(&s, "addon.arrange.replace")); + refuses(&mut s, "addon.arrange.replace", &[json!({})]); + select(&mut s, &[a, b, top]); + let n = steps(&s); + let r = s.execute("addon.arrange.replace", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let st = s.doc().unwrap(); + assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted"); + assert!(st.doc.node(top).is_some(), "the top object stays"); + assert_eq!(st.selection.objects, ids, "the copies are selected"); + // Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle). + assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0])); + assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1])); + let layer = &s.doc().unwrap().doc.layers[0]; + let order: Vec = layer.children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [ids[0], ids[1], top]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_some()); +} + +#[test] +fn replace_fits_the_copies_and_can_keep_the_originals() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy)); + let order: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [a, copy, top], "kept: the copy goes right above it"); + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + // 10 × 5 into 20 × 40: twice as large, centred on (10, 20). + assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy)); +} + +#[test] +fn size_gives_each_object_a_width_and_height() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + let n = steps(&s); + s.execute("addon.arrange.size", &json!({"width": 30})).unwrap(); + assert_eq!(steps(&s), n + 1); + // About each centre; the height stays. + assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap(); + assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a)); +} + +#[test] +fn bad_params_are_refused() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + refuses( + &mut s, + "addon.arrange.grid", + &[ + json!({"layout": "spiral"}), + json!({"columns": 0}), + json!({"columns": 1.5}), + json!({"columns": 1e12}), + json!({"gapX": 1e308}), + json!({"gapY": "10"}), + json!({"centerOnArtboard": "yes"}), + ], + ); + refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]); + refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]); + refuses( + &mut s, + "addon.arrange.size", + &[ + json!({}), + json!({"width": 0}), + json!({"height": -5}), + json!({"width": 1e308}), + json!({"width": "10"}), + json!({"width": 10, "reference": 9}), + json!({"width": 10, "proportional": "yes"}), + ], + ); +} + +#[test] +fn off_without_a_selection() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 20.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + for c in [ + "addon.arrange.grid", + "addon.arrange.swap", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.groupOverlapping", + "addon.arrange.randomizeOpacity", + "addon.arrange.sort", + ] { + assert!(!enabled(&s, c), "{c}"); + } + assert!(!enabled(&Session::new(), "addon.arrange.swap")); +} + +fn ids_of(v: &Value) -> Vec { + v.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn children(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().map(|n| n.id).collect() +} + +#[test] +fn group_overlapping_groups_chains_and_leaves_loners() { + let mut s = session(); + // a–b–c a chain (c only touches b), d alone, e over f. + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let d = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 15.0, 15.0, 20.0, 20.0); + let e = rect(&mut s, 100.0, 100.0, 30.0, 30.0); + let c = rect(&mut s, 35.0, 0.0, 10.0, 20.0); + let f = rect(&mut s, 110.0, 110.0, 5.0, 5.0); + select(&mut s, &[a, b, c, d, e, f]); + let n = steps(&s); + let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert_eq!(r["grouped"], json!(5)); + let groups = ids_of(&r["groups"]); + assert_eq!(groups.len(), 2); + assert_eq!(children(&s, groups[0]), [a, b, c], "members keep their stacking order"); + assert_eq!(children(&s, groups[1]), [e, f]); + // Each group where its frontmost member was: a d [abc] [ef] → d, group(abc) above it, then (ef). + let layer: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [d, groups[0], groups[1]]); + let sel = &s.doc().unwrap().selection.objects; + assert!(sel.contains(&groups[0]) && sel.contains(&groups[1]) && sel.contains(&d) && sel.len() == 3, "{sel:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 6); +} + +#[test] +fn group_overlapping_keeps_layers_apart_and_measures_visual_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let gap = rect(&mut s, 22.0, 0.0, 20.0, 20.0); + s.execute("layer.new", &json!({})).unwrap(); + let other = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + select(&mut s, &[a, gap, other]); + // 2 pt apart: nothing overlaps within a layer, and a and `other` are in different layers. + let n = steps(&s); + let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap(); + assert_eq!((r["grouped"].clone(), steps(&s)), (json!(0), n), "no groups, no step"); + // A 4 pt stroke reaches 2 pt out: the two in the first layer now touch. + select(&mut s, &[a, gap]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + select(&mut s, &[a, gap, other]); + let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap(); + let groups = ids_of(&r["groups"]); + assert_eq!(groups.len(), 1); + assert_eq!(children(&s, groups[0]), [a, gap]); + let doc = &s.doc().unwrap().doc; + assert_ne!(doc.layer_of(other), doc.layer_of(a), "left in its own layer"); +} + +#[test] +fn group_overlapping_is_off_with_one_object_and_scales_to_many() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.groupOverlapping")); + // A long row of pairs: each pair overlaps, neighbouring pairs don't. + let rects: Vec<(usize, Rect)> = + (0..4000).map(|i| (0, Rect::new(i as f64 * 10.0, 0.0, i as f64 * 10.0 + if i % 2 == 0 { 12.0 } else { 3.0 }, 5.0))).collect(); + let sets = super::overlapping(&rects); + assert_eq!(sets.len(), 2000); + assert!(sets.iter().all(|s| s.len() == 2 && s[1] == s[0] + 1)); +} + +fn opacity(s: &Session, id: NodeId) -> f32 { + s.doc().unwrap().doc.node(id).unwrap().opacity +} + +#[test] +fn randomize_opacity_gives_each_object_its_own_opacity_within_the_range() { + let mut s = session(); + let ids: Vec = (0..20).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + let n = steps(&s); + let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap(); + assert_eq!(r["count"], json!(20)); + assert_eq!(steps(&s), n + 1, "one undo step"); + let first: Vec = ids.iter().map(|id| opacity(&s, *id)).collect(); + assert!(first.iter().all(|o| (0.3..=0.6).contains(o)), "{first:?}"); + assert!(first.windows(2).any(|w| w[0] != w[1]), "each its own: {first:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(ids.iter().all(|id| opacity(&s, *id) == 1.0), "undone"); + // The same seed repeats them; another seed doesn't. + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap(); + assert_eq!(ids.iter().map(|id| opacity(&s, *id)).collect::>(), first); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 8})).unwrap(); + assert_ne!(ids.iter().map(|id| opacity(&s, *id)).collect::>(), first); + // Defaults 30 to 100; min = max gives every object that opacity. + s.execute("addon.arrange.randomizeOpacity", &json!({})).unwrap(); + assert!(ids.iter().all(|id| (0.3..=1.0).contains(&opacity(&s, *id)))); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 45, "max": 45})).unwrap(); + assert!(ids.iter().all(|id| opacity(&s, *id) == 0.45)); +} + +#[test] +fn randomize_opacity_takes_ids_and_leaves_the_rest() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0); + select(&mut s, &[a, b]); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let c = rect(&mut s, 20.0, 0.0, 5.0, 5.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(!enabled(&s, "addon.arrange.randomizeOpacity")); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [c.0], "min": 10, "max": 10})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (1.0, 1.0, 0.1)); + // A group takes one opacity of its own; the objects inside keep theirs. + select(&mut s, &[g]); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 20, "max": 20})).unwrap(); + assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0)); +} + +#[test] +fn randomize_opacity_refuses_bad_params() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + select(&mut s, &[a]); + refuses( + &mut s, + "addon.arrange.randomizeOpacity", + &[ + json!({"min": -1}), + json!({"max": 101}), + json!({"min": 80, "max": 20}), + json!({"min": 1e308}), + json!({"max": "50"}), + json!({"seed": -1}), + json!({"seed": 1.5}), + json!({"seed": "x"}), + json!({"ids": [999_999]}), + json!({"ids": "x"}), + ], + ); + let e = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 80, "max": 20})).unwrap_err().to_string(); + assert!(e.contains("`min` is greater than `max`"), "{e}"); +} + +fn layer_order(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +/// Three squares: `a` 30 wide at x 0, `b` 10 wide at x 100, `c` 20 wide at x 200, all selected. +fn squares(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 30.0, 30.0); + let b = rect(s, 100.0, 0.0, 10.0, 10.0); + let c = rect(s, 200.0, 0.0, 20.0, 20.0); + select(s, &[a, b, c]); + (a, b, c) +} + +#[test] +fn sort_by_area_lines_objects_up_along_x() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + let n = steps(&s); + let r = s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x"})).unwrap(); + assert_eq!(r["count"], json!(3)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Smallest first: b, c, a take the left edges 0, 100, 200; y stays. + assert!(near(bounds(&s, b), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (100.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, a), (200.0, 0.0, 30.0, 30.0)), "{:?}", bounds(&s, a)); + // Large to small: a, c, b. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, c).x0, bounds(&s, b).x0), (0.0, 100.0, 200.0)); + // Already in order: no step. + let n = steps(&s); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn sort_distributes_values_evenly() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 10.0, 50.0, 10.0, 50.0); + let c = rect(&mut s, 200.0, 0.0, 10.0, 20.0); + select(&mut s, &[a, b, c]); + // Left edges 0, 10, 200 step evenly: 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "x", "distribute": true})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, b).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0)); + // Heights that step evenly with x (10 to 50), each about its centre. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "height", "distribute": true})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 60.0, 10.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (200.0, -15.0, 10.0, 50.0)), "{:?}", bounds(&s, c)); +} + +#[test] +fn sort_restacks_within_each_layer() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Largest at the back. + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "stacking", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + s.execute("addon.arrange.sort", &json!({})).unwrap(); + assert_eq!(layer_order(&s), [b, c, a], "by area, small to large, into the stacking order by default"); + // An object in another layer keeps its layer; the others trade places among themselves. + s.execute("layer.new", &json!({})).unwrap(); + let big = rect(&mut s, 0.0, 100.0, 50.0, 50.0); + select(&mut s, &[a, b, c, big]); + s.execute("addon.arrange.sort", &json!({"order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + let doc = &s.doc().unwrap().doc; + assert_ne!(doc.layer_of(big), doc.layer_of(a), "left in its own layer"); +} + +#[test] +fn sort_by_stacking_and_opacity() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Back to front a, b, c; reversed: c, b, a take the left edges 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "stacking", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, c).x0, bounds(&s, b).x0, bounds(&s, a).x0), (0.0, 100.0, 200.0)); + for (id, o) in [(a, 20), (b, 80), (c, 50)] { + s.execute("object.setProps", &json!({"ids": [id.0], "opacity": o})).unwrap(); + } + select(&mut s, &[a, b, c]); + let opacity = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().opacity; + // By x, large to small: a (x 200) gets the least opacity, then b, then c. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "opacity", "order": "descending"})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (0.2, 0.5, 0.8)); + // By opacity into the stacking order, large to small: the most opaque at the back. + s.execute("addon.arrange.sort", &json!({"by": "opacity", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [c, b, a]); +} + +#[test] +fn sort_by_area_into_areas_scales_in_proportion() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Areas 900, 100, 400 by x: a (x 0) 100, b 500, c 900. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "area", "distribute": true})).unwrap(); + let side = |s: &Session, id| bounds(s, id).width(); + assert!((side(&s, a) - 10.0).abs() < 1e-6 && (side(&s, b) - 500f64.sqrt()).abs() < 1e-6 && (side(&s, c) - 30.0).abs() < 1e-6); + assert!((bounds(&s, b).height() - 500f64.sqrt()).abs() < 1e-6, "in proportion"); +} + +#[test] +fn sort_in_random_order_repeats_with_a_seed() { + let mut s = session(); + let ids: Vec = (0..12).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + let first = layer_order(&s); + assert_ne!(first, ids, "shuffled"); + let mut sorted = first.clone(); + sorted.sort_by_key(|i| i.0); + assert_eq!(sorted, ids, "the same objects"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_order(&s), ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + assert_eq!(layer_order(&s), first, "the same seed, the same order"); +} + +#[test] +fn sort_takes_ids_and_refuses_bad_params() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.sort")); + s.execute("addon.arrange.sort", &json!({"ids": [b.0, a.0], "apply": "x"})).unwrap(); + assert_eq!((bounds(&s, b).x0, bounds(&s, a).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0), "c is left alone"); + select(&mut s, &[a, b, c]); + refuses( + &mut s, + "addon.arrange.sort", + &[ + json!({"by": "color"}), + json!({"apply": 3}), + json!({"order": "up"}), + json!({"distribute": "yes"}), + json!({"seed": -1}), + json!({"seed": 0.5}), + json!({"scaleStrokes": 1}), + json!({"ids": "x"}), + json!({"ids": [999_999]}), + ], + ); +} diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index 0b29b07e2..cba512cd1 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -1,10 +1,12 @@ //! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the //! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path -//! Pieces. Its panel and its submenu, Object › Plug-ins › Artwork Cleanup, are the UI's. +//! Pieces, and its text tools ([`text`]). Its panel and its submenu, Object › Plug-ins › Artwork +//! Cleanup, are the UI's. mod checks; #[cfg(test)] mod tests; +mod text; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -16,9 +18,8 @@ use super::super::edit::selected_roots; use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, switch_or, text_param}; use crate::{EngineError, Result, Session}; -/// Simplify Anchors' tolerance by default, and the drift (pt) each step of it allows. +/// Simplify Anchors' tolerance by default. const TOLERANCE: f64 = 20.0; -pub(super) const PT_PER_TOLERANCE: f64 = 0.02; /// Rejoin Path Pieces' gap by default, and the widest (pt). const GAP: f64 = 0.1; const MAX_GAP: f64 = 6.0; @@ -30,13 +31,13 @@ const FIX_ROUNDS: usize = 3; const MAX_LISTED: usize = 1000; pub fn specs() -> Vec { - vec![ + let mut v = vec![ cmd!( "addon.cleanup.simplify", "Simplify Anchors (Keep Shape)…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay → {ids, before, after} anchor counts", + "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts", has_selection, simplify ), @@ -45,7 +46,7 @@ pub fn specs() -> Vec { "Health Check", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", + "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one, or type on the same type within 1 pt), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", has_doc, check ), @@ -72,7 +73,7 @@ pub fn specs() -> Vec { "Deep Ungroup…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in → {ids (the objects left, selected), ungrouped (groups)}", + "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", has_selection, deep_ungroup ), @@ -85,10 +86,13 @@ pub fn specs() -> Vec { has_selection, rejoin ), - ] + ]; + v.extend(text::specs()); + v } -/// The paths (not guides) under `roots`, compound path members included. +/// The paths (not guides) under `roots`, compound path members included; locked and hidden +/// paths, and those in locked or hidden groups, stay out. fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { let mut out = vec![]; for n in roots.iter().filter_map(|id| d.node(*id)) { @@ -98,6 +102,8 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { } }); } + let editable = d.editable(); + out.retain(|id| editable(*id)); out } @@ -119,7 +125,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let changed: Vec<(NodeId, PathData)> = paths .iter() .filter_map(|(id, path)| { - let np = po::simplify_keep_shape(path, tolerance * PT_PER_TOLERANCE, protect); + let np = po::simplify_keep_shape(path, tolerance * po::PT_PER_TOLERANCE, protect); (np != **path).then_some((*id, np)) }) .collect(); @@ -253,9 +259,9 @@ fn ungroupable(n: &Node, clips: bool) -> bool { /// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts /// the groups. Members take on the opacity of the groups they were in, and a clipping path -/// becomes a plain path. +/// becomes a plain path. A locked or hidden group stays whole. fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { - if !ungroupable(&n, clips) { + if !ungroupable(&n, clips) || n.locked || !n.visible { out.push(n); return; } @@ -279,6 +285,11 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { let clips = switch_or(p, "includeClipGroups", false, C)?; let roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; + // Locked and hidden objects, and those in locked or hidden groups, stay as they are. + let roots: Vec = { + let editable = d.editable(); + roots.into_iter().filter(|id| editable(*id)).collect() + }; let (mut items, mut selected, mut count) = (vec![], vec![], 0); for id in &roots { match d.node(*id).filter(|n| ungroupable(n, clips)) { @@ -291,13 +302,18 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { None => selected.push(*id), } } - // With no group to take apart, the document and its undo history stay as they are. - if !items.is_empty() { - let ids = selected.clone(); - s.edit("Deep Ungroup", |d, sel| { + // With no group to take apart, the document, the selection and the undo history stay as they + // are, and the reply lists the selection. + if items.is_empty() { + selected = s.doc()?.selection.objects.to_vec(); + } else { + selected = s.edit("Deep Ungroup", move |d, sel| { d.insert_beside(items, true)?; - sel.set(ids); - Ok(()) + // Members that are locked or hidden themselves keep that, and are not selected. + let editable = d.editable(); + selected.retain(|id| editable(*id)); + sel.set(selected.iter().copied()); + Ok(selected) })?; } Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "ungrouped": count })) diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 46c81293d..4b03f0e43 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -120,12 +120,12 @@ impl Kind { let removable = !place.clips && !is_guide_or_clip(n); let gone = |is: bool| is.then(Vec::new); match self { - Kind::EmptyText => gone(matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())), + Kind::EmptyText => gone(removable && matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())), Kind::StrayPoints => gone(removable && matches!(&n.kind, NodeKind::Path { path, .. } if path.anchor_count() <= 1)), Kind::Unpainted => gone(removable && !place.in_compound && is_shape(n) && !is_painted(&n.appearance)), Kind::ZeroOpacity => gone(removable && !place.in_compound && n.opacity <= 0.0), Kind::DuplicateObjects => gone(removable && duplicates_the_one_below(cx.d, n)), - Kind::OffArtboard => gone(place.top && !is_guide_or_clip(n) && off_artboards(cx, n)), + Kind::OffArtboard => gone(place.top && removable && off_artboards(cx, n)), Kind::NeedlessGroups => needless_group(n), Kind::NeedlessCompounds => needless_compound(n), Kind::NeedlessClipGroups => needless_clip_group(cx, n), @@ -175,7 +175,9 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> { let top = Place { top: true, ..Place::default() }; match scope { Scope::Document => d.layers.iter().for_each(|l| visit(l, top, &mut out)), - Scope::Objects(ids) => ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, top, &mut out)), + Scope::Objects(ids) => { + ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, Place { clips: is_clipping_path(d, n.id), ..top }, &mut out)) + } } let editable = d.editable(); out.retain(|(n, _)| editable(n.id)); @@ -202,6 +204,11 @@ fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) { } } +/// Is `id` the clipping path of its group or layer? +fn is_clipping_path(d: &Document, id: NodeId) -> bool { + d.position(id).is_some_and(|(parent, i, _)| i == 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips)) +} + /// A group that stands for live or generated art: its members aren't loose art to clean. fn is_generated(n: &Node) -> bool { let e = n.extras(); @@ -235,17 +242,46 @@ fn is_plain(n: &Node) -> bool { && *n.extras() == NodeExtras::default() } -/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it? +/// Is `n` a plain path or type object (full opacity, normal blending) that doubles the object just +/// below it? fn duplicates_the_one_below(d: &Document, n: &Node) -> bool { - if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal { + if n.opacity < 1.0 || n.blend != BlendMode::Normal { return false; } let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned()); below.is_some_and(|b| { - b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras() + b.visible + && same_art(&b.kind, &n.kind) + && b.appearance == n.appearance + && b.opacity == n.opacity + && b.blend == n.blend + && b.extras() == n.extras() }) } +/// Distance (pt) within which doubled type counts as stacked: text set twice from a PDF or an old +/// file, or for a fake bold, is often a fraction of a point off. +const DOUBLED_TYPE: f64 = 1.0; + +/// The same path, or the same type (text, styles, size and turn) within [`DOUBLED_TYPE`] of it. +fn same_art(a: &NodeKind, b: &NodeKind) -> bool { + match (a, b) { + (NodeKind::Path { .. }, NodeKind::Path { .. }) => a == b, + (NodeKind::Text(a), NodeKind::Text(b)) => { + let ([a0, a1, a2, a3, ax, ay], [b0, b1, b2, b3, bx, by]) = (a.xf.as_coeffs(), b.xf.as_coeffs()); + [a0, a1, a2, a3] == [b0, b1, b2, b3] && (ax - bx).hypot(ay - by) <= DOUBLED_TYPE && { + // `b` placed on `a`, with `a`'s layout caches (they are in document space). + let mut moved = (**b).clone(); + moved.xf = a.xf; + moved.cached_bounds = a.cached_bounds; + moved.cached_baselines.clone_from(&a.cached_baselines); + **a == moved + } + } + _ => false, + } +} + fn inside(a: Rect, b: Rect) -> bool { a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1 } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 3351dd755..b20eb0d59 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -104,6 +104,39 @@ fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() { } } +#[test] +fn simplify_leaves_locked_and_hidden_paths_alone() { + let mut s = session(); + let mut ids = vec![]; + for x in [20.0, 140.0, 260.0] { + ids.push(id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": 50, "width": 100, "height": 100})).unwrap())); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + } + select(&mut s, &ids); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + change(&mut s, ids[1], |n| n.locked = true); + change(&mut s, ids[2], |n| n.visible = false); + select(&mut s, &[g]); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([ids[0].0])); + assert_eq!(r["before"], 16); + assert!(anchors(&s, ids[0]) < 16); + assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); + // Named by `ids`, or in a locked group, a path stays too, with no undo step. + let steps = undo_steps(&s); + s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap(); + assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); + assert_eq!(undo_steps(&s), steps); + change(&mut s, ids[1], |n| n.locked = false); + change(&mut s, g, |n| n.locked = true); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0))); + assert_eq!(anchors(&s, ids[1]), 16); + assert_eq!(undo_steps(&s), steps); +} + /// Each kind: what makes the issue in a fresh document (beside a clean rectangle). fn make_issue(s: &mut Session, kind: &str) { match kind { @@ -226,6 +259,69 @@ fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() { } } +/// The pixel at (100, 100) of the document drawn on white. +fn pixel(s: &Session) -> [u8; 4] { + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(100, 100) +} + +/// A clipping path hides the art outside it: empty type that clips a group, and type off every +/// artboard that clips a layer, stay, and the art stays hidden. +#[test] +fn clipping_type_stays_with_the_art_it_hides() { + const WHITE: [u8; 4] = [255, 255, 255, 255]; + let mut s = session(); + let art = rect(&mut s, 50.0, 50.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 60, "y": 80, "text": " ", "size": 40})).unwrap()); + select(&mut s, &[art, t]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + select(&mut s, &[]); + assert_eq!(pixel(&s), WHITE); + assert_eq!(found(&mut s, "emptyText", "document"), 0); + assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0); + select(&mut s, &[t]); + assert_eq!(found(&mut s, "emptyText", "selection"), 0, "the clipping path selected"); + assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap()["total"], 0); + assert!(node(&s, t).is_some()); + assert_eq!(pixel(&s), WHITE); + + let mut s = session(); + rect(&mut s, 50.0, 50.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": -300, "y": -300, "text": "Clip", "size": 40})).unwrap()); + s.execute("layer.clippingMask.toggle", &json!({})).unwrap(); + select(&mut s, &[]); + assert_eq!(pixel(&s), WHITE); + assert_eq!(found(&mut s, "offArtboard", "document"), 0); + assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0); + select(&mut s, &[t]); + assert_eq!(found(&mut s, "offArtboard", "selection"), 0, "the clipping path selected"); + assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap()["total"], 0); + assert!(node(&s, t).is_some()); + assert_eq!(pixel(&s), WHITE); +} + +/// Type set twice in the same place (from a PDF, or for a fake bold) is a duplicate within a point: +/// Show selects the upper copy and Fix removes it. Other text, or the same text further off, stays. +#[test] +fn doubled_type_within_a_point_is_a_duplicate() { + let mut s = session(); + let text = |s: &mut Session, x: f64, text: &str| id_of(&s.execute("text.create", &json!({"x": x, "y": 100, "text": text})).unwrap()); + let base = text(&mut s, 20.0, "Total"); + let doubled = text(&mut s, 20.4, "Total"); + let other = text(&mut s, 20.0, "Subtotal"); + let far = text(&mut s, 23.0, "Subtotal"); + assert_eq!(found(&mut s, "duplicateObjects", "document"), 1); + let shown = s.execute("addon.cleanup.show", &json!({"kind": "duplicateObjects", "scope": "document"})).unwrap(); + assert_eq!(shown["ids"], json!([doubled.0])); + s.execute("addon.cleanup.fix", &json!({"kinds": ["duplicateObjects"], "scope": "document"})).unwrap(); + assert!(node(&s, doubled).is_none()); + for id in [base, other, far] { + assert!(node(&s, id).is_some(), "{id:?} stays"); + } +} + #[test] fn fix_all_is_one_undo_step() { let mut s = session(); @@ -313,6 +409,51 @@ fn deep_ungroup_takes_apart_every_level_but_clipping_groups() { assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. })); } +#[test] +fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + select(&mut s, &[b]); + let hidden = id_of(&s.execute("object.group", &json!({})).unwrap()); + select(&mut s, &[c]); + let locked = id_of(&s.execute("object.group", &json!({})).unwrap()); + change(&mut s, hidden, |n| n.visible = false); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, e, |n| n.locked = true); + s.edit("t", |d, _| { + let w = d.alloc_id(); + Ok(d.wrap(&[a, hidden, locked, e], |ch| Node::group(w, ch))?) + }) + .unwrap(); + let outer = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id; + select(&mut s, &[outer]); + let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap(); + assert_eq!(r["ungrouped"], 1); + assert_eq!(r["ids"], json!([a.0])); + let d = &s.doc().unwrap().doc; + let layer: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [a, hidden, locked, e], "the hidden and locked groups stay whole"); + assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible); + assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked); + assert_eq!(s.doc().unwrap().selection.objects, [a]); + // Named by `ids`, a locked group stays as it is, with no undo step. + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap(); + assert_eq!(r["ungrouped"], 0); + assert!(node(&s, locked).is_some()); + assert_eq!(undo_steps(&s), steps); + // With nothing ungrouped, the selection stays and the reply lists it. + assert_eq!(r["ids"], json!([a.0])); + let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + select(&mut s, &[a]); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap(); + assert_eq!((r["ids"].clone(), r["ungrouped"].clone()), (json!([a.0]), json!(0))); + assert_eq!(s.doc().unwrap().selection.objects, [a]); +} + #[test] fn rejoin_joins_pieces_within_the_gap_only() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs new file mode 100644 index 000000000..0d279c0f6 --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -0,0 +1,962 @@ +//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point +//! Text, Join Point Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset +//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters' +//! styles and stay where the layout drew them: each is moved so the glyph of its first character +//! lands where it was. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, ParaStyle, RubySpan, TextKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit}; + +use crate::cmd::fonts::{edit_symbol_texts, symbol_texts}; +use crate::cmd::textedit::editing_range; +use crate::cmd::typecmd::refresh_bounds; +use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Most text objects one command takes. +const MAX_TEXTS: usize = 10_000; +/// Most pieces Break Text and Split Text at Cursor make at once. +const MAX_PIECES: usize = 20_000; +/// Most breaks Remove Line / Paragraph Breaks removes at once. +const MAX_BREAKS: usize = 200_000; +/// Most pieces Combine Point Text tries to join, counted over every try: past it, what it joined +/// so far is the result. +const MAX_JOIN_TRIES: usize = 200_000; +/// How far (pt) a glyph Combine Point Text sets may land from where it was. +const TOLERANCE: f64 = 0.01; +/// Combine Point Text: pieces whose baselines are within this many ems of each other share a line +/// (the difference becomes baseline shift); a gap wider than `MAX_GAP` ems, or an overlap of more +/// than `MAX_OVERLAP`, starts another object. +const SAME_LINE: f64 = 0.4; +const MAX_GAP: f64 = 10.0; +const MAX_OVERLAP: f64 = 1.0; +/// Tracking Combine Point Text may give a character (thousandths of an em). +const TRACKING: std::ops::RangeInclusive = -1000.0..=10000.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.cleanup.breakText", + "Break Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", + has_selection, + break_text + ), + cmd!( + "addon.cleanup.combineText", + "Combine Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + has_selection, + combine_text + ), + cmd!( + "addon.cleanup.joinText", + "Join Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", + has_selection, + join_text + ), + cmd!( + "addon.cleanup.removeBreaks", + "Remove Breaks", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}", + has_selection, + remove_breaks + ), + cmd!( + "addon.cleanup.splitText", + "Split Text at Cursor", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{} while a Type tool edits horizontal point or area type: the text before the selected characters, they and the text after become point type objects of their own (at a cursor: before and after it), each where its characters were, as one undo step; spaces at the ends of each piece go, and editing ends with the pieces selected → {ids}", + editing_text, + split_text + ), + cmd!( + "addon.cleanup.alignInPlace", + "Set Alignment, Keep Position", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", + has_selection, + align_in_place + ), + cmd!( + "addon.cleanup.resetTextTransform", + "Reset Text Transform", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", + has_selection, + reset_transform + ), + cmd!( + "addon.cleanup.replaceFonts", + "Replace Missing Fonts…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type (the type in the art of its symbols included) whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", + has_doc, + replace_fonts + ), + ] +} + +/// Enabled while a Type tool edits text. +fn editing_text(s: &Session) -> std::result::Result<(), String> { + match s.tool_options().get("editing") { + Some(v) if !v.is_null() => Ok(()), + _ => Err("Click in type with a Type tool to split it".into()), + } +} + +/// The type objects under `roots` (inside groups and layers, not live art) that can be edited. +fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { + fn visit(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Text(_) => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + _ => {} + } + } + let mut out = vec![]; + roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); + let editable = d.editable(); + out.retain(|id| editable(*id)); + out.dedup(); + if out.len() > MAX_TEXTS { + return Err(EngineError::Other("Too many type objects to change at once".into())); + } + Ok(out) +} + +fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { + match &d.node(id)?.kind { + NodeKind::Text(t) => Some(t), + _ => None, + } +} + +fn layout(t: &TextObject) -> TextLayout { + vectorcraft_text::layout(FontDb::global(), t) +} + +/// Where the layout draws the character at `byte` (the one it starts in): its glyph's pen position. +fn glyph_at(lay: &TextLayout, byte: usize) -> Option { + let start = lay.glyphs.iter().filter(|g| g.len > 0 && g.byte <= byte).map(|g| g.byte).max()?; + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == start).map(|g| g.origin) +} + +/// The pen position of the glyph that starts at `byte`, if one does. +fn glyph_starting(lay: &TextLayout, byte: usize) -> Option { + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == byte).map(|g| g.origin) +} + +/// The first glyph of line `l`, in text order. +fn line_start(lay: &TextLayout, l: usize) -> Option { + let line = lay.lines.get(l)?; + let g = lay.glyphs.get(line.glyph_start..line.glyph_end)?.iter().filter(|g| g.len > 0).min_by_key(|g| g.byte)?; + Some(g.origin) +} + +/// `a..b` of `text` without the white space at its ends; None when nothing else is left. +fn trimmed(text: &str, a: usize, b: usize) -> Option<(usize, usize)> { + let s = text.get(a..b)?; + let start = a + (s.len() - s.trim_start().len()); + let end = a + s.trim_end().len(); + (start < end).then_some((start, end)) +} + +/// Paragraph alignment as point type keeps it: justified lines align to their side. +fn point_justify(j: Justify) -> Justify { + match j { + Justify::JustifyLeft | Justify::JustifyAll => Justify::Left, + Justify::JustifyCenter => Justify::Center, + Justify::JustifyRight => Justify::Right, + j => j, + } +} + +#[derive(Clone, Copy, PartialEq)] +enum By { + Lines, + Paragraphs, + Words, + Glyphs, +} + +/// A type object being cut into point type pieces: its text as drawn (Shrink Text to Fit's sizes +/// taken in) and its layout. +struct Source { + t: TextObject, + lay: TextLayout, + text: String, +} + +impl Source { + /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. + fn of(d: &Document, id: NodeId) -> Option { + let t = text_of(d, id)?; + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + return None; + } + let lay = layout(t); + // Text left out of a frame would be lost with it. + if lay.overflow { + return None; + } + let t = if (lay.fit_scale - 1.0).abs() > 1e-9 { vectorcraft_text::scaled(t, lay.fit_scale) } else { t.clone() }; + Some(Self { text: t.plain_text(), t, lay }) + } + + /// The byte ranges of the pieces `by` cuts the text into. + fn ranges(&self, by: By) -> Vec<(usize, usize)> { + let text = &self.text; + match by { + By::Paragraphs => { + let mut out = vec![]; + let mut a = 0; + for (i, _) in text.match_indices('\n') { + out.push((a, i)); + a = i + 1; + } + out.push((a, text.len())); + out + } + By::Lines => self.lay.lines.iter().map(|l| (l.start, l.end)).collect(), + By::Words => { + let mut out = vec![]; + for l in &self.lay.lines { + let Some(s) = text.get(l.start..l.end) else { continue }; + let mut word: Option = None; + for (i, c) in s.char_indices() { + match (c.is_whitespace(), word) { + (true, Some(w)) => { + out.push((l.start + w, l.start + i)); + word = None; + } + (false, None) => word = Some(i), + _ => {} + } + } + if let Some(w) = word { + out.push((l.start + w, l.end)); + } + } + out + } + By::Glyphs => { + // One piece per cluster (a ligature's characters stay together). + let mut clusters = std::collections::BTreeMap::new(); + for g in self.lay.glyphs.iter().filter(|g| g.len > 0) { + let end = clusters.entry(g.byte).or_insert(g.byte + g.len); + *end = (*end).max(g.byte + g.len); + } + clusters.into_iter().collect() + } + } + } + + /// Point type holding bytes `a..b` (white space at its ends left out) where they were drawn. + /// Lines the layout wrapped end in a forced line break; `lists` keeps bulleted paragraphs' + /// bullets where the piece starts its paragraph. + fn piece(&self, (a, b): (usize, usize), lists: bool) -> Option { + let (a, b) = trimmed(&self.text, a, b)?; + let mut p = TextObject::point(Point::ZERO, "", CharStyle::default()); + p.runs = edit::slice_runs(&self.t.runs, a, b); + p.xf = self.t.xf; + p.optical_margin = self.t.optical_margin; + p.system_layout = self.t.system_layout; + p.fractional_widths = self.t.fractional_widths; + p.ruby = + self.t.ruby.iter().filter(|r| a <= r.start && r.end <= b).map(|r| RubySpan { start: r.start - a, end: r.end - a, ..r.clone() }).collect(); + // The paragraphs it holds, with their styles; the first keeps its bullet only from its start. + let starts_para = self.text.get(..a).is_some_and(|s| s.rsplit('\n').next().is_some_and(|line| line.trim().is_empty())); + let styles = self.t.paragraph_styles(); + let mut paras: Vec = styles.get(self.t.paragraphs_in(a, b))?.to_vec(); + for (i, para) in paras.iter_mut().enumerate() { + para.justify = point_justify(para.justify); + if !(lists && (i > 0 || starts_para) && para.list.as_ref().is_some_and(|l| l.number.is_none())) { + para.list = None; + } + } + p.set_paragraph_styles(paras); + // Lines the layout wrapped end in a forced line break, in place of the spaces they ended in. + let mut wraps = vec![]; + for next in self.lay.lines.iter().skip(1).filter(|l| a < l.start && l.start < b) { + let content = self.text.get(..next.start)?.trim_end_matches([' ', '\t']); + // A line that ends in a break of its own keeps it. + if !content.ends_with(['\n', '\u{2028}']) { + wraps.push((content.len().max(a) - a, next.start - a)); + } + } + for &(s, e) in wraps.iter().rev() { + edit::replace_range(&mut p.runs, s, e, "\u{2028}"); + p.ruby_after_replace(s, e, '\u{2028}'.len_utf8()); + } + self.place(&mut p, a)?; + Some(p) + } + + /// Move `p` (cut from byte `a`) so the glyph of its first character lands where it was drawn. + /// A piece of several lines that starts inside a left-aligned line takes a first-line indent, + /// so its later lines stay where they were too. + fn place(&self, p: &mut TextObject, a: usize) -> Option<()> { + let lay = layout(p); + let mut shift: Vec2 = glyph_at(&self.lay, a)? - glyph_at(&lay, 0)?; + let first = self.lay.lines.iter().rposition(|l| l.start <= a); + if let (Some(now), Some(was)) = (line_start(&lay, 1), first.and_then(|l| line_start(&self.lay, l + 1))) { + let off = was.x - (now.x + shift.x); + let left = match p.para.justify { + Justify::Left => true, + Justify::Auto => lay.lines.first().is_some_and(|l| !l.rtl), + _ => false, + }; + if left && off.abs() > 1e-6 { + p.edit_paras(Some(0..1), |para| para.first_line_indent -= off); + shift.x += off; + } + } + p.xf = self.t.xf * Affine::translate(shift); + Some(()) + } +} + +/// Put each object of `items` (id, the node, its pieces) out of the document, its pieces in its +/// place, as one undo step labelled `label`, the pieces selected → their ids. +fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, Vec)>) -> Result> { + s.edit(label, |d, sel| { + let (mut made, mut places) = (vec![], vec![]); + for (id, mut node, pieces) in items { + // A light template: the pieces take its paint and attributes, not its text. + node.kind = NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "", CharStyle::default()))); + let nodes: Vec = pieces + .into_iter() + .map(|mut t| { + refresh_bounds(&mut t); + let mut n = d.reid(&node); + n.kind = NodeKind::Text(Box::new(t)); + n + }) + .collect(); + made.extend(nodes.iter().map(|n| n.id)); + places.push((id, nodes)); + } + d.insert_beside(places, true)?; + sel.set(made.iter().copied()); + Ok(made.iter().map(|i| i.0).collect()) + }) +} + +fn ids_json(ids: &[NodeId]) -> Vec { + ids.iter().map(|i| i.0).collect() +} + +fn break_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.breakText"; + let by = match text_param(p, "by", C)?.unwrap_or("lines") { + "lines" => By::Lines, + "paragraphs" => By::Paragraphs, + "words" => By::Words, + "glyphs" => By::Glyphs, + other => return Err(bad(C, format!("`by` must be lines, paragraphs, words or glyphs, not {other:?}"))), + }; + let d = &s.doc()?.doc; + let (mut items, mut skipped, mut count) = (vec![], vec![], 0); + for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + skipped.push(id); + continue; + }; + let ranges = src.ranges(by); + count += ranges.len(); + if count > MAX_PIECES { + return Err(EngineError::Other("Too many pieces to make at once".into())); + } + let pieces: Vec = ranges.into_iter().filter_map(|r| src.piece(r, by == By::Paragraphs)).collect(); + if pieces.is_empty() { + skipped.push(id); + } else { + items.push((id, node.clone(), pieces)); + } + } + if items.is_empty() { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + } + let ids = replace_with_pieces(s, "Break Text", items)?; + Ok(json!({ "ids": ids, "skipped": ids_json(&skipped) })) +} + +fn split_text(s: &mut Session, _: &Value) -> Result { + let Some((id, a, b)) = editing_range(s)? else { + return Err(EngineError::Other("Click in type with a Type tool to split it".into())); + }; + let d = &s.doc()?.doc; + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + }; + let text = &src.text; + let (a, b) = (edit::floor_char(text, a.min(b)), edit::floor_char(text, a.max(b))); + let cuts: Vec<(usize, usize)> = if a == b { vec![(0, a), (a, text.len())] } else { vec![(0, a), (a, b), (b, text.len())] }; + let pieces: Vec = cuts.into_iter().filter_map(|r| src.piece(r, true)).collect(); + if pieces.len() < 2 { + return Err(EngineError::Other("Nothing to split at the cursor".into())); + } + let node = node.clone(); + let ids = replace_with_pieces(s, "Split Text at Cursor", vec![(id, node, pieces)])?; + // The text edited is gone: the pieces stay selected. + s.set_tool_option("stopEditing", &Value::Bool(true)); + Ok(json!({ "ids": ids })) +} + +/// One piece of point type Combine Point Text takes: its text with its object's scale taken into +/// its characters, and where it starts and ends along its line in the frame of its turn and shear. +struct Piece { + id: NodeId, + t: TextObject, + /// The object's turn and shear (`xf` without its move). + turn: [f64; 4], + /// Where its first character's glyph starts on its baseline, in that frame. + at: Point, + /// Where its last glyph ends along the line, in that frame. + end: f64, + /// Its first character's size. + size: f64, + /// Each character's pen position from the first's (byte, x), as it lays out alone. + pens: Vec<(usize, f64)>, +} + +impl Piece { + fn of(d: &Document, id: NodeId) -> Option<(Self, &Node)> { + let node = d.node(id)?; + let NodeKind::Text(t) = &node.kind else { return None }; + let text = t.plain_text(); + if t.vertical || !matches!(t.kind, TextKind::Point) || text.trim().is_empty() || text.contains(['\n', '\u{2028}']) { + return None; + } + let mut t = (**t).clone(); + t.scale_text_attributes(); + t.style_scale()?; + let lay = layout(&t); + let line = lay.lines.first().filter(|l| !l.rtl && lay.lines.len() == 1)?; + let first = glyph_at(&lay, 0)?; + // The first glyph of each cluster (a mark drawn over it shares its byte). + let mut seen = std::collections::HashSet::new(); + let pens = lay.glyphs.iter().filter(|g| g.len > 0 && seen.insert(g.byte)).map(|g| (g.byte, g.origin.x - first.x)).collect(); + let end = lay.glyphs.iter().map(|g| g.origin.x + g.advance).fold(first.x, f64::max) - first.x; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + let turn = Affine::new([a, b, c, dd, 0.0, 0.0]); + let at = turn.inverse() * (t.xf * Point::new(first.x, line.baseline)); + let size = t.runs.first()?.style.size; + if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) { + return None; + } + Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) + } + + /// Whether `o` is turned and sheared alike. + fn turned_like(&self, o: &Piece) -> bool { + self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0)) + } +} + +/// Whether two objects look alike apart from their text (paint, opacity, blending, effects). +fn same_look(a: &Node, b: &Node) -> bool { + a.appearance == b.appearance + && a.opacity == b.opacity + && a.blend == b.blend + && a.isolate == b.isolate + && a.knockout == b.knockout + && a.extras == b.extras +} + +/// How wide a space of `style` is. +fn space_width(style: &CharStyle) -> f64 { + let probe = TextObject::point(Point::ZERO, " ", CharStyle { tracking: 0.0, ..style.clone() }); + layout(&probe).glyphs.first().map_or(style.size * 0.25, |g| g.advance) +} + +/// `chain` (pieces left to right on one line) as one point type object whose glyphs land where +/// theirs were, or the index of the first piece that can't join the ones before it so. +fn merge(chain: &[&Piece]) -> std::result::Result { + let Some(first) = chain.first() else { return Err(0) }; + let mut runs = vec![]; + // Each piece's first byte, and the byte range of the character before it (whose tracking sets + // the gap). + let (mut starts, mut before) = (vec![], vec![]); + let mut text = String::new(); + for (k, p) in chain.iter().enumerate() { + if let Some(prev) = k.checked_sub(1).and_then(|i| chain.get(i)) { + let starts_blank = p.t.plain_text().starts_with(char::is_whitespace); + let last = prev.t.runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !text.ends_with(char::is_whitespace) && !starts_blank && p.at.x - prev.end >= 0.5 * space_width(&last) { + runs.push(vectorcraft_doc::TextRun::new(" ", last)); + text.push(' '); + } + let c = text.chars().next_back().map_or(1, char::len_utf8); + before.push((text.len() - c, text.len())); + } + starts.push(text.len()); + // Baselines a little apart: the difference is baseline shift (up is positive). + let shift = first.at.y - p.at.y; + for r in &p.t.runs { + let mut r = r.clone(); + r.style.baseline_shift += shift; + text.push_str(&r.text); + runs.push(r); + } + } + let mut m = first.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..first.t.para.clone() }); + let [a, b, c, d] = first.turn; + let base = Affine::new([a, b, c, d, 0.0, 0.0]) * Affine::translate(first.at.to_vec2()); + m.xf = base; + m.runs = runs; + m.ruby.clear(); + // Tracking after the character before each piece, until each lands where it was. + let targets: Vec = chain.iter().map(|p| p.at.x - first.at.x).collect(); + let mut lay = layout(&m); + for _ in 0..4 { + // A piece whose first character joins the one before it (a ligature) can't keep its place. + let xs: Vec = match starts.iter().enumerate().map(|(k, b)| glyph_starting(&lay, *b).map(|g| g.x).ok_or(k)).collect() { + Ok(xs) => xs, + Err(k) => return Err(k.max(1)), + }; + let x0 = xs.first().copied().unwrap_or(0.0); + let off: Vec = targets.iter().zip(&xs).map(|(t, x)| t - (x - x0)).collect(); + if off.iter().all(|o| o.abs() <= TOLERANCE * 0.5) { + break; + } + // Moving piece k moves those after it as far. + for (k, w) in off.windows(2).enumerate() { + let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue }; + if delta.abs() > 1e-9 { + edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0); + if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) { + return Err(k + 1); + } + } + } + lay = layout(&m); + } + // Every glyph where it was, within the tolerance; else the run ends before the piece that moved. + let x0 = glyph_starting(&lay, 0).map_or(0.0, |g| g.x); + for (k, p) in chain.iter().enumerate() { + let (Some(&start), Some(&target)) = (starts.get(k), targets.get(k)) else { return Err(k.max(1)) }; + let moved = p.pens.iter().any(|(byte, x)| glyph_starting(&lay, start + byte).is_none_or(|g| (g.x - x0 - target - x).abs() > TOLERANCE)); + if moved { + return Err(k.max(1)); + } + } + m.xf = base * Affine::translate((-x0, 0.0)); + Ok(m) +} + +fn combine_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.combineText"; + let d = &s.doc()?.doc; + let mut pieces: Vec<(Piece, &Node)> = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); + // Lines: pieces turned alike and looking alike, by baseline, then left to right. + pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); + let mut lines: Vec> = vec![]; + for (piece, node) in &pieces { + let fits = |l: &Vec<(&Piece, &Node)>| { + l.first() + .is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) + }; + match lines.iter_mut().find(|l| fits(l)) { + Some(l) => l.push((piece, node)), + None => lines.push(vec![(piece, node)]), + } + } + // Runs along each line: a wide gap or an overlap starts another. + let mut chains: Vec> = vec![]; + for mut l in lines { + l.sort_by(|x, y| x.0.at.x.total_cmp(&y.0.at.x)); + let mut chain: Vec<&Piece> = vec![]; + for (piece, _) in l { + if let Some(prev) = chain.last() { + let gap = piece.at.x - prev.end; + if gap > MAX_GAP * prev.size || gap < -MAX_OVERLAP * prev.size { + chains.push(std::mem::take(&mut chain)); + } + } + chain.push(piece); + } + chains.push(chain); + } + // Each run as one object, or as several where a piece can't keep its place joined. + let mut made: Vec<(NodeId, TextObject, Vec)> = vec![]; + let mut tries = 0; + 'chains: for chain in chains { + let mut rest: &[&Piece] = &chain; + while rest.len() > 1 { + let mut upto = rest.len(); + while let Some(part) = rest.get(..upto) { + tries += part.len(); + if tries > MAX_JOIN_TRIES { + break 'chains; + } + match merge(part) { + Ok(m) => { + let gone = part.iter().skip(1).map(|p| p.id).collect(); + if let Some(first) = part.first() { + made.push((first.id, m, gone)); + } + break; + } + // A run of one stays as it is. + Err(k) if k <= 1 => { + upto = 1; + break; + } + Err(k) => upto = k, + } + } + rest = rest.get(upto.max(1)..).unwrap_or_default(); + } + } + if made.is_empty() { + return Err(EngineError::Other("Select two or more one-line point type objects on one baseline to combine".into())); + } + let combined: usize = made.iter().map(|m| m.2.len()).sum(); + let ids: Vec = made.iter().map(|m| m.0.0).collect(); + s.edit("Combine Point Text", |d, sel| { + let mut gone = vec![]; + for (id, mut t, others) in made { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + } + gone.extend(others); + } + d.remove_many(&gone)?; + sel.set(ids.iter().map(|i| NodeId(*i))); + Ok(()) + })?; + Ok(json!({ "ids": ids, "combined": combined })) +} + +fn join_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.joinText"; + let d = &s.doc()?.doc; + let pieces: Vec = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); + // The piece top-left on the page sets the turn; the others turned like it are read in that frame. + let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at; + let first = pieces.iter().min_by(|x, y| { + let (a, b) = (on_page(x), on_page(y)); + a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)) + }); + let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default(); + kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x))); + // Lines, top to bottom, by baseline; each line's pieces left to right. + let mut lines: Vec> = vec![]; + for piece in kept { + match lines.last_mut().filter(|l| l.first().is_some_and(|f| (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))) { + Some(l) => l.push(piece), + None => lines.push(vec![piece]), + } + } + for line in &mut lines { + line.sort_by(|x, y| x.at.x.total_cmp(&y.at.x)); + } + let joined: usize = lines.iter().map(Vec::len).sum(); + let (Some(top), true) = (lines.first().and_then(|l| l.first()).copied(), joined >= 2) else { + return Err(EngineError::Other("Select two or more one-line point type objects to join".into())); + }; + let mut runs: Vec = vec![]; + let mut prev_y = None; + for line in &lines { + let y = line.first().map_or(top.at.y, |p| p.at.y); + // The line keeps the distance from the baseline above as its leading. + let leading = prev_y.map(|above| y - above).filter(|l: &f64| l.is_finite() && *l > 0.0); + prev_y = Some(y); + let last = |runs: &[vectorcraft_doc::TextRun]| runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !runs.is_empty() { + runs.push(vectorcraft_doc::TextRun::new("\n", last(&runs))); + } + let start = runs.len(); + for (k, piece) in line.iter().enumerate() { + let blank = runs.last().is_some_and(|r| r.text.ends_with(char::is_whitespace)) || piece.t.plain_text().starts_with(char::is_whitespace); + if k > 0 && !blank { + runs.push(vectorcraft_doc::TextRun::new(" ", last(&runs))); + } + runs.extend(piece.t.runs.iter().cloned()); + } + if let Some(l) = leading { + for r in runs.iter_mut().skip(start) { + r.style.leading = Some(l); + } + } + } + let mut m = top.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..top.t.para.clone() }); + m.runs = runs; + edit::normalize(&mut m.runs); + m.ruby.clear(); + // The top-left piece's first glyph stays where it was. + let [a, b, c, dd] = top.turn; + let base = Affine::new([a, b, c, dd, 0.0, 0.0]) * Affine::translate(top.at.to_vec2()); + m.xf = base; + let x0 = glyph_starting(&layout(&m), 0).map_or(0.0, |g| g.x); + m.xf = base * Affine::translate((-x0, 0.0)); + refresh_bounds(&mut m); + let id = top.id; + let gone: Vec = lines.iter().flatten().map(|p| p.id).filter(|g| *g != id).collect(); + let count = lines.len(); + s.edit("Join Point Text", |d, sel| { + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(m)); + } + d.remove_many(&gone)?; + sel.set([id]); + Ok(()) + })?; + Ok(json!({ "id": id.0, "joined": joined, "lines": count })) +} + +/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many +/// went. +fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize { + let text = t.plain_text(); + let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b)); + let Some(range) = text.get(a..b) else { return 0 }; + let mut prev = text.get(..a).and_then(|s| s.chars().next_back()); + let mut cuts = vec![]; + for (i, c) in range.char_indices() { + let at = a + i; + if c != brk { + prev = Some(c); + continue; + } + // The next character left: past the breaks that go too. + let after = at + c.len_utf8(); + let next = text.get(after..).and_then(|rest| rest.char_indices().find(|(j, n)| *n != brk || after + j >= b).map(|(_, n)| n)); + let space = prev.is_some_and(|p| !p.is_whitespace()) && next.is_some_and(|n| !n.is_whitespace()); + if space { + prev = Some(' '); + } + cuts.push((at, space)); + } + for &(at, space) in cuts.iter().rev() { + let insert = if space { " " } else { "" }; + let end = at + brk.len_utf8(); + t.splice_paras(at, end, insert); + edit::replace_range(&mut t.runs, at, end, insert); + t.ruby_after_replace(at, end, insert.len()); + } + cuts.len() +} + +fn remove_breaks(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.removeBreaks"; + let (brk, label, none) = match text_param(p, "kind", C)? { + Some("line") => ('\u{2028}', "Remove Line Breaks", "No line breaks to remove"), + Some("paragraph") => ('\n', "Remove Paragraph Breaks", "No paragraph breaks to remove"), + Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))), + None => return Err(bad(C, "missing `kind`")), + }; + // The characters a Type tool has selected, else all the text of the selected type. + let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let d = &s.doc()?.doc; + let targets: Vec<(NodeId, usize, usize)> = match editing { + Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(), + None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(), + }; + let count: usize = targets + .iter() + .filter_map(|(id, a, b)| { + let text = text_of(d, *id)?.plain_text(); + let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b)); + Some(text.get(a..b)?.matches(brk).count()) + }) + .sum(); + if count == 0 { + return Err(EngineError::Other(none.into())); + } + if count > MAX_BREAKS { + return Err(EngineError::Other("Too many breaks to remove at once".into())); + } + let (removed, ids) = s.edit(label, |d, _| { + let (mut removed, mut ids) = (0, vec![]); + for (id, a, b) in targets { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let n = strip_breaks(t, a, b, brk); + if n > 0 { + refresh_bounds(t); + removed += n; + ids.push(id.0); + } + } + Ok((removed, ids)) + })?; + Ok(json!({ "ids": ids, "removed": removed })) +} + +/// Horizontal point type among the objects `p` names. +fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result> { + let d = &s.doc()?.doc; + let ids: Vec = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)? + .into_iter() + .filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))) + .collect(); + if ids.is_empty() { + return Err(EngineError::Other("Select point type".into())); + } + Ok(ids) +} + +/// Replace the type objects of `changed` as one undo step labelled `label` → their ids. +fn set_texts(s: &mut Session, label: &str, changed: Vec<(NodeId, TextObject)>, reset_angle: bool) -> Result { + let ids: Vec = changed.iter().map(|c| c.0.0).collect(); + s.edit(label, |d, _| { + for (id, mut t) in changed { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + if reset_angle { + n.bbox_angle = 0.0; + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids })) +} + +fn align_in_place(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.alignInPlace"; + let justify = match text_param(p, "align", C)? { + Some("left") => Justify::Left, + Some("center") => Justify::Center, + Some("right") => Justify::Right, + Some(other) => return Err(bad(C, format!("`align` must be \"left\", \"center\" or \"right\", not {other:?}"))), + None => return Err(bad(C, "missing `align`")), + }; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id).filter(|t| !t.vertical && t.para_styles().any(|para| para.justify != justify)) else { continue }; + let was = layout(t); + // The longest line stays where it is. + let Some((i, line)) = was.lines.iter().enumerate().max_by(|a, b| (a.1.x1 - a.1.x0).total_cmp(&(b.1.x1 - b.1.x0))) else { continue }; + let mut n = t.clone(); + n.edit_paras(None, |para| para.justify = justify); + let Some(now) = layout(&n).lines.get(i).map(|l| l.x0) else { continue }; + n.xf *= Affine::translate((line.x0 - now, 0.0)); + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type is aligned that way already".into())); + } + set_texts(s, "Set Alignment, Keep Position", changed, false) +} + +fn reset_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.resetTextTransform"; + let scale = switch_or(p, "scale", false, C)?; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id) else { continue }; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + // The object's own axes: its width along its baseline, and its height square to it. + let (sx, det) = (a.hypot(b), a * dd - b * c); + if !(sx > 1e-12 && sx.is_finite() && det.is_finite() && det.abs() > 1e-12) { + continue; + } + let sy = det.abs() / sx; + let unscaled = |t: &TextObject| t.runs.iter().all(|r| r.style.h_scale == 100.0); + let upright = b.abs() <= 1e-9 * sx && c.abs() <= 1e-9 * sy && a > 0.0 && dd > 0.0; + if upright && (!scale || ((sx - sy).abs() <= 1e-9 * sy && unscaled(t))) { + continue; + } + let mut n = t.clone(); + let w = if scale { + n.runs.iter_mut().for_each(|r| r.style.h_scale = 100.0); + sy + } else { + sx + }; + // The centre of the text stays where it is. + let centre = t.xf * layout(t).bounds.center(); + let turn = Affine::scale_non_uniform(w, sy); + let now = turn * layout(&n).bounds.center(); + n.xf = Affine::translate(centre - now) * turn; + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type has no transform to reset".into())); + } + set_texts(s, "Reset Text Transform", changed, true) +} + +fn replace_fonts(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.replaceFonts"; + let default = CharStyle::default().font_family; + let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); + let db = FontDb::global(); + // Any name of the family is stored as its own name. + let family = match db.resolve(wanted, "Regular") { + Some((f, m)) if m != FontMatch::Missing => f.family.clone(), + _ => return Err(bad(C, "the replacement font is not available")), + }; + let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); + let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style)); + let d = &s.doc()?.doc; + let mut ids = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && lacks(t) + { + ids.push(n.id); + } + }); + } + // The type in the symbols' art too, which exports name when its font is missing. + if ids.is_empty() && !symbol_texts(d, None).into_iter().any(|(t, _)| lacks(t)) { + return Err(EngineError::Other("No fonts are missing".into())); + } + let runs = s.edit("Replace Missing Fonts", |d, _| { + let mut runs = 0; + let mut replace = |t: &mut TextObject| { + for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { + // The style the font has closest to the one the character had. + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + r.style.font_family = family.clone(); + r.style.font_version = None; + r.style.font_axes.clear(); + runs += 1; + } + edit::normalize(&mut t.runs); + refresh_bounds(t); + }; + for id in &ids { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + replace(t); + } + } + edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); + drop(replace); + Ok(runs) + })?; + Ok(json!({ "runs": runs, "font": family })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs new file mode 100644 index 000000000..c75446b10 --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -0,0 +1,363 @@ +//! Artwork Cleanup's text tools: pieces stay where their glyphs were drawn and keep their styles, +//! each command is one undo step and refuses cleanly when there is nothing to do. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Justify, NodeId, NodeKind, TextKind, TextObject}; +use vectorcraft_geom::Point; + +use crate::{EngineError, Session}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, p: Value) -> NodeId { + NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +/// Each drawn character of `ids` (spaces left out) with where its glyph starts on the page, in +/// the order of the objects and their text. +fn glyphs(s: &Session, ids: &[NodeId]) -> Vec<(char, Point)> { + let mut out = vec![]; + for id in ids { + let t = obj(s, *id); + let text = t.plain_text(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let mut gs: Vec<_> = lay.glyphs.iter().filter(|g| g.len > 0).collect(); + gs.sort_by_key(|g| g.byte); + gs.dedup_by_key(|g| g.byte); + for g in gs { + let c = text[g.byte..].chars().next().unwrap(); + if !c.is_whitespace() { + out.push((c, t.xf * g.origin)); + } + } + } + out +} + +fn assert_same_places(was: &[(char, Point)], now: &[(char, Point)]) { + assert_eq!(was.iter().map(|g| g.0).collect::(), now.iter().map(|g| g.0).collect::()); + for (a, b) in was.iter().zip(now) { + assert!((a.1 - b.1).hypot() < 0.02, "{:?} moved to {:?}", a, b); + } +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn doc_json(s: &Session) -> String { + serde_json::to_string(&s.doc().unwrap().doc.layers).unwrap() +} + +fn other(r: Result) -> String { + match r { + Err(EngineError::Other(m)) => m, + r => panic!("expected a refusal, got {r:?}"), + } +} + +#[test] +fn break_into_words_keeps_places_styles_and_is_one_step() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 100, "text": "Hello big World", "size": 24})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 9, "size": 36, "tracking": 50})).unwrap(); + let rotated = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned words", "size": 18, "rotation": 30})); + let was = glyphs(&s, &[id, rotated]); + let before = doc_json(&s); + let steps = undo_steps(&s); + select(&mut s, &[id, rotated]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + let pieces = ids(&r); + assert_eq!(pieces.len(), 5); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.doc().unwrap().selection.objects.len(), 5, "the pieces are selected"); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, ["Hello", "big", "World", "Turned", "words"]); + assert_same_places(&was, &glyphs(&s, &pieces)); + let big = obj(&s, pieces[1]); + assert!(matches!(big.kind, TextKind::Point)); + assert_eq!((big.runs[0].style.size, big.runs[0].style.tracking), (36.0, 50.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn break_area_type_into_lines_paragraphs_and_glyphs() { + for (by, count) in [("lines", None), ("paragraphs", Some(2)), ("glyphs", None)] { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "A paragraph long enough to wrap in its frame\nSecond one", "size": 14, "area": {"width": 140, "height": 200}}), + ); + let t = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.lines.len() >= 3, "the first paragraph wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap(); + let pieces = ids(&r); + let want = count.unwrap_or(if by == "lines" { lay.lines.len() } else { was.len() }); + assert_eq!(pieces.len(), want, "{by}"); + assert_same_places(&was, &glyphs(&s, &pieces)); + if by == "paragraphs" { + // The wrapped lines end in line breaks. + assert!(obj(&s, pieces[0]).plain_text().contains('\u{2028}')); + } + } +} + +#[test] +fn break_text_refuses_what_it_cannot_break() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + assert!(other(s.execute("addon.cleanup.breakText", &json!({}))).contains("point or area type")); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "sentences"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} + +#[test] +fn combine_puts_glyph_pieces_back_together_where_they_were() { + let mut s = session(); + let id = text(&mut s, json!({"x": 50, "y": 120, "text": "Water Lily", "size": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 10, "fill": "#ff0000"})).unwrap(); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": "glyphs"})).unwrap()); + assert_eq!(pieces.len(), 9); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let made = ids(&r); + assert_eq!((made.len(), r["combined"].as_u64()), (1, Some(8))); + let t = obj(&s, made[0]); + assert_eq!(t.plain_text(), "Water Lily", "a space goes back between the words"); + assert_same_places(&was, &glyphs(&s, &made)); + let red = t.runs.iter().find(|r| r.text.contains('L')).unwrap(); + assert_ne!(red.style.fill, t.runs[0].style.fill, "styles stay"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(pieces[1]).is_some(), "undo brings the pieces back"); +} + +#[test] +fn combine_joins_only_pieces_on_one_line_turned_alike() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 200, "text": "Below", "size": 20})); + let d = text(&mut s, json!({"x": 300, "y": 100, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, b]); + select(&mut s, &[a, b, c, d]); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert_same_places(&was, &glyphs(&s, &[a])); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_some() && doc.node(d).is_some()); + select(&mut s, &[c, d]); + assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); +} + +/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs, +/// pieces on a line left to right, each line's baseline where it was (its leading is the distance +/// from the line above), in one undo step. A piece turned unlike the top-left one stays. +#[test] +fn join_stacks_lines_into_one_object_with_their_spacing() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let b = text(&mut s, json!({"x": 120, "y": 101, "text": "line", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20})); + let d = text(&mut s, json!({"x": 50, "y": 166, "text": "Third", "size": 20})); + let turned = text(&mut s, json!({"x": 300, "y": 300, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, c, d]); + select(&mut s, &[d, b, turned, c, a]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.joinText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), r["lines"].as_u64()), (Some(a.0), Some(4), Some(3))); + let t = obj(&s, a); + assert_eq!(t.plain_text(), "First line\nSecond\nThird"); + assert_eq!(t.runs.iter().filter(|r| r.text.contains("Third")).map(|r| r.style.leading).collect::>(), [Some(36.0)]); + // Each line's first glyph is where it was. + let now = glyphs(&s, &[a]); + for ch in ['F', 'S', 'T'] { + let (p, q) = (was.iter().find(|g| g.0 == ch).unwrap().1, now.iter().find(|g| g.0 == ch).unwrap().1); + assert!((p - q).hypot() < 0.05, "{ch}: {p:?} moved to {q:?}"); + } + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_none() && doc.node(d).is_none() && doc.node(turned).is_some()); + select(&mut s, &[turned]); + assert!(other(s.execute("addon.cleanup.joinText", &json!({}))).contains("join")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(c).is_some(), "undo brings the pieces back"); +} + +#[test] +fn remove_line_and_paragraph_breaks() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "one\u{2028}two \u{2028}three\nfour\n\nfive", "size": 12})); + select(&mut s, &[id]); + let before = doc_json(&s); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"})).unwrap(); + assert_eq!(r["removed"], 2); + assert_eq!(obj(&s, id).plain_text(), "one two three\nfour\n\nfive"); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(r["removed"], 3); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.paragraph_count()), ("one two three four five", 1)); + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"}))), "No line breaks to remove"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); + assert!(matches!(s.execute("addon.cleanup.removeBreaks", &json!({})), Err(EngineError::BadParams { .. }))); +} + +/// The Type tool editing `id` with bytes `anchor..caret` selected. +fn edit_text(s: &mut Session, id: NodeId, anchor: usize, caret: usize) { + s.select_tool("type", Default::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": id.0, "anchor": anchor, "caret": caret})); +} + +#[test] +fn remove_breaks_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + +#[test] +fn split_at_the_cursor_and_around_a_selection() { + for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "Split here please", "size": 20, "justify": "center"})); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.splitText", &json!({})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("Type tool")), "{r:?}"); + edit_text(&mut s, id, anchor, caret); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.splitText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let pieces = ids(&r); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, want); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert!(s.tool_options()["editing"].is_null(), "editing ends"); + } +} + +#[test] +fn split_keeps_the_later_lines_of_a_wrapped_piece() { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "First words then a long run of text that wraps in the frame", "size": 14, "area": {"width": 160, "height": 200}}), + ); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + edit_text(&mut s, id, 6, 6); + let pieces = ids(&s.execute("addon.cleanup.splitText", &json!({})).unwrap()); + assert_eq!(pieces.len(), 2); + assert_same_places(&was, &glyphs(&s, &pieces)); +} + +#[test] +fn set_alignment_keeps_the_text_in_place() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 60, "text": "Stay put", "size": 20})); + let multi = text(&mut s, json!({"x": 100, "y": 160, "text": "Short\u{2028}The longest line", "size": 20})); + let was = glyphs(&s, &[id]); + let longest: Vec<_> = glyphs(&s, &[multi]).into_iter().skip(5).collect(); + select(&mut s, &[id, multi]); + for align in ["center", "right", "left"] { + s.execute("addon.cleanup.alignInPlace", &json!({"align": align})).unwrap(); + assert_same_places(&was, &glyphs(&s, &[id])); + assert_same_places(&longest, &glyphs(&s, &[multi]).into_iter().skip(5).collect::>()); + } + assert_eq!(obj(&s, id).para.justify, Justify::Left); + assert!(other(s.execute("addon.cleanup.alignInPlace", &json!({"align": "left"}))).contains("already")); + assert!(matches!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "up"})), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn reset_text_transform_takes_off_rotation_and_with_scale_the_horizontal_scale() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 100, "text": "Tilted", "size": 20, "rotation": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150})).unwrap(); + let centre = |s: &Session| { + let t = obj(s, id); + t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds.center() + }; + let was = centre(&s); + let size = obj(&s, id).effective_char_style(&obj(&s, id).runs[0].style).unwrap().size; + select(&mut s, &[id]); + s.execute("addon.cleanup.resetTextTransform", &json!({})).unwrap(); + let t = obj(&s, id); + let [_, b, c, _, _, _] = t.xf.as_coeffs(); + assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "{:?}", t.xf); + assert!((centre(&s) - was).hypot() < 1e-6); + assert!((t.effective_char_style(&t.runs[0].style).unwrap().size - size).abs() < 1e-6, "the size stays"); + assert!(t.runs[0].style.h_scale > 100.0, "the horizontal scale stays"); + assert!(other(s.execute("addon.cleanup.resetTextTransform", &json!({}))).contains("no transform")); + s.execute("addon.cleanup.resetTextTransform", &json!({"scale": true})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.h_scale, 100.0); +} + +#[test] +fn replace_missing_fonts_with_the_default_or_a_chosen_font() { + let mut s = session(); + let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"})); + let kept = text(&mut s, json!({"x": 40, "y": 120, "text": "Fine", "size": 20})); + let fine = obj(&s, kept); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3"))); + let t = obj(&s, gone); + assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("Source Sans 3", "Bold")); + assert_eq!(obj(&s, kept), fine); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} + +#[test] +fn replace_missing_fonts_reaches_the_type_in_symbols() { + let mut s = session(); + let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"})); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [gone.0]})).unwrap(); + let family = |s: &Session| crate::cmd::fonts::symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family.clone(); + assert_eq!(family(&s), "No Such Font Family"); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3")), "{r}"); + assert_eq!(family(&s), "Source Sans 3"); + assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + // A symbol being edited: its type on the editing layer is replaced once. + let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"})); + s.execute("symbol.new", &json!({"name": "Mark 2", "ids": [gone.0]})).unwrap(); + s.execute("symbol.edit", &json!({"name": "Mark 2"})).unwrap(); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!(r["runs"].as_u64(), Some(1), "{r}"); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None); +} diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 3ddbd8c44..d1ed6c84a 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -1,9 +1,15 @@ -//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate and -//! Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as Filter writes -//! any colour adjustment into the colours themselves, and Select by Color Kind selects the objects -//! that use spot colours, carry a live colour adjustment, or are images of a colour model. Its panel -//! and its submenu, Object › Plug-ins › Color Effects, are the UI's. +//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate, +//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as +//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the +//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model, +//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, Find Nearest +//! Swatch ([`nearest`]) matches colours to a swatch library's, and Create Swatch Legend +//! ([`legend`]) draws labelled chips of the document's swatches. Its panel and its submenu, Object › +//! Plug-ins › Color Effects, are the UI's. +pub mod legend; +mod nearest; +mod print_fixes; #[cfg(test)] mod tests; @@ -11,7 +17,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_render::effects; -use super::super::colorcmds::recolor; +use super::super::colorcmds::recolor_links; use super::super::select::walk_selectable; use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, text_param}; use crate::{Result, Session}; @@ -35,11 +41,41 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}", has_selection, filter ), + cmd!( + "addon.color.printFixes", + "Print Ink Fixes", + ["Object", "Plug-ins", "Color Effects"], + None, + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + has_doc, + print_fixes::run + ), + cmd!( + query "addon.color.nearestSwatch", + "Find Nearest Swatch", + ["Object", "Plug-ins", "Color Effects"], + None, + "{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}", + has_doc, + nearest::find + ), + cmd!( + "addon.color.applyNearestSwatch", + "Apply Nearest Swatch", + ["Object", "Plug-ins", "Color Effects"], + None, + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + has_selection, + nearest::apply + ), ] + .into_iter() + .chain(legend::specs()) + .collect() } fn select(s: &mut Session, p: &Value) -> Result { @@ -116,5 +152,7 @@ fn filter(s: &mut Session, p: &Value) -> Result { o.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::>())); } let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…')); - recolor(s, &given, label, &|c| map.apply(c)) + let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc); + let swatches = |name: &str| kinds.get(name).copied(); + recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) } diff --git a/crates/engine/src/cmd/addons/color/legend.rs b/crates/engine/src/cmd/addons/color/legend.rs new file mode 100644 index 000000000..e2f38a7a1 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/legend.rs @@ -0,0 +1,183 @@ +//! Create Swatch Legend (`addon.color.swatchLegend`; Object › Plug-ins › Color Effects › Create +//! Swatch Legend…, #4919): a grid of chips on the artboard, one per colour swatch, each filled with +//! its swatch and labelled with the swatch's name and colour values, as one group made in one undo +//! step. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint, cms::Model}; +use vectorcraft_doc::{Appearance, AreaFit, Node, NodeKind, TextKind, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::super::super::{CommandSpec, bad, cmd, has_doc}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.color.swatchLegend", + "Create Swatch Legend", + ["Object", "Plug-ins", "Color Effects"], + None, + "{names?: [swatch name…] (default: every colour swatch of the document, in panel order), columns?: 1..50 (4), size?: chip side in pt 8..1000 (72), gap?: pt 0..1000 (12), values?: [hex|rgb|cmyk|lab|gray…] (each at most once; default: each swatch's own colour model; spot swatches also say spot), x?, y? (the legend's top-left; default: the first artboard's, inset by `gap`)} draw a legend of colour swatches: one square chip per swatch filled with it (linked to it), labelled inside with the swatch name and the values asked for, in black on light chips and white on dark ones, as one group in one undo step; the group is selected → {id, count} (#4919)", + has_doc, + legend + )] +} + +/// The command. +pub const COMMAND: &str = "addon.color.swatchLegend"; + +/// Most swatches one legend draws. +const MAX_CHIPS: usize = 2000; +const VALUES: [&str; 5] = ["hex", "rgb", "cmyk", "lab", "gray"]; + +fn number(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && range.contains(n)) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a number {}..{}", range.start(), range.end()))), + } +} + +/// The label lines of colour `c`: the values `values` names, else those of its own model. +fn value_lines(c: Color, values: &[String], spot: bool) -> Vec { + let own = match c.model() { + Model::Rgb => "rgb", + Model::Cmyk => "cmyk", + Model::Gray => "gray", + Model::Lab => "lab", + }; + let picked: Vec<&str> = if values.is_empty() { vec![own] } else { values.iter().map(String::as_str).collect() }; + let pct = |v: f32| (v * 100.0).round() as i32; + let mut lines: Vec = picked + .into_iter() + .map(|v| match v { + "hex" => c.to_hex().to_uppercase(), + "rgb" => { + let [r, g, b] = c.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32); + format!("R={r} G={g} B={b}") + } + "cmyk" => { + let [cy, m, y, k] = c.to_cmyk().map(|v| pct(v.clamp(0.0, 1.0))); + format!("C={cy} M={m} Y={y} K={k}") + } + "lab" => { + let l = c.to_lab(); + format!("L={} a={} b={}", l.l.round() as i32, l.a.round() as i32, l.b.round() as i32) + } + _ => { + // As Edit Colors › Convert to Grayscale and the Color panel convert it. + let k = match c.in_model(Model::Gray) { + Color::Gray { k } => k, + _ => 1.0 - c.to_lab().l / 100.0, + }; + format!("K={}%", pct(k.clamp(0.0, 1.0))) + } + }) + .collect(); + if spot { + lines.insert(0, "Spot".into()); + } + lines +} + +fn legend(s: &mut Session, p: &Value) -> Result { + const C: &str = COMMAND; + let columns = number(p, "columns", 4.0, 1.0..=50.0, C)?; + if columns.fract() != 0.0 { + return Err(bad(C, "`columns` must be a whole number 1..50")); + } + let columns = columns as usize; + let size = number(p, "size", 72.0, 8.0..=1000.0, C)?; + let gap = number(p, "gap", 12.0, 0.0..=1000.0, C)?; + let values: Vec = match p.get("values") { + None | Some(Value::Null) => vec![], + Some(Value::Array(a)) => a + .iter() + .map(|v| { + v.as_str().filter(|v| VALUES.contains(v)).map(str::to_string).ok_or_else(|| bad(C, "`values` must name hex, rgb, cmyk, lab or gray")) + }) + .collect::>()?, + Some(_) => return Err(bad(C, "`values` must be a list")), + }; + // Each kind once: every value line goes into every chip, so repeats would only multiply them. + if values.iter().enumerate().any(|(i, v)| values[..i].contains(v)) { + return Err(bad(C, "`values` names a kind twice")); + } + let st = s.doc()?; + let doc = &st.doc; + let colour = |w: &&vectorcraft_color::Swatch| w.paint.color().is_some(); + let swatches: Vec = match p.get("names") { + None | Some(Value::Null) => doc.swatches_iter().filter(colour).cloned().collect(), + Some(Value::Array(a)) => { + let mut out = vec![]; + for n in a { + let n = n.as_str().ok_or_else(|| bad(C, "`names` must be a list of swatch names"))?; + let w = doc.swatches_iter().find(|w| w.name == n).ok_or_else(|| bad(C, format!("no swatch named `{n}`")))?; + if w.paint.color().is_none() { + return Err(bad(C, format!("`{n}` is not a colour swatch"))); + } + out.push(w.clone()); + } + out + } + Some(_) => return Err(bad(C, "`names` must be a list of swatch names")), + }; + if swatches.is_empty() { + return Err(bad(C, "there are no colour swatches to draw")); + } + if swatches.len() > MAX_CHIPS { + return Err(bad(C, format!("a legend draws at most {MAX_CHIPS} swatches"))); + } + let origin = doc.artboards.first().map_or(Point::ZERO, |a| a.rect.origin()); + let x0 = number(p, "x", origin.x + gap, -1e6..=1e6, C)?; + let y0 = number(p, "y", origin.y + gap, -1e6..=1e6, C)?; + let system_layout = doc.setup.system_layout; + let parent = st.target_parent()?; + let text_size = (size / 7.0).clamp(4.0, 12.0); + let pad = (size / 12.0).clamp(1.0, 8.0); + let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size })); + let count = swatches.len(); + // Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or + // spot swatch then recolours the legend too. + let fills: Vec = + swatches.iter().map(|w| super::super::super::paint::swatch_solid(doc, &w.name, None).unwrap_or_else(|_| w.paint.clone())).collect(); + let gid = s.edit("Create Swatch Legend", |d, sel| { + let mut children = Vec::with_capacity(count * 2); + for (i, (w, fill)) in swatches.iter().zip(fills).enumerate() { + let Some(c) = w.paint.color() else { continue }; + let (col, row) = (i % columns, i / columns); + let (x, y) = (x0 + col as f64 * (size + gap), y0 + row as f64 * (size + gap)); + let mut chip = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + size, y + size)), Appearance::basic(fill, Paint::None, 0.0)); + chip.name = Some(w.name.clone()); + children.push(std::sync::Arc::new(chip)); + let mut lines = vec![w.name.clone()]; + lines.extend(value_lines(c, &values, w.spot)); + let mut label_style = style.clone(); + label_style.fill = Paint::solid(if c.to_lab().l < 50.0 { Color::WHITE } else { Color::BLACK }); + label_style.stroke = Paint::None; + // Area type the size of the chip (less its inset) that shrinks text to fit, so a long + // name or value line stays on its chip. + let mut t = TextObject::point(Point::new(x + pad, y + pad), &lines.join("\n"), label_style); + let inner = (size - 2.0 * pad).max(1.0); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, inner, inner)) }; + t.area.fit = AreaFit::ShrinkText { min_percent: AreaFit::MIN_PERCENT }; + t.para = super::super::super::create::new_type_para(); + t.system_layout = system_layout; + super::super::super::typecmd::refresh_bounds(&mut t); + children.push(std::sync::Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))))); + } + let gid = d.alloc_id(); + let mut g = Node::new(gid, NodeKind::Group { children, clip: false }); + g.name = Some("Swatch Legend".into()); + d.insert(parent, usize::MAX, g)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": gid.0, "count": count })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/addons/color/legend/tests.rs b/crates/engine/src/cmd/addons/color/legend/tests.rs new file mode 100644 index 000000000..9a8fa57f2 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/legend/tests.rs @@ -0,0 +1,118 @@ +use serde_json::json; +use vectorcraft_color::Color; +use vectorcraft_doc::{Node, NodeId, NodeKind}; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn group(s: &Session, id: u64) -> Node { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone() +} + +/// The text of each label in group `g`, in order. +fn labels(g: &Node) -> Vec<(String, Color)> { + g.children() + .unwrap() + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((t.plain_text(), t.runs[0].style.fill.color().unwrap())), + _ => None, + }) + .collect() +} + +#[test] +fn a_legend_draws_a_labelled_chip_per_colour_swatch_in_one_undo_step() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": "#101010"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Paper", "color": "#f8f4e8"})).unwrap(); + let colours = s.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("addon.color.swatchLegend", &json!({"columns": 3, "size": 60, "gap": 10})).unwrap(); + assert_eq!(r["count"].as_u64().unwrap() as usize, colours); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(r["id"].as_u64().unwrap())], "the legend is selected"); + let g = group(&s, r["id"].as_u64().unwrap()); + assert_eq!(g.name.as_deref(), Some("Swatch Legend")); + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 2 * colours, "a chip and a label each"); + // Chips fill with their swatch, three to a row, 70 pt apart. + let chips: Vec<&Node> = kids.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| &**n).collect(); + let doc = &s.doc().unwrap().doc; + for (chip, w) in chips.iter().zip(doc.swatches_iter().filter(|w| w.paint.color().is_some())) { + assert_eq!(chip.appearance.fill_paint().color(), w.paint.color(), "{}", w.name); + assert_eq!(chip.name.as_deref(), Some(w.name.as_str())); + } + let b = |i: usize| chips[i].geometric_bounds().unwrap(); + assert!((b(1).x0 - b(0).x0 - 70.0).abs() < 1e-6 && (b(3).y0 - b(0).y0 - 70.0).abs() < 1e-6 && (b(3).x0 - b(0).x0).abs() < 1e-6); + assert!((b(0).width() - 60.0).abs() < 1e-6); + // Labels: the name, then the swatch's own values; white on the dark chip, black on the light. + let l = labels(&g); + let ink = l.iter().find(|(t, _)| t.starts_with("Ink")).unwrap(); + let paper = l.iter().find(|(t, _)| t.starts_with("Paper")).unwrap(); + assert!(ink.0.contains("R=16 G=16 B=16"), "{}", ink.0); + assert_eq!((ink.1, paper.1), (Color::WHITE, Color::BLACK)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g.id).is_none()); +} + +#[test] +fn a_legend_takes_names_values_and_a_position() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Signal", "color": "cmyk 0 100 100 0", "spot": true})).unwrap(); + let r = + s.execute("addon.color.swatchLegend", &json!({"names": ["Signal"], "values": ["hex", "cmyk", "lab", "gray"], "x": 100, "y": 50})).unwrap(); + assert_eq!(r["count"], 1); + let g = group(&s, r["id"].as_u64().unwrap()); + let (text, _) = labels(&g).remove(0); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[0], "Signal"); + assert_eq!(lines[1], "Spot", "a spot swatch says so"); + assert!(lines[2].starts_with('#') && lines[3] == "C=0 M=100 Y=100 K=0" && lines[4].starts_with("L=") && lines[5].starts_with("K="), "{lines:?}"); + let chip = g.children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().geometric_bounds().unwrap(); + assert!((chip.x0 - 100.0).abs() < 1e-6 && (chip.y0 - 50.0).abs() < 1e-6); + // Refused, with nothing drawn. + let before = s.doc().unwrap().history.undo.len(); + for bad in [ + json!({"names": ["Nope"]}), + json!({"names": "Signal"}), + json!({"values": ["xyz"]}), + json!({"columns": 0}), + json!({"columns": 2.5}), + json!({"size": 2}), + json!({"gap": -1}), + json!({"x": f64::MAX}), + json!({"names": []}), + json!({"values": ["rgb", "rgb"]}), + ] { + assert!(s.execute("addon.color.swatchLegend", &bad).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), before); +} + +/// A chip of a global swatch is linked to it: editing the swatch recolours the chip. Each label is +/// area type inside its chip that shrinks a long name to fit (#4919 review). +#[test] +fn chips_stay_linked_and_labels_stay_on_their_chips() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "A Rather Long Brand Colour Name", "color": "#2050a0", "global": true})).unwrap(); + let r = s + .execute("addon.color.swatchLegend", &json!({"names": ["A Rather Long Brand Colour Name"], "size": 40, "values": ["hex", "rgb", "cmyk"]})) + .unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("swatch.edit", &json!({"name": "A Rather Long Brand Colour Name", "color": "#a02050"})).unwrap(); + let g = group(&s, id); + let kids = g.children().unwrap(); + let chip = kids.iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap(); + assert_eq!(chip.appearance.fill_paint().color().map(|c| c.to_hex()), Some("#a02050".into()), "the edit reached the chip"); + let label = kids.iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap(); + let NodeKind::Text(t) = &label.kind else { panic!("not type") }; + assert!(matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) && matches!(t.area.fit, vectorcraft_doc::AreaFit::ShrinkText { .. })); + let (c, l) = (chip.geometric_bounds().unwrap(), label.geometric_bounds().unwrap()); + assert!(l.x0 >= c.x0 - 1e-6 && l.y0 >= c.y0 - 1e-6 && l.x1 <= c.x1 + 1e-6 && l.y1 <= c.y1 + 1e-6, "label {l:?} inside chip {c:?}"); +} diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs new file mode 100644 index 000000000..5a9d0e7c7 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -0,0 +1,198 @@ +//! Find Nearest Swatch (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`; Object › +//! Plug-ins › Color Effects › Find Nearest Swatch…): the swatches of a library, a colour book the +//! user loaded or the document closest to each colour of the selection (CIEDE2000), and the +//! colours replaced by them as one undo step (#4923). + +use serde_json::{Value, json}; +use vectorcraft_color::cms::{Lab, delta_e2000}; +use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_doc::swatches::color_name; +use vectorcraft_doc::{Document, NodeId, NodeKind}; + +use super::super::super::colorcmds::recolor_links; +use super::super::super::menucmds::squash; +use super::super::super::swatchlib::{DOCUMENT_SWATCHES, library_swatches}; +use super::super::super::{bad, checked_ids_or_selection, color_value, index, switch_or, text_param}; +use super::super::arrange::whole_in; +use crate::{Result, Session}; + +const FIND: &str = "addon.color.nearestSwatch"; +const APPLY: &str = "addon.color.applyNearestSwatch"; +/// The most colours of the selection looked up. +const MAX_COLORS: usize = 100; + +/// The `library` param ([`DOCUMENT_SWATCHES`] when left out) and its colour swatches with their Lab +/// values. +fn candidates(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec<(Swatch, Lab)>)> { + let key = text_param(p, "library", cmd)?.unwrap_or(DOCUMENT_SWATCHES).to_string(); + let swatches = library_swatches(s, &key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?; + let labs: Vec<(Swatch, Lab)> = swatches + .into_iter() + .filter(|w| !w.is_reserved()) + .filter_map(|w| { + let lab = w.paint.color()?.to_lab(); + Some((w, lab)) + }) + .collect(); + if labs.is_empty() { + return Err(bad(cmd, format!("`{key}` has no colours"))); + } + Ok((key, labs)) +} + +/// The `count` swatches of `among` closest to `c`, closest first, with their difference (ΔE 2000). +fn nearest(among: &[(Swatch, Lab)], c: Color, count: usize) -> Vec<(&Swatch, f32)> { + let lab = c.to_lab(); + let mut v: Vec<(&Swatch, f32)> = among.iter().map(|(w, l)| (w, delta_e2000(*l, lab))).collect(); + v.sort_by(|a, b| a.1.total_cmp(&b.1)); + v.truncate(count); + v +} + +/// The solid colours and gradient stops of the fills (`fill`) and strokes (`stroke`) of `ids`, of +/// everything inside them and of their type, once each and at most [`MAX_COLORS`]. +fn art_colors(d: &Document, ids: &[NodeId], fill: bool, stroke: bool) -> Vec { + let mut out: Vec = vec![]; + let mut add = |p: &Paint| { + let colors: Vec = match p { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), + _ => vec![], + }; + for c in colors { + if out.len() < MAX_COLORS && !out.contains(&c) { + out.push(c); + } + } + }; + for n in ids.iter().filter_map(|id| d.node(*id)) { + n.walk(&mut |m| { + for it in &m.appearance.items { + if if it.is_fill() { fill } else { stroke } { + add(it.paint()); + } + } + if let NodeKind::Text(t) = &m.kind { + for r in &t.runs { + if fill { + add(&r.style.fill); + } + if stroke { + add(&r.style.stroke); + } + } + } + }); + } + out +} + +/// A swatch's name and values as the results give them. +fn described(w: &Swatch) -> Value { + let c = w.paint.color().unwrap_or_default(); + json!({ "name": w.name, "values": color_name(c), "hex": c.to_hex(), "spot": w.spot }) +} + +pub(super) fn find(s: &mut Session, p: &Value) -> Result { + let count = whole_in(p, "count", (1, 50), FIND)?.map_or(5, index); + let (fill, stroke) = (switch_or(p, "fill", true, FIND)?, switch_or(p, "stroke", true, FIND)?); + let colors = match p.get("color").filter(|v| !v.is_null()) { + Some(v) => vec![color_value(v).ok_or_else(|| bad(FIND, "`color` is not a colour"))?], + None => { + let ids = checked_ids_or_selection(s, p, FIND)?; + if ids.is_empty() { + return Err(bad(FIND, "select objects or give `color`")); + } + art_colors(&s.doc()?.doc, &ids, fill, stroke) + } + }; + let (library, among) = candidates(s, p, FIND)?; + let found: Vec = colors + .iter() + .map(|c| { + let matches: Vec = nearest(&among, *c, count) + .into_iter() + .map(|(w, de)| { + let mut m = described(w); + m["deltaE"] = json!((f64::from(de) * 100.0).round() / 100.0); + m + }) + .collect(); + json!({ "values": color_name(*c), "hex": c.to_hex(), "matches": matches }) + }) + .collect(); + Ok(json!({ "library": library, "colors": found })) +} + +pub(super) fn apply(s: &mut Session, p: &Value) -> Result { + let (fill, stroke) = (switch_or(p, "fill", true, APPLY)?, switch_or(p, "stroke", true, APPLY)?); + let ids = checked_ids_or_selection(s, p, APPLY)?; + if ids.is_empty() { + return Err(bad(APPLY, "nothing selected")); + } + let (library, among) = candidates(s, p, APPLY)?; + // The swatch each colour takes, once each. + let colors = art_colors(&s.doc()?.doc, &ids, fill, stroke); + let mut used: Vec<&Swatch> = vec![]; + for c in &colors { + if let Some((w, _)) = nearest(&among, *c, 1).first() + && !used.iter().any(|u| u.name == w.name) + { + used.push(w); + } + } + if used.is_empty() { + return Ok(json!({ "changed": 0, "swatches": [], "added": [] })); + } + let undo = s.doc()?.history.undo.len(); + // A library's swatches join the document's first, as Add to Swatches adds them, so spot and + // global colours stay linked to theirs. + let mut added = vec![]; + let mut names: Vec = used.iter().map(|w| w.name.clone()).collect(); + if library != DOCUMENT_SWATCHES { + let r = s.execute("swatch.library.add", &json!({ "library": library, "names": names }))?; + let listed = |k: &str| r.get(k).and_then(Value::as_array).cloned().unwrap_or_default(); + let in_doc: Vec = listed("added").iter().chain(&listed("existing")).filter_map(Value::as_str).map(str::to_string).collect(); + let d = &s.doc()?.doc; + // Each library swatch's copy: the document swatch with its paint among those added or kept. + names = used + .iter() + .map(|w| in_doc.iter().find(|n| d.swatch(n).is_some_and(|x| x.paint == w.paint)).cloned().unwrap_or_else(|| w.name.clone())) + .collect(); + added = listed("added"); + } + // Each used swatch's paint as art takes it: linked to a global or spot swatch. + let d = &s.doc()?.doc; + let paints: Vec<(Lab, Paint)> = used + .iter() + .zip(&names) + .filter_map(|(w, name)| { + let lab = w.paint.color()?.to_lab(); + let paint = d.swatch(name).filter(|x| x.global).and_then(|_| d.tint_paint(name, 1.0)).unwrap_or_else(|| w.paint.clone()); + Some((lab, paint)) + }) + .collect(); + // shortcut: a colour past the MAX_COLORS looked up takes the closest of the swatches those + // took; look up every colour if documents with more colours need their own matches. + let to = |c: Color| -> Option<&Paint> { + let lab = c.to_lab(); + paints.iter().min_by(|a, b| delta_e2000(a.0, lab).total_cmp(&delta_e2000(b.0, lab))).map(|(_, p)| p) + }; + let links = |c: &mut Color, link: &mut Option, tint: &mut f32| { + let Some(Paint::Solid { color, swatch, .. }) = to(*c) else { return false }; + if *c == *color && *link == *swatch { + return false; + } + (*c, *link, *tint) = (*color, swatch.clone(), 1.0); + true + }; + let given = json!({ + "ids": ids.iter().map(|i| i.0).collect::>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false, + }); + let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; + squash(s, undo, "Apply Nearest Swatch"); + Ok(json!({ "changed": r["changed"], "swatches": names, "added": added })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/addons/color/nearest/tests.rs b/crates/engine/src/cmd/addons/color/nearest/tests.rs new file mode 100644 index 000000000..0436d19c8 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/nearest/tests.rs @@ -0,0 +1,115 @@ +//! Find Nearest Swatch. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const FIND: &str = "addon.color.nearestSwatch"; +const APPLY: &str = "addon.color.applyNearestSwatch"; + +/// A document with a global swatch "Brand" and a selected rectangle filled `fill`, stroked `stroke`. +fn doc(fill: &str, stroke: Option<&str>) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff8000", "global": true})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": fill})).unwrap(); + match stroke { + Some(c) => s.execute("paint.setStroke", &json!({"color": c})).unwrap(), + None => s.execute("paint.setStroke", &json!({"none": true})).unwrap(), + }; + (s, id) +} + +fn paints(s: &Session, id: NodeId) -> (Paint, Paint) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + (n.appearance.fill_paint(), n.appearance.items.iter().find(|i| !i.is_fill()).unwrap().paint().clone()) +} + +fn names(matches: &Value) -> Vec<&str> { + matches.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap()).collect() +} + +#[test] +fn the_closest_swatches_of_a_library_are_listed_closest_first() { + let (mut s, _) = doc("#ff6634", None); + let v = s.execute(FIND, &json!({"library": "web-safe-216", "count": 3})).unwrap(); + assert_eq!(v["library"], "web-safe-216"); + let colors = v["colors"].as_array().unwrap(); + assert_eq!(colors.len(), 1, "the fill; no stroke"); + assert_eq!((colors[0]["hex"].as_str(), colors[0]["values"].as_str()), (Some("#ff6634"), Some("R=255 G=102 B=52"))); + let m = &colors[0]["matches"]; + assert_eq!(names(m)[0], "#FF6633"); + let de: Vec = m.as_array().unwrap().iter().map(|x| x["deltaE"].as_f64().unwrap()).collect(); + assert_eq!(de.len(), 3); + assert!(de[0] < 1.0 && de.windows(2).all(|w| w[0] <= w[1]), "{de:?}"); + // A colour given is looked up instead of the selection's. + let v = s.execute(FIND, &json!({"library": "web-safe-216", "color": "#010203", "count": 1})).unwrap(); + assert_eq!(names(&v["colors"][0]["matches"]), ["#000000"]); +} + +#[test] +fn the_document_swatches_are_the_default_library() { + let (mut s, _) = doc("#ff8010", Some("#ff8010")); + let v = s.execute(FIND, &json!({})).unwrap(); + assert_eq!(v["library"], "document"); + assert_eq!(v["colors"].as_array().unwrap().len(), 1, "fill and stroke share a colour: looked up once"); + assert_eq!(names(&v["colors"][0]["matches"])[0], "Brand"); + assert!(!names(&v["colors"][0]["matches"]).contains(&"[Registration]")); +} + +#[test] +fn applying_links_each_colour_to_its_nearest_document_swatch_as_one_undo_step() { + let (mut s, id) = doc("#ff8010", Some("#ff8010")); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute(APPLY, &json!({"stroke": false})).unwrap(); + assert_eq!((v["changed"].as_u64(), v["swatches"].clone(), v["added"].clone()), (Some(1), json!(["Brand"]), json!([]))); + let (fill, stroke) = paints(&s, id); + assert!(matches!(&fill, Paint::Solid { swatch: Some(n), color, .. } if n == "Brand" && color.to_hex() == "#ff8000"), "{fill:?}"); + assert_eq!(stroke.color().unwrap().to_hex(), "#ff8010", "strokes left alone"); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Apply Nearest Swatch")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paints(&s, id).0.color().unwrap().to_hex(), "#ff8010"); +} + +#[test] +fn a_library_spot_colour_joins_the_document_and_stays_spot() { + let (mut s, id) = doc("#e01525", None); + // A colour book of one spot colour, loaded as a user would load one they own. + s.execute("swatch.new", &json!({"name": "Ink Red", "color": "#e01020", "spot": true})).unwrap(); + let data = s.execute("swatch.library.save", &json!({"names": ["Ink Red"]})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("swatch.delete", &json!({"names": ["Ink Red"]})).unwrap(); + let lib = s.execute("swatch.library.load", &json!({"data": data, "name": "Book.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute(APPLY, &json!({"library": lib})).unwrap(); + assert_eq!((v["swatches"].clone(), v["added"].clone()), (json!(["Ink Red"]), json!(["Ink Red"]))); + let d = &s.doc().unwrap().doc; + assert!(d.swatch("Ink Red").is_some_and(|w| w.spot && w.global)); + assert!(matches!(paints(&s, id).0, Paint::Solid { swatch: Some(n), .. } if n == "Ink Red")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "adding and applying undo together"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("Ink Red").is_none()); + assert_eq!(paints(&s, id).0, Paint::solid(Color::from_hex("#e01525").unwrap())); +} + +#[test] +fn bad_requests_are_refused() { + let (mut s, _) = doc("#ff8010", None); + for (p, why) in [ + (json!({"library": "nope"}), "no swatch library `nope`"), + (json!({"library": "spectrum-gradients"}), "has no colours"), + (json!({"count": 0}), "`count` must be from 1 to 50"), + (json!({"color": "zzz"}), "`color` is not a colour"), + (json!({"fill": "yes"}), "`fill` must be true or false"), + ] { + let e = s.execute(FIND, &p).unwrap_err().to_string(); + assert!(e.contains(why), "{p}: {e}"); + } + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute(FIND, &json!({})).unwrap_err().to_string().contains("select objects or give `color`")); + assert!(s.execute(APPLY, &json!({})).is_err(), "nothing selected"); +} diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs new file mode 100644 index 000000000..7a4cfee9c --- /dev/null +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -0,0 +1,170 @@ +//! Print Ink Fixes (`addon.color.printFixes`, Object › Plug-ins › Color Effects › Print Ink +//! Fixes…): a CMYK document's inks made ready for print in one undo step. Colours with more total +//! ink than the limit are scaled down to it, white fills and strokes stop overprinting, and black +//! ones start to, as Edit › Edit Colors › Overprint Black sets it. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::overprint::{OverprintBlack, is_white}; +use vectorcraft_doc::{ColorMode, Node, NodeKind}; + +use super::super::super::colorcmds::{Recolor, Scope}; +use super::super::super::overprint::edit_counted; +use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param}; +use crate::{Result, Session}; + +const C: &str = "addon.color.printFixes"; + +/// What `p` asks for. +struct Fixes { + /// Most total ink, 0..4 (0: off). + ink_limit: f32, + exclude_pure_black: bool, + remove_white_overprint: bool, + /// Overprint Black's least black, 0..1 (0: off). + overprint_black: f32, + text_only: bool, + fill: bool, + stroke: bool, +} + +/// The percentage `p` gives for `key` in 0..=`max`, as a fraction (0 when left out). +fn percent(p: &Value, key: &str, max: f64) -> Result { + let v = number_or(p, key, 0.0, C)?; + if !(0.0..=max).contains(&v) { + return Err(bad(C, format!("`{key}` must be 0..{max}"))); + } + Ok((v / 100.0) as f32) +} + +impl Fixes { + fn of(p: &Value) -> Result { + let f = Fixes { + ink_limit: percent(p, "inkLimit", 400.0)?, + exclude_pure_black: switch_or(p, "excludePureBlack", true, C)?, + remove_white_overprint: switch_or(p, "removeWhiteOverprint", false, C)?, + overprint_black: percent(p, "overprintBlack", 100.0)?, + text_only: switch_or(p, "textOnly", false, C)?, + fill: switch_or(p, "fill", true, C)?, + stroke: switch_or(p, "stroke", true, C)?, + }; + if f.ink_limit <= 0.0 && f.overprint_black <= 0.0 && !f.remove_white_overprint { + return Err(bad(C, "give `inkLimit`, `removeWhiteOverprint` or `overprintBlack`")); + } + Ok(f) + } + + /// `c` with its total ink scaled down to the limit: CMYK, and grey (black ink) unless + /// pure blacks are excluded, which CMYK with black ink only is too. + fn limit(&self, c: Color) -> Color { + const E: f32 = 0.005; + let max = self.ink_limit; + match c { + Color::Cmyk { c: cy, m, y, k } => { + let total = cy + m + y + k; + let pure_black = cy <= E && m <= E && y <= E; + if total <= max || (self.exclude_pure_black && pure_black) || !total.is_finite() { + return c; + } + let s = max / total; + Color::cmyk(cy * s, m * s, y * s, k * s) + } + Color::Gray { k } if k > max && !self.exclude_pure_black => Color::gray(max), + _ => c, + } + } + + /// Remove white overprint from `n`'s own fills and strokes and its characters': true when + /// that changed anything. + fn unset_white(&self, n: &mut Node) -> bool { + let mut changed = false; + let mut off = |aimed: bool, white: bool, flag: &mut bool| { + if aimed && white && *flag { + *flag = false; + changed = true; + } + }; + for it in &mut n.appearance.items { + let aimed = if it.is_fill() { self.fill } else { self.stroke }; + let white = is_white(it.paint()); + off(aimed, white, it.overprint_mut()); + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + off(self.fill, is_white(&r.style.fill), &mut r.style.overprint_fill); + off(self.stroke, is_white(&r.style.stroke), &mut r.style.overprint_stroke); + } + } + changed + } + + /// The overprint fixes on `n` and everything inside it: (objects whose white overprint was + /// removed, objects set to overprint black). + fn overprint(&self, n: &mut Node, black: &OverprintBlack, spots: &[String]) -> (usize, usize) { + let white = usize::from(self.remove_white_overprint && self.unset_white(n)); + let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_))); + let mut counts = (white, usize::from(blacks && black.apply(n, spots))); + for c in n.children_mut().into_iter().flatten() { + let (w, b) = self.overprint(Arc::make_mut(c), black, spots); + counts = (counts.0 + w, counts.1 + b); + } + counts + } +} + +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let fixes = Fixes::of(p)?; + let st = s.doc()?; + if st.doc.color_mode != ColorMode::Cmyk { + return Err(bad(C, "Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first")); + } + let layers = st.doc.layers.iter().map(|l| l.id).collect(); + let roots = checked_ids_or_selection(s, p, C)?; + // As Artwork Cleanup's: the selection, or the document while nothing is selected. + let ids = match text_param(p, "scope", C)? { + Some("document") => layers, + None if roots.is_empty() => layers, + Some("selection") if roots.is_empty() => return Err(bad(C, "nothing selected")), + Some("selection") | None => roots, + Some(other) => return Err(bad(C, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))), + }; + let black = OverprintBlack { min_k: fixes.overprint_black, fill: fixes.fill, stroke: fixes.stroke, ..OverprintBlack::default() }; + let (mut ink, mut white, mut blacks) = (0, 0, 0); + let changed = edit_counted(s, "Print Ink Fixes", |d| { + if fixes.ink_limit > 0.0 { + let spots = d.spot_names(); + // Spot inks and Registration aren't process ink: they keep their colour and link. + let links = |c: &mut Color, link: &mut Option, _: &mut f32| { + if link.as_ref().is_some_and(|n| n == REGISTRATION || spots.contains(n)) { + return false; + } + let new = fixes.limit(*c); + if new == *c { + return false; + } + (*c, *link) = (new, None); + true + }; + let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false }; + ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids); + } + if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 { + return Ok(ink); + } + let spots = d.spot_names(); + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let (w, b) = fixes.overprint(n, &black, &spots); + (white, blacks) = (white + w, blacks + b); + } + } + Ok(ink + white + blacks) + })?; + Ok(json!({ "changed": changed, "inkLimited": ink, "whiteOverprintRemoved": white, "blackOverprinted": blacks })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/addons/color/print_fixes/tests.rs b/crates/engine/src/cmd/addons/color/print_fixes/tests.rs new file mode 100644 index 000000000..e4c191587 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/print_fixes/tests.rs @@ -0,0 +1,129 @@ +//! Print Ink Fixes. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const C: &str = "addon.color.printFixes"; + +/// A CMYK document with a rectangle per fill, each with overprint as given; nothing selected. +fn doc(fills: &[(Value, bool)]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "colorMode": "cmyk"})).unwrap(); + let ids = fills + .iter() + .enumerate() + .map(|(i, (fill, overprint))| { + let id = s.execute("shape.rectangle", &json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({ "color": fill })).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("object.setOverprint", &json!({ "fill": overprint })).unwrap(); + NodeId(id) + }) + .collect(); + s.execute("select.none", &json!({})).unwrap(); + (s, ids) +} + +fn fill(s: &Session, id: NodeId) -> (Color, bool) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let item = n.appearance.items.iter().find(|i| i.is_fill()).unwrap().clone(); + (item.paint().color().unwrap(), item.overprint()) +} + +fn cmyk(c: f64, m: f64, y: f64, k: f64) -> Value { + json!({"c": c, "m": m, "y": y, "k": k}) +} + +fn close(a: Color, b: [f32; 4]) -> bool { + let Color::Cmyk { c, m, y, k } = a else { return false }; + [c, m, y, k].iter().zip(b).all(|(x, y)| (x - y).abs() < 0.001) +} + +#[test] +fn the_ink_limit_scales_heavy_colours_down_to_it() { + let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), false), (cmyk(40.0, 30.0, 20.0, 10.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false)]); + let undo = s.doc().unwrap().history.undo.len(); + // Nothing selected: the whole document. + let v = s.execute(C, &json!({"inkLimit": 300})).unwrap(); + assert_eq!((v["inkLimited"].clone(), v["changed"].clone()), (json!(1), json!(1))); + assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "{:?}", fill(&s, ids[0])); + assert!(close(fill(&s, ids[1]).0, [0.4, 0.3, 0.2, 0.1]), "under the limit: as it was"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The selection only. + s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute(C, &json!({"inkLimit": 50})).unwrap(); + assert!(close(fill(&s, ids[1]).0, [0.2, 0.15, 0.1, 0.05])); + assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "left out"); + // Pure black keeps its ink unless it is included. + s.execute(C, &json!({"inkLimit": 50, "scope": "document"})).unwrap(); + assert!(close(fill(&s, ids[0]).0, [0.125; 4])); + assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.8])); + s.execute(C, &json!({"inkLimit": 50, "scope": "document", "excludePureBlack": false})).unwrap(); + assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.5])); +} + +#[test] +fn spot_and_registration_colours_keep_their_ink() { + let (mut s, ids) = doc(&[(cmyk(0.0, 0.0, 0.0, 0.0), false), (cmyk(0.0, 0.0, 0.0, 0.0), false)]); + s.execute("swatch.new", &json!({"name": "Heavy", "color": cmyk(100.0, 100.0, 100.0, 50.0), "spot": true})).unwrap(); + s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Heavy"})).unwrap(); + s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "[Registration]"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let before = [fill(&s, ids[0]), fill(&s, ids[1])]; + assert_eq!(s.execute(C, &json!({"inkLimit": 200})).unwrap()["changed"], 0); + assert_eq!([fill(&s, ids[0]), fill(&s, ids[1])], before); +} + +#[test] +fn white_overprint_goes_and_black_overprint_comes_at_the_threshold() { + let white = cmyk(0.0, 0.0, 0.0, 0.0); + let (mut s, ids) = + doc(&[(white.clone(), true), (cmyk(0.0, 0.0, 0.0, 95.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false), (cmyk(30.0, 0.0, 0.0, 100.0), false)]); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute(C, &json!({"removeWhiteOverprint": true, "overprintBlack": 90})).unwrap(); + assert_eq!((v["whiteOverprintRemoved"].clone(), v["blackOverprinted"].clone()), (json!(1), json!(1)), "{v}"); + let overprints: Vec = ids.iter().map(|id| fill(&s, *id).1).collect(); + // White stops; 95 % black starts; 80 % is under the threshold; a rich black isn't pure black. + assert_eq!(overprints, [false, true, false, false]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ids.iter().map(|id| fill(&s, *id).1).collect::>(), [true, false, false, false]); + // On type only: the rectangles stay. + let v = s.execute(C, &json!({"overprintBlack": 90, "textOnly": true})).unwrap(); + assert_eq!(v["changed"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed, no undo step"); + // Strokes only: the fills stay. + assert_eq!(s.execute(C, &json!({"overprintBlack": 90, "fill": false})).unwrap()["changed"], 0); +} + +#[test] +fn bad_requests_and_rgb_documents_are_refused() { + let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), true)]); + for p in [ + json!({}), + json!({"inkLimit": 0}), + json!({"inkLimit": "300"}), + json!({"inkLimit": 401}), + json!({"inkLimit": -1}), + json!({"overprintBlack": 101}), + json!({"overprintBlack": true}), + json!({"removeWhiteOverprint": "yes"}), + json!({"inkLimit": 300, "fill": 1}), + json!({"inkLimit": 300, "scope": "page"}), + json!({"inkLimit": 300, "scope": "selection"}), + json!({"inkLimit": 300, "ids": [999_999]}), + ] { + assert!(s.execute(C, &p).is_err(), "{p}"); + } + assert!(close(fill(&s, ids[0]).0, [1.0; 4])); + let mut rgb = Session::new(); + rgb.execute("file.new", &json!({})).unwrap(); + rgb.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let e = rgb.execute(C, &json!({"inkLimit": 300})).unwrap_err().to_string(); + assert!(e.contains("CMYK"), "{e}"); +} diff --git a/crates/engine/src/cmd/addons/color/tests.rs b/crates/engine/src/cmd/addons/color/tests.rs index 29034c51e..ed8137d6a 100644 --- a/crates/engine/src/cmd/addons/color/tests.rs +++ b/crates/engine/src/cmd/addons/color/tests.rs @@ -154,3 +154,163 @@ fn select_by_color_kind_finds_spot_colours_effects_and_image_models() { assert!(s.execute("addon.color.select", &json!({"kind": "pantone"})).is_err()); assert!(s.execute("addon.color.select", &json!({})).is_err()); } + +/// Vector Halftone, the live effect. +const HALFTONE: &str = "addon.color.halftone"; + +/// The dots in group `g`: each path's geometry, ink and blend mode. +fn dots(g: &vectorcraft_doc::Node) -> Vec<(vectorcraft_geom::PathData, String, vectorcraft_color::BlendMode)> { + let ink = |c: &vectorcraft_doc::Node| c.appearance.fill_paint().color().unwrap().to_hex(); + g.children().unwrap().iter().map(|c| (c.path_data().unwrap().clone(), ink(c), c.blend)).collect() +} + +/// `format` as `document.export` writes the active document. +fn exported(s: &mut Session, format: &str) -> Vec { + let v = s.execute("document.export", &json!({"format": format})).unwrap(); + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// How many subpaths the path data of `svg` starts. +fn svg_moves(svg: &[u8]) -> usize { + let svg = String::from_utf8_lossy(svg); + svg.split(" d=\"").skip(1).map(|d| d.split('"').next().unwrap_or("").matches(['M', 'm']).count()).sum() +} + +#[test] +fn expand_appearance_gives_the_dots_object_vector_halftone_makes() { + for params in [ + json!({"frequency": 18}), + json!({"frequency": 12, "mode": "cmyk", "shape": "square", "angle": 15}), + json!({"shape": "line", "invert": true, "clip": false}), + json!({"shape": "ellipse", "color": "#336699", "angle": -30}), + ] { + let (mut live, id) = red_rect(); + live.execute("effect.apply", &json!({"effect": HALFTONE, "params": params})).unwrap(); + assert_eq!(fill_hex(&live, id), "#ff0000", "the effect leaves the object as it is"); + live.execute("effect.expandAppearance", &json!({})).unwrap(); + let expanded = live.doc().unwrap().doc.node(id).cloned().unwrap(); + assert!(expanded.appearance.effects.is_empty()); + let (mut made, _) = red_rect(); + let r = made.execute("object.vectorHalftone", ¶ms).unwrap(); + let g = made.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).cloned().unwrap(); + assert!(!dots(&g).is_empty(), "{params}"); + assert_eq!(dots(&expanded), dots(&g), "{params}"); + // Ids of their own. + let ids: std::collections::HashSet<_> = expanded.children().unwrap().iter().map(|c| c.id).chain([id]).collect(); + assert_eq!(ids.len(), expanded.children().unwrap().len() + 1); + } +} + +#[test] +fn exports_write_the_dots_as_paths() { + let (mut s, _) = red_rect(); + let plain: Vec = ["pdf", "eps", "ai"].map(|f| exported(&mut s, f).len()).to_vec(); + s.execute("effect.apply", &json!({"effect": HALFTONE, "params": {"frequency": 18}})).unwrap(); + // The exporters' copy of the document has the dots in the object's place. + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + let mut paths = 0; + baked.walk(|n| { + assert!(n.appearance.effects.iter().all(|e| e.id != HALFTONE)); + paths += n.path_data().map_or(0, |p| p.subpaths.len()); + }); + assert!(paths > 100, "{paths} subpaths"); + // SVG: path data of a subpath per dot. + let moves = svg_moves(&exported(&mut s, "svg")); + assert!(moves > 100, "{moves} subpaths"); + // PDF, EPS and AI: the dots' paths make each file larger. + for (f, plain) in ["pdf", "eps", "ai"].into_iter().zip(plain) { + let with = exported(&mut s, f).len(); + assert!(with > plain + 1000, "{f}: {with} bytes, {plain} without the effect"); + } +} + +#[test] +fn a_halftone_past_the_caps_leaves_the_object_as_it_is() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 9000})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": HALFTONE, "params": {"frequency": 300}})).unwrap(); + // Drawn as it is, exported and expanded without dots and without a panic. + pixel(&s); + assert!(svg_moves(&exported(&mut s, "svg")) < 5); + let r = s.execute("effect.expandAppearance", &json!({})); + assert!(!matches!(r, Err(crate::EngineError::Internal { .. })), "{r:?}"); + let n = s.doc().unwrap().doc.node(id).cloned().unwrap(); + assert!(n.children().is_none_or(|c| c.len() < 5), "no dots"); +} + +/// Duotone, the live effect. +const DUOTONE: &str = "addon.color.duotone"; + +/// A document with spot swatch "Ink" and a selected grey (50 %) rectangle, unstroked. +fn grey_rect() -> (Session, NodeId) { + let (mut s, id) = red_rect(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0.0, "m": 0.6, "y": 1.0, "k": 0.0}, "spot": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": {"gray": 0.5}})).unwrap(); + (s, id) +} + +fn ink_tone(curve: &str) -> serde_json::Value { + json!({"tones": [{"color": {"model": "cmyk", "c": 0.0, "m": 0.6, "y": 1.0, "k": 0.0}, "swatch": "Ink", "curve": curve}]}) +} + +fn fill_paint(s: &Session, id: NodeId) -> vectorcraft_color::Paint { + s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().clone() +} + +#[test] +fn duotone_draws_a_tint_of_its_spot_tone_and_expands_to_it() { + let (mut s, id) = grey_rect(); + let grey = pixel(&s); + s.execute("effect.apply", &json!({"effect": DUOTONE, "params": ink_tone("0,0 100,100")})).unwrap(); + assert_eq!(fill_paint(&s, id).color().unwrap(), vectorcraft_color::Color::gray(0.5), "the colours stay"); + let drawn = pixel(&s); + assert!(drawn != grey && drawn[0] > drawn[2], "drawn in the ink: {drawn:?}"); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill_paint(&s, id) else { panic!("a solid fill") }; + assert_eq!(swatch.as_deref(), Some("Ink"), "linked to the spot swatch, so it separates on its plate"); + assert!((tint - 0.5).abs() < 0.01, "{tint}"); +} + +#[test] +fn duotone_as_a_filter_equals_the_live_effect_expanded() { + for params in [ + ink_tone("0,0 100,100"), + ink_tone("0,0 40,70 100,100"), + json!({"tones": [{"color": "#00ffff", "swatch": "Ink"}, {"color": {"model": "cmyk", "c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}, "curve": "0,0 100,40"}]}), + json!({"tones": [{"color": "#000000"}], "skipSpot": true}), + ] { + let (mut live, id) = grey_rect(); + live.execute("effect.apply", &json!({"effect": DUOTONE, "params": params})).unwrap(); + live.execute("effect.expandAppearance", &json!({})).unwrap(); + let (mut filtered, _) = grey_rect(); + let undo = filtered.doc().unwrap().history.undo.len(); + assert_eq!(filtered.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": params})).unwrap()["changed"], 1); + assert_eq!(filtered.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(fill_paint(&filtered, id), fill_paint(&live, id), "{params}"); + } + // The params round-trip: the effect keeps what it was given, and the filter reads the same. + let (mut s, id) = grey_rect(); + let params = ink_tone("0,0 40,70 100,100"); + s.execute("effect.apply", &json!({"effect": DUOTONE, "params": params})).unwrap(); + let kept = s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params.clone(); + assert_eq!(kept["tones"], params["tones"]); + s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": kept})).unwrap(); + let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill_paint(&s, id) else { panic!("a solid fill") }; + assert_eq!(swatch.as_deref(), Some("Ink")); + let fx = vectorcraft_render::effects::addons::duotone::tone_curve_points(Some(&json!("0,0 40,70 100,100"))); + let want = vectorcraft_render::effects::curve_at(&fx, 0.5); + assert!(want > 0.7 && (tint - want).abs() < 0.01, "the curve lifts the mid tone: {tint}, {want}"); +} + +#[test] +fn duotone_skips_spot_colours_when_asked() { + let (mut s, id) = grey_rect(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let spot = fill_paint(&s, id); + s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": {"tones": [{"color": "#000000"}], "skipSpot": true}})).unwrap(); + assert_eq!(fill_paint(&s, id), spot); + s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": {"tones": [{"color": "#000000"}]}})).unwrap(); + assert_ne!(fill_paint(&s, id), spot); +} diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs new file mode 100644 index 000000000..30548b80a --- /dev/null +++ b/crates/engine/src/cmd/addons/document.rs @@ -0,0 +1,230 @@ +//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of +//! the document named with the date and time through File › Save a Copy's save; New Symbols from +//! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a +//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins › +//! Document Tools) is the UI's. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration}; + +use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol}; +use super::super::fileio::{self, SaveMode}; +use super::super::{CommandSpec, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; +use crate::{Result, Session}; + +/// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken. +const MAX_NUMBER: u32 = 999; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.document.saveTimestampedCopy", + "Save a Timestamped Copy", + ["Object", "Plug-ins", "Document Tools"], + None, + "{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named _YYYY-MM-DD_HH-MM. (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}", + has_clock, + save_timestamped_copy + ), + cmd!( + "addon.document.exportAllOpen", + "Export All Open Documents", + ["Object", "Plug-ins", "Document Tools"], + None, + "{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (.; -2, -3… added when that name is taken, so no file is replaced), one self-contained file each with all of its art (images embedded or included, every artboard; no remembered save options apply): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}", + has_doc, + export_all_open + ), + cmd!( + "addon.document.symbolsFromLayers", + "New Symbols from Layers", + ["Object", "Plug-ins", "Document Tools"], + None, + "{layers?: [layer id…] (top-level layers; default: those highlighted in the Layers panel when two or more are, else every one), replace?: bool (false: the art stays; true: each layer's art is replaced by an instance of its symbol in the same place)} make one symbol of each layer's art as symbol.new makes one (registration in the centre), named after the layer (with a number added when a symbol has that name), as one undo step. Layers with no art are skipped, and with replace so are locked layers; with replace the instances are selected → {symbols: [name…], ids: [instance id…] (with replace), skipped: [{layer, name, reason: \"empty\"|\"locked\"}…]}", + has_doc, + symbols_from_layers + ), + ] +} + +/// A document is open and the time can be told. +fn has_clock(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + vectorcraft_doc::metadata::output_time().map(|_| ()).ok_or_else(|| "there is no clock here".into()) +} + +/// `.`, then `-2.`… in `folder`: the first that `taken` doesn't hold. +fn free_name(folder: &str, base: &str, ext: &str, taken: impl Fn(&str) -> bool) -> Option { + (1..=MAX_NUMBER) + .map(|i| { + let name = if i == 1 { format!("{base}.{ext}") } else { format!("{base}-{i}.{ext}") }; + std::path::Path::new(folder).join(name).to_string_lossy().into_owned() + }) + .find(|path| !taken(path)) +} + +fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.document.saveTimestampedCopy"; + let st = s.doc()?; + let format = match str_param(p, "format") { + Some(f) => fileio::save_format(Some(f), None), + // A document opened from a format Save doesn't write (a picture, a drawing) copies as native. + None => fileio::save_format(Some(st.format), None).or_else(|_| fileio::save_format(None, None)), + } + .map_err(|e| bad(C, e))?; + let folder = match str_param(p, "folder").filter(|f| !f.trim().is_empty()) { + Some(f) => f.to_string(), + None => st + .path + .as_deref() + .and_then(|path| std::path::Path::new(path).parent()) + .map(|f| f.to_string_lossy().into_owned()) + .filter(|f| !f.is_empty()) + .ok_or_else(|| bad(C, "the document has never been saved: give `folder`"))?, + }; + let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); + let given = date_param(p, "time", C)?; + let t = clock_date(s, "time", given).ok_or_else(|| bad(C, "there is no clock here: give `time` (Unix seconds)"))?; + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); + let base = format!("{stem}_{y:04}-{mo:02}-{d:02}_{h:02}-{mi:02}"); + // Only whether something is there: the write itself goes through the automation roots. + let path = free_name(&folder, &base, format.extensions[0], |p| std::path::Path::new(p).exists()) + .ok_or_else(|| bad(C, "every numbered name for the copy is taken"))?; + fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) +} + +/// The save options of every copy, given in full so that no document's remembered options (linked +/// SVG images, a PDF page range, separate artboard files) apply: one self-contained file per +/// document with all of its art. Each format reads only the options it has, and `range` is left +/// out of SVG, where it would pick artboards over `useArtboards`. +fn copy_options(format: &str) -> Value { + match format { + "svg" | "svgz" => json!({ "images": "embed", "useArtboards": false }), + "pdf" => json!({ "range": "all" }), + "eps" => json!({ "includeLinkedFiles": true, "useArtboards": false }), + "ai" | "ait" => json!({ "range": "all", "includeLinked": true, "separateArtboards": false }), + _ => json!({ "includeLinked": true, "separateArtboards": false }), + } +} + +fn export_all_open(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.document.exportAllOpen"; + let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?; + let folder = str_param(p, "folder").map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "give `folder`"))?.to_string(); + let ext = format.extensions.first().copied().ok_or_else(|| bad(C, "the format has no file extension"))?; + let (mut files, mut failed, mut written) = (vec![], vec![], Vec::::new()); + let active = s.active; + for i in 0..s.docs.len() { + // Each document in turn is the one Save a Copy writes; switching tabs this way leaves every + // document's tool and interaction alone, and the active one comes back below. + s.active = Some(i); + let Ok(st) = s.doc() else { continue }; + let title = st.doc.title.clone(); + let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title)); + // Names the copies already took count as taken, as do files on disk. + let Some(path) = free_name(&folder, &stem, ext, |p| written.iter().any(|w| w == p) || std::path::Path::new(p).exists()) else { + failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" })); + continue; + }; + let options = copy_options(format.id); + match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id, "options": options })) { + Ok(r) => { + written.push(path.clone()); + // Any other file the save wrote is taken for the copies still to come. + for key in ["files", "linked"] { + for f in r.get(key).and_then(Value::as_array).into_iter().flatten() { + if let Some(f) = f.as_str().or_else(|| f.get("path").and_then(Value::as_str)) { + written.push(f.to_string()); + } + } + } + files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) })); + } + Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })), + } + } + s.active = active; + Ok(json!({ "files": files, "failed": failed, "format": format.id })) +} + +/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds +/// no layers). +fn without_layers(n: &Node) -> Node { + let mut n = n.clone(); + if let NodeKind::Layer { children, clip, .. } = &n.kind { + n.kind = NodeKind::Group { children: children.clone(), clip: *clip }; + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { + *c = Arc::new(without_layers(c)); + } + } + n +} + +fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.document.symbolsFromLayers"; + let replace = switch_or(p, "replace", false, C)?; + let st = s.doc()?; + let d = &st.doc; + let layers: Vec = match p.get("layers").filter(|v| !v.is_null()) { + // The layers highlighted in the Layers panel when two or more are (one is mostly just the + // current layer), else every one. + None => { + let rows = st.highlighted_rows(); + let picked: Vec = d.layers.iter().map(|l| l.id).filter(|id| rows.contains(id)).collect(); + if picked.len() > 1 { picked } else { d.layers.iter().map(|l| l.id).collect() } + } + Some(_) => { + let ids = checked_ids_param(s, p, "layers", C)?; + if let Some(NodeId(id)) = ids.iter().find(|id| !d.layers.iter().any(|l| l.id == **id)) { + return Err(bad(C, format!("{id} is no top-level layer"))); + } + d.layers.iter().map(|l| l.id).filter(|id| ids.contains(id)).collect() + } + }; + // Each layer with art: its id and name, the art's roots, and the art ready for a symbol. + let (mut made, mut skipped) = (vec![], vec![]); + for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) { + let name = layer.display_name(); + let roots: Vec = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default(); + let art = match layer.children().map(Vec::as_slice).unwrap_or_default() { + [] => None, + [one] => Some(without_layers(one)), + many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), + }; + let reason = if replace && layer.locked { Some("locked") } else { None }; + let ready = art.and_then(|mut art| { + crate::cmd::threads::keep_shown_text(d, &mut art); + normalize(art).ok() + }); + match (reason, ready) { + (None, Some(ready)) => made.push((name, roots, ready)), + (reason, _) => skipped.push(json!({ "layer": layer.id.0, "name": name, "reason": reason.unwrap_or("empty") })), + } + } + if made.is_empty() { + return Ok(json!({ "symbols": [], "ids": [], "skipped": skipped })); + } + let (names, ids) = s.edit("New Symbols from Layers", |d, sel| { + let (mut names, mut ids) = (vec![], vec![]); + for (name, roots, (art, size, center)) in &made { + let name = d.unique_symbol_name(name); + store_symbol(d, art, &name, *size); + if replace { + ids.push(instance_in_place(d, roots, &name, place_xf(*center, *size, SymbolRegistration::CENTER))?); + } + names.push(name); + } + if replace { + sel.set(ids.iter().copied()); + } + Ok((names, ids)) + })?; + Ok(json!({ "symbols": names, "ids": ids.iter().map(|i| i.0).collect::>(), "skipped": skipped })) +} diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs new file mode 100644 index 000000000..562a389e8 --- /dev/null +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -0,0 +1,278 @@ +//! Document Tools' commands: the timestamped copy's name and place with the document left as it +//! was, symbols made of layers, and bad params refused. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// 2026-10-05 14:03:00 UTC. +const TIME: i64 = 1_791_208_980; + +/// A new, empty folder of its own for test `name`. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-addon-document-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s +} + +fn stamp(s: &mut Session, p: Value) -> crate::Result { + s.execute("addon.document.saveTimestampedCopy", &p) +} + +#[test] +fn a_timestamped_copy_goes_next_to_the_document_which_stays_as_it_was() { + let dir = folder("next-to"); + let mut s = session(); + let own = dir.join("poster.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let (steps, title) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.title.clone()); + let r = stamp(&mut s, json!({"time": TIME})).unwrap(); + let copy = dir.join("poster_2026-10-05_14-03.vectorcraft"); + assert_eq!(Path::new(r["path"].as_str().unwrap()), copy); + assert!(copy.is_file()); + let st = s.doc().unwrap(); + assert_eq!(st.path.as_deref().map(Path::new), Some(own.as_path()), "the document keeps its path"); + assert_eq!((st.history.undo.len(), &st.doc.title, st.is_dirty()), (steps, &title, true), "no step; still modified"); + // The copy holds the document as it is now: the ellipse too. + let mut other = Session::new(); + other.execute("document.open", &json!({"path": copy})).unwrap(); + assert_eq!(other.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // The same minute again: numbered. + let r = stamp(&mut s, json!({"time": TIME})).unwrap(); + assert!(r["path"].as_str().unwrap().ends_with("poster_2026-10-05_14-03-2.vectorcraft"), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_unsaved_document_needs_a_folder_and_other_formats_take_their_extension() { + let dir = folder("unsaved"); + let mut s = session(); + let before = s.doc().unwrap().doc.clone(); + assert!(stamp(&mut s, json!({"time": TIME})).is_err(), "no folder to put it in"); + let r = stamp(&mut s, json!({"time": TIME, "folder": dir, "format": "svg"})).unwrap(); + let name = Path::new(r["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned(); + assert!(name.ends_with("_2026-10-05_14-03.svg") && dir.join(&name).is_file(), "{name}"); + let st = s.doc().unwrap(); + assert!(st.path.is_none() && *st.doc == *before, "the document is unchanged"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_params_are_refused_and_writes_stay_inside_the_automation_roots() { + let dir = folder("bad"); + let mut s = session(); + for p in [ + json!({"folder": dir, "format": "png"}), + json!({"folder": dir, "format": "dxf"}), + json!({"folder": dir, "time": "now"}), + json!({"folder": dir, "time": 1.5}), + json!({"folder": dir.join("missing").join("deeper"), "time": TIME}), + ] { + assert!(stamp(&mut s, p.clone()).is_err(), "{p}"); + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written"); + let allowed = dir.join("allowed"); + std::fs::create_dir_all(&allowed).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&dir], &[&allowed]).unwrap(); + let outside = crate::file_access::confine(roots.as_ref(), || stamp(&mut s, json!({"folder": dir, "time": TIME}))); + assert!(outside.is_err(), "outside the write root"); + let inside = crate::file_access::confine(roots.as_ref(), || stamp(&mut s, json!({"folder": allowed, "time": TIME}))); + assert!(inside.is_ok(), "{inside:?}"); + assert!(!enabled_without_document(), "off with no document"); + let _ = std::fs::remove_dir_all(&dir); +} + +fn enabled_without_document() -> bool { + Session::new().commands().into_iter().find(|c| c.id == "addon.document.saveTimestampedCopy").unwrap().enabled +} + +fn layer_ids(s: &Session) -> Vec { + s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect() +} + +fn symbol_names(s: &Session) -> Vec { + s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect() +} + +/// A document of three layers: `Layer 1` with two rectangles, an empty one, and `Icons` with one +/// ellipse. +fn layered() -> Session { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("layer.new", &json!({})).unwrap(); + s.execute("layer.new", &json!({"name": "Icons"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 40, "height": 20})).unwrap(); + s +} + +#[test] +fn a_symbol_is_made_of_each_layer_and_empty_layers_are_reported() { + let mut s = layered(); + let before = symbol_names(&s).len(); + let n = s.doc().unwrap().history.undo.len(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step"); + let names: Vec = serde_json::from_value(r["symbols"].clone()).unwrap(); + assert_eq!(names.len(), 2, "{r}"); + assert!(names.contains(&"Icons".to_string())); + assert_eq!(symbol_names(&s).len(), before + 2); + assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "the empty layer: {r}"); + assert_eq!(r["skipped"][0]["reason"], json!("empty")); + assert_eq!(r["ids"], json!([]), "without replace the art stays"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // Again: the names are taken, so they get numbers. + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert!(r["symbols"].as_array().unwrap().contains(&json!("Icons 2")), "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(symbol_names(&s).len(), before); +} + +#[test] +fn replace_puts_an_instance_where_the_layers_art_was() { + let mut s = layered(); + let first = layer_ids(&s)[0]; + let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let layer = NodeId(first); + let art = bounds(&s, layer); + let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [first], "replace": true})).unwrap(); + let ids = r["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 1); + let inst = NodeId(ids[0].as_u64().unwrap()); + let st = s.doc().unwrap(); + let kids = st.doc.layers[0].children().unwrap(); + assert_eq!(kids.len(), 1); + assert!(matches!(&kids[0].kind, NodeKind::SymbolInstance { .. })); + assert_eq!(st.selection.objects, [inst]); + let now = bounds(&s, layer); + assert!((now.x0 - art.x0).abs() < 1e-6 && (now.y1 - art.y1).abs() < 1e-6 && (now.width() - art.width()).abs() < 1e-6, "{art:?} {now:?}"); + assert_eq!(s.doc().unwrap().doc.layers.len(), 3, "the other layers untouched"); +} + +#[test] +fn locked_layers_keep_their_art_and_bad_layers_are_refused() { + let mut s = layered(); + let ids = layer_ids(&s); + s.execute("layer.setProps", &json!({"id": ids[2], "locked": true})).unwrap(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({"replace": true})).unwrap(); + let skipped: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|k| k["reason"].as_str().unwrap()).collect(); + assert_eq!(skipped, ["empty", "locked"]); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0; + for p in [json!({"layers": [rect]}), json!({"layers": [u64::MAX]}), json!({"layers": "all"}), json!({"layers": [-1]}), json!({"replace": "yes"})] + { + assert!(s.execute("addon.document.symbolsFromLayers", &p).is_err(), "{p}"); + } + assert!(*s.doc().unwrap().doc == *before.0 && s.doc().unwrap().history.undo.len() == before.1); + // Only an empty layer: nothing made, no step. + let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [ids[1]]})).unwrap(); + assert_eq!((r["symbols"].clone(), s.doc().unwrap().history.undo.len()), (json!([]), before.1)); +} + +#[test] +fn the_layers_highlighted_in_the_layers_panel_are_the_default() { + let mut s = layered(); + let ids = layer_ids(&s); + // One highlighted row is just the current layer: every layer. + s.execute("layer.setCurrent", &json!({"id": ids[0]})).unwrap(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert_eq!(r["symbols"].as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("layer.highlight", &json!({"ids": [ids[1], ids[2]]})).unwrap(); + let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap(); + assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1)); +} + +/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be +/// written lists every document in `failed`, and either way the active document stays active. +#[test] +fn export_all_open_documents_writes_each_and_reports_failures() { + let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists()); + assert_eq!(s.active_index(), Some(0)); + assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved"); + let missing = dir.join("missing").join("deeper"); + let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap(); + assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}"); + assert_eq!(s.active_index(), Some(0)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An SVG document last saved with linked images exports as one SVG with its images embedded: no +/// image file is written beside the copies (#4920 review). +#[test] +fn export_all_open_documents_writes_one_file_each() { + let dir = std::env::temp_dir().join(format!("vc-export-all-one-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("out")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "name": "pic"})).unwrap(); + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("document.save", &json!({"path": dir.join("pic.svg"), "format": "svg", "options": {"images": "link"}})).unwrap(); + let saved: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().is_file()).collect(); + assert!(saved.len() > 1, "the linked save wrote its image beside it: {saved:?}"); + let out = dir.join("out"); + let r = s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap(); + let names: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["pic.svg"], "{r}"); + assert!(std::fs::read_to_string(out.join("pic.svg")).unwrap().contains("data:image/png;base64,"), "the image is embedded"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A PDF document last saved with a page range exports with every page, and an SVG document with +/// two artboards exports as one SVG holding the art of both (#4920 review). +#[test] +fn export_all_open_documents_keeps_every_page_and_artboard() { + let dir = std::env::temp_dir().join(format!("vc-export-all-pages-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("out")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "name": "pages"})).unwrap(); + s.execute("document.save", &json!({"path": dir.join("pages.pdf"), "format": "pdf", "options": {"range": "1"}})).unwrap(); + let pages = |b: &[u8]| { + String::from_utf8_lossy(b).matches("/Type /Page\n").count() + + String::from_utf8_lossy(b).matches("/Type/Page/").count() + + String::from_utf8_lossy(b).matches("/Type /Page/").count() + + String::from_utf8_lossy(b).matches("/Type /Page ").count() + }; + let first = pages(&std::fs::read(dir.join("pages.pdf")).unwrap()); + let out = dir.join("out"); + s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "pdf"})).unwrap(); + let copy = pages(&std::fs::read(out.join("pages.pdf")).unwrap()); + assert!(copy > first, "the copy has every page: {first} saved, {copy} exported"); + // SVG: one file with the art on both artboards. + let a = s.doc().unwrap().doc.artboards[1].rect; + s.execute("shape.rectangle", &json!({"x": a.x0 + 10.0, "y": a.y0 + 10.0, "width": 20, "height": 20})).unwrap(); + s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap(); + let svgs: Vec = + std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".svg")).collect(); + assert_eq!(svgs, ["pages.svg"]); + let svg = std::fs::read_to_string(out.join("pages.svg")).unwrap(); + // Not the first artboard alone (its 100 × 100 box at the origin): the art's own bounds. + let view = svg.split("viewBox=\"").nth(1).and_then(|v| v.split('"').next()).unwrap(); + let w: f64 = view.split_whitespace().nth(2).unwrap().parse().unwrap(); + assert!(w > 19.0 && w < 30.0, "the SVG shows the art (the square on the second artboard), not the first artboard: viewBox {view}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 4e78680ae..5d0e5723f 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -1,17 +1,24 @@ //! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke, //! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's -//! anchor edits, Split / Add Points at Crossings and the Parametric Shape Tool's shapes ([`shape`]). +//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners +//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's +//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes +//! into Paths. //! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's //! and the UI's. +mod corners; +#[cfg(test)] +mod effect_tests; mod shape; +mod shared_edge; #[cfg(test)] mod tests; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use vectorcraft_doc::{AnchorRef, Appearance, AppearanceItem, Document, LineCap, NodeId, NodeKind}; use vectorcraft_geom::{PathData, Point, SubPath}; use vectorcraft_pathops::addons::{self as po, pathtools as pt}; @@ -20,7 +27,6 @@ use super::super::pathops::Beside; use super::super::{ CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param, }; -use super::cleanup::PT_PER_TOLERANCE; use crate::{EngineError, Result, Session}; /// The Anchor Eraser Brush's tolerance by default (the scale Simplify Anchors (Keep Shape) uses). @@ -35,6 +41,8 @@ const MIN_RADIUS: f64 = 0.05; const MAX_DIAMETER: f64 = 2500.0; /// The farthest an end is extended at once (pt). const MAX_EXTEND: f64 = 1e6; +/// Most pieces Divide into Dashes and Expand Dashes into Paths make at once. +const MAX_PIECES: usize = 100_000; /// Most anchors Add Points puts on each segment. const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64; @@ -54,7 +62,7 @@ pub fn specs() -> Vec { "Extend Path", [], None, - "{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step → {id, length (pt)}", + "{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step; with Keep Shared Points Together on, anchors coincident with an end that moves go with it → {id, length (pt)}", has_doc, extend ), @@ -72,7 +80,7 @@ pub fn specs() -> Vec { "Set Anchor", [], None, - "{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set → {anchors}", + "{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set; with Keep Shared Points Together on, anchors coincident with one that moves go with it → {anchors}", has_selection, set_anchor ), @@ -139,8 +147,37 @@ pub fn specs() -> Vec { has_selection, crossings ), + cmd!( + "addon.pathtools.slideAnchor", + "Slide Anchor Along Path", + [], + None, + "{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide; with Keep Shared Points Together on, anchors coincident with it go with it → {id, strayed (pt)}", + has_doc, + slide_anchor + ), + cmd!( + "addon.pathtools.divideDashes", + "Divide into Dashes", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{dash?: pt 0.01..1000000 (10), gap?: pt 0..1000000 (0: the pieces touch), fit?: bool (false: exact lengths, the last piece of each run what is left; true: fit to corners and ends, every run between corners and ends holding whole dashes, all stretched or squeezed alike so it starts and ends on a dash), removeGaps?: bool (true: the gaps are left empty; false: they stay as pieces too)} cut the selected paths into separate open paths along the curve, each dash long with gap between them and keeping the path's appearance, as one undo step. Only the direct-selected segments (or those between two direct-selected anchors) are divided when there are any, the dash pattern running on across segments that follow one another; the rest of such a path stays, as pieces of its own. The pieces are selected; at most 100000 → {ids, dashes}", + has_selection, + divide_dashes + ), + cmd!( + "addon.pathtools.expandDashes", + "Expand Dashes into Paths", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{ids?: [id…] (default: the selection)} turn the dashed stroke of each path and compound path under the objects into separate open paths, one per dash, laid out as the stroke draws them (its dash pattern, offset and Align Dashes to Corners and Path Ends), each keeping the path's appearance with the dashes off, so the gaps are real gaps: a dash running all the way round a closed path stays closed, and with a round or projecting cap a dash of no length (a dot) becomes a path on its point. The top stroke's dashes are the ones expanded; its arrowheads go, and the path's fills stay on it, below its dashes. Paths without a dashed stroke stay as they are. One undo step; the dashes are selected; at most 100000 → {ids, dashes}", + has_selection, + expand_dashes + ), ]; v.extend(shape::specs()); + v.extend(corners::specs()); + v.extend(shared_edge::specs()); v } @@ -244,7 +281,7 @@ fn erase(s: &mut Session, p: &Value) -> Result { let ids = paths_under(d, &roots); let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); check_size(paths.iter().map(|(_, p)| *p), C)?; - let tol = if ignore { f64::INFINITY } else { tolerance * PT_PER_TOLERANCE }; + let tol = if ignore { f64::INFINITY } else { tolerance * po::PT_PER_TOLERANCE }; let mut removed = 0; let mut changed = vec![]; for (id, path) in paths { @@ -288,8 +325,8 @@ fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) -> // ---------- Extend Path ---------- -/// The open subpath an `extend` request names: (path id, subpath index, its data). -fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> { +/// The subpath a request names: (path id, subpath index, its data). +fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> { let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?; let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0); let d = &s.doc()?.doc; @@ -298,15 +335,36 @@ fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Sub } let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?; let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?; - if sp.closed || sp.anchors.len() < 2 { - return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)")); - } Ok((id, si, sp.clone())) } +/// Put `new` in place of the subpath `old` (from [`named_subpath`]) as one step labelled `label` +/// (none when it is the same), with what Keep Shared Points Together moves too; the path's direct +/// selection goes. +fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> { + if new == old { + return Ok(()); + } + let mut np = s.doc()?.doc.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?; + if let Some(slot) = np.subpaths.get_mut(si) { + *slot = new; + } + let shared = shared_edge::shared_points(s, [(id, &np)])?; + s.edit(label, |d, sel| { + sel.anchors.remove(&id); + sel.segments.remove(&id); + set_path(d, id, np); + shared_edge::move_shared(d, shared); + Ok(()) + }) +} + fn extend(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.extend"; - let (id, si, sp) = open_subpath(s, p, C)?; + let (id, si, sp) = named_subpath(s, p, C)?; + if sp.closed || sp.anchors.len() < 2 { + return Err(bad(C, "the subpath has no ends to extend (it is closed or a single point)")); + } let at_start = match text_param(p, "end", C)? { Some("start") => true, Some("end") => false, @@ -328,22 +386,26 @@ fn extend(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`length` must be a number of at most 1000000 pt either way")); } let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?; - if new != sp { - s.edit("Extend Path", |d, sel| { - let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?; - let mut np = n; - if let Some(slot) = np.subpaths.get_mut(si) { - *slot = new; - } - sel.anchors.remove(&id); - sel.segments.remove(&id); - set_path(d, id, np); - Ok(()) - })?; - } + set_subpath(s, "Extend Path", (id, si, sp), new)?; Ok(json!({ "id": id.0, "length": length })) } +// ---------- Slide Anchor Along Path ---------- + +fn slide_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.slideAnchor"; + let (id, si, sp) = named_subpath(s, p, C)?; + let anchor = usize_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "give `anchor`, the index of the anchor to slide"))?; + let t = number_or(p, "t", f64::NAN, C)?; + if t.is_nan() || t.abs() > pt::MAX_SLIDE { + return Err(bad(C, "`t` must be a number from -0.98 to 0.98")); + } + let (new, strayed) = + pt::slide_anchor(&sp, anchor, t).ok_or_else(|| bad(C, "that anchor can't slide: an end of an open path has nowhere to go"))?; + set_subpath(s, "Slide Anchor Along Path", (id, si, sp), new)?; + Ok(json!({ "id": id.0, "strayed": strayed })) +} + // ---------- Measure & Inspect ---------- fn polar(h: Option<(f64, f64)>) -> Value { @@ -355,7 +417,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result { let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?; let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?); let tolerance = number_or(p, "tolerance", 3.0, C)?; - if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) { + if !(x.is_finite() && y.is_finite() && tolerance >= 0.0 && tolerance.is_finite()) { return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)")); } let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?; @@ -423,10 +485,12 @@ fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath } } if !changed.is_empty() { + let shared = shared_edge::shared_points(s, changed.iter().map(|(id, np)| (*id, np)))?; s.edit(label, |d, _| { for (id, np) in changed { set_path(d, id, np); } + shared_edge::move_shared(d, shared); Ok(()) })?; } @@ -540,6 +604,16 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { Ok(json!({ "removed": removed })) } +/// The segments of subpath `si` (`sp`) picked: the direct-selected segments `segments` of its +/// path, else those between two of its selected `anchors`. +fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet) -> BTreeSet { + let n = sp.anchors.len().max(1); + match segments.filter(|set| !set.is_empty()) { + Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(), + } +} + fn add_points(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.addPoints"; let count = number_or(p, "count", 1.0, C)?; @@ -554,16 +628,10 @@ fn add_points(s: &mut Session, p: &Value) -> Result { let mut changed = vec![]; for (id, by_sub) in &picked { let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue }; - let picked_segments = st.selection.segments.get(id); let mut np = path.clone(); for (si, sp) in np.subpaths.iter_mut().enumerate() { let Some(anchors) = by_sub.get(&si) else { continue }; - let n = sp.anchors.len(); - // Picked segments, else those between two selected anchors. - let segs: BTreeSet = match picked_segments { - Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), - None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n.max(1)))).collect(), - }; + let segs = picked_segments(st.selection.segments.get(id), si, sp, anchors); let (out, marks) = pt::add_points(sp, Some(&segs), count as usize); added += marks.len(); *sp = out; @@ -678,3 +746,157 @@ fn crossings(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>(), "crossings": count })) } + +// ---------- Divide into Dashes ---------- + +fn divide_dashes(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.divideDashes"; + let spec = pt::DashSpec { + dash: given(p, "dash", Some((0.01, 1e6)), C)?.unwrap_or(10.0), + gap: given(p, "gap", Some((0.0, 1e6)), C)?.unwrap_or(0.0), + fit: switch_or(p, "fit", false, C)?, + }; + let remove_gaps = switch_or(p, "removeGaps", true, C)?; + let picked = picked_anchors(s, true)?; + let st = s.doc()?; + let d = &st.doc; + check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?; + let (mut made, mut dashes) = (0, 0); + // Each path divided: what stays of it (the subpaths not divided, as one path) and its pieces. + let mut changed: Vec<(NodeId, Vec)> = vec![]; + for (id, by_sub) in &picked { + let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue }; + let (mut kept, mut pieces) = (vec![], vec![]); + for (si, sp) in path.subpaths.iter().enumerate() { + let segs = by_sub.get(&si).map(|anchors| picked_segments(st.selection.segments.get(id), si, sp, anchors)).unwrap_or_default(); + if segs.is_empty() { + kept.push(sp.clone()); + continue; + } + let only = (segs.len() < sp.segment_count()).then_some(&segs); + let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)) + .ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?; + made += out.len(); + for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) { + dashes += usize::from(kind == pt::Piece::Dash); + pieces.push(PathData::single(piece)); + } + } + if !pieces.is_empty() { + changed.push((*id, (!kept.is_empty()).then(|| PathData::new(kept)).into_iter().chain(pieces).collect())); + } + } + if changed.is_empty() { + return Err(bad(C, "select paths, or segments, to divide")); + } + let ids = s.edit("Divide into Dashes", |d, sel| { + let mut all = vec![]; + let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in changed { + all.extend(replace_with_pieces(d, &mut beside, id, pieces)?); + } + beside.finish(d)?; + // The pieces join the objects selected (whole, in one go: there may be many). + let before = std::mem::take(&mut sel.objects); + sel.set(before.into_iter().chain(all.iter().copied())); + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "dashes": dashes })) +} + +// ---------- Expand Dashes into Paths ---------- + +/// The paths and compound paths under `roots` whose top stroke is dashed: a compound path's +/// members stand for it, which holds their paint. Clipping paths are left alone. +fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec { + let editable = d.editable(); + let mut seen = BTreeSet::new(); + let mut out = vec![]; + for id in paths_under(d, roots) { + let compound = d.parent_of(id).and_then(|p| d.node(p)).filter(|p| matches!(p.kind, NodeKind::Compound { .. })); + let Some(n) = compound.or_else(|| d.node(id)) else { continue }; + let dashed = n.appearance.stroke().and_then(|st| st.dash.as_ref()).is_some_and(|dash| dash.is_dashed()); + if dashed && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) && editable(n.id) && seen.insert(n.id) { + out.push(n.id); + } + } + out +} + +fn expand_dashes(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.expandDashes"; + let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer"); + let roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + // Each dashed path and its dashes, as the canvas draws them. + let mut changed: Vec<(NodeId, Vec)> = vec![]; + let mut count = 0; + for id in dashed_paths(d, &roots) { + let Some(n) = d.node(id) else { continue }; + let (Some(st), Some(bp)) = (n.appearance.stroke(), n.stroke_path()) else { continue }; + let Some(dash) = &st.dash else { continue }; + // The line the stroke draws: the path, its ends trimmed under any arrowheads. + let line = vectorcraft_render::effects::stroke::stroke_pieces(&bp, st).line; + // A pattern too fine to draw is drawn solid: it has too many dashes to expand. + let dashed = vectorcraft_render::effects::stroke::dash(&line, dash).ok_or_else(too_many)?; + let mut pieces = PathData::from_bezpath(&dashed.path).subpaths; + // A dot paints its cap; a path that stays on its point is drawn so too. Under a butt cap it + // paints nothing. + if st.cap != LineCap::Butt { + pieces.extend(dashed.dots.iter().map(|dot| SubPath::polyline(&[dot.at, dot.at], false))); + } + count += pieces.len(); + if count > MAX_PIECES { + return Err(too_many()); + } + if !pieces.is_empty() { + changed.push((id, pieces)); + } + } + if changed.is_empty() { + return Err(bad(C, "select paths with dashed strokes to expand")); + } + let ids = s.edit("Expand Dashes into Paths", |d, sel| { + let mut all = vec![]; + let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in changed { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &n.kind else { continue }; + let rule = *rule; + let filled = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none()); + // The dashes: open paths with the strokes (the top one's dashes and arrowheads off). + let mut look = Appearance::clone(&n.appearance); + look.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); + if let Some(st) = look.stroke_mut() { + (st.dash, st.start_arrow, st.end_arrow) = (None, None, None); + } + let mut dash = n.clone(); + dash.kind = NodeKind::Path { path: PathData::default(), rule, live: None, clipping: false, guide: false }; + dash.appearance = look.into(); + // The fills stay on the path, below its dashes; without fills it goes. + let mut parts = vec![]; + if n.appearance.items.iter().any(filled) { + let mut fill = n.clone(); + let mut look = Appearance::clone(&n.appearance); + look.items.retain(filled); + fill.appearance = look.into(); + parts.push(fill); + } + for sp in pieces { + let mut piece = d.reid(&dash); + if let NodeKind::Path { path, .. } = &mut piece.kind { + *path = PathData::single(sp); + } + all.push(piece.id); + parts.push(piece); + } + beside.put(d, id, parts)?; + } + beside.finish(d)?; + // The dashes join the objects selected (whole, in one go: there may be many). + let before = std::mem::take(&mut sel.objects); + sel.set(before.into_iter().filter(|id| d.node(*id).is_some()).chain(all.iter().copied())); + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "dashes": ids.len() })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/corners.rs b/crates/engine/src/cmd/addons/pathtools/corners.rs new file mode 100644 index 000000000..464fa032b --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/corners.rs @@ -0,0 +1,144 @@ +//! The Corner Tool's commands: the app's Live Corners ([`LiveCorners`]) cut, resized and removed on +//! chosen anchors, each edit one undo step. + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveCorners, NodeId, NodeKind}; +use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_tools::addons::corner_tool::MAX_RADIUS; + +use super::super::super::object::recut; +use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, number_or}; +use super::{check_size, paths_under}; +use crate::{Result, Session}; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.cornerSet", + "Set Corners", + [], + None, + "{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}", + has_doc, + corner_set + ), + cmd!( + "addon.pathtools.cornerRemove", + "Remove Corners", + [], + None, + "{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors)} make those live corners sharp again, as one undo step → {ids, corners (how many)}", + has_doc, + corner_remove + ), + ] +} + +/// The `anchors` of `p`. +fn anchors_param(p: &Value, cmd: &str) -> Result>> { + let Some(v) = p.get("anchors").filter(|v| !v.is_null()) else { return Ok(None) }; + let list = v.as_array().ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {v}")))?; + list.iter() + .map(|k| { + k.as_u64() + .and_then(|k| usize::try_from(k).ok()) + .ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {k}"))) + }) + .collect::>() + .map(Some) +} + +/// The corners a request names: each path with the corners (anchor indices of its uncut outline). +fn named_corners(s: &Session, p: &Value, cmd: &str) -> Result)>> { + let shared = anchors_param(p, cmd)?; + // Each path with its own `anchors`, or sharing the request's. + let mut own: Vec>> = vec![]; + let ids: Vec = match p.get("items").filter(|v| !v.is_null()) { + None => paths_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, cmd)?), + Some(Value::Array(items)) => { + let mut ids = vec![]; + for it in items { + ids.push(checked_id(s, it.get("id").unwrap_or(&Value::Null), cmd)?); + own.push(anchors_param(it, cmd)?); + } + ids + } + Some(v) => return Err(bad(cmd, format!("`items` must be a list of {{id, anchors?}}, not {v}"))), + }; + let st = s.doc()?; + let d = &st.doc; + check_size(ids.iter().filter_map(|id| d.node(*id)?.path_data()), cmd)?; + let editable = d.editable(); + let mut out = vec![]; + for (i, id) in ids.iter().enumerate() { + let Some(lc) = d.node(*id).filter(|_| editable(*id)).and_then(LiveCorners::of) else { continue }; + let corners = match own.get(i).and_then(Option::as_ref).or(shared.as_ref()) { + Some(given) => { + let n = lc.base.anchor_count(); + if let Some(past) = given.iter().find(|k| **k >= n) { + return Err(bad(cmd, format!("object {id} has {n} anchors, so no anchor {past}"))); + } + given.iter().copied().filter(|k| lc.corner(*k).is_some()).collect() + } + None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)), + }; + if !corners.is_empty() { + out.push((*id, corners)); + } + } + if out.is_empty() { + return Err(bad(cmd, "choose anchors where two sides meet at an angle: only those take a corner")); + } + Ok(out) +} + +/// Cut `radius` and `kind` on `corners` as one step (none when they have them already). +fn edit_corners(s: &mut Session, label: &str, corners: Vec<(NodeId, BTreeSet)>, radius: f64, kind: Option) -> Result { + let d = &s.doc()?.doc; + let same = corners.iter().all(|(id, picked)| { + d.node(*id) + .and_then(LiveCorners::of) + .is_some_and(|c| picked.iter().all(|k| (c.drawn_radius(*k) - radius).abs() < 1e-9 && kind.is_none_or(|kd| c.kind(*k) == kd))) + }); + let out = json!({ + "ids": corners.iter().map(|(id, _)| id.0).collect::>(), + "corners": corners.iter().map(|(_, c)| c.len()).sum::(), + }); + if !same { + s.edit(label, |d, sel| { + for (id, picked) in corners { + let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + // A cut changes the anchors, which is all a segment selection goes by. + sel.segments.remove(&id); + recut(path, live, sel.anchors.get_mut(&id), &picked, Some(radius), kind); + } + Ok(()) + })?; + } + Ok(out) +} + +fn corner_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.cornerSet"; + let radius = number_or(p, "radius", f64::NAN, C)?; + if !(0.0..=MAX_RADIUS).contains(&radius) { + return Err(bad(C, "`radius` must be from 0 to 1000000 pt")); + } + let kind = p + .get("kind") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) + }) + .transpose()?; + let corners = named_corners(s, p, C)?; + edit_corners(s, "Set Corners", corners, radius, kind) +} + +fn corner_remove(s: &mut Session, p: &Value) -> Result { + let corners = named_corners(s, p, "addon.pathtools.cornerRemove")?; + edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round)) +} diff --git a/crates/engine/src/cmd/addons/pathtools/effect_tests.rs b/crates/engine/src/cmd/addons/pathtools/effect_tests.rs new file mode 100644 index 000000000..3465b7279 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/effect_tests.rs @@ -0,0 +1,186 @@ +//! Path Effects (`addon.path.*`) through the session: live on the canvas, the document's path left +//! alone, and Expand Appearance and a save keeping what they draw. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Effect, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; +use vectorcraft_render::Renderer; +use vectorcraft_render::effects::{GeomContext, apply_geometry_bez, effect_catalog, new_effect, preview, scale_effect}; + +use crate::Session; + +/// A black 4 pt stroke along `anchors`, unfilled. +fn stroked(anchors: Value, closed: bool) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.select(|_, sel| sel.set([id])).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + (s, id) +} + +fn wave() -> (Session, NodeId) { + let anchors = json!([ + {"x": 20, "y": 100}, + {"x": 60, "y": 60, "in": [40, 60], "out": [80, 60]}, + {"x": 100, "y": 100}, + {"x": 140, "y": 140}, + {"x": 180, "y": 100} + ]); + stroked(anchors, false) +} + +/// Every Path Effect, with parameters that change the wave. +fn effects() -> Vec<(&'static str, Value)> { + vec![ + ("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "dash2": 3, "gap2": 6})), + ("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "alignCorners": true})), + ("addon.path.addAnchors", json!({"count": 3})), + ("addon.path.addAnchors", json!({"spacing": "distance", "distance": 15, "target": "curved"})), + ("addon.path.extend", json!({"startLength": 10, "endPercent": 10, "endShape": "arc"})), + ("addon.path.extend", json!({"startLength": -10, "endLength": -20})), + ("addon.path.close", json!({})), + ("addon.path.close", json!({"mode": "smooth"})), + ("addon.path.smooth", json!({"ratio": 0.3})), + ("addon.path.retract", json!({})), + ("addon.path.reverse", json!({})), + ("addon.path.simplify", json!({"tolerance": 200, "protectCorners": false})), + ("addon.path.split", json!({"anchors": "both"})), + ] +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn picture_of(d: &Document) -> Vec { + let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + (0..200).flat_map(|y| (0..200).map(move |x| (x, y))).flat_map(|(x, y)| r.pixel(x, y)).collect() +} + +fn picture(s: &Session) -> Vec { + picture_of(&s.doc().unwrap().doc) +} + +/// The outline of `n`: its path, or those of the compound path it became. +fn outline(n: &Node) -> BezPath { + let mut out = n.path_data().map(|p| p.to_bezpath()).unwrap_or_default(); + n.children().into_iter().flatten().for_each(|c| out.extend(outline(c))); + out +} + +/// How many bytes of two pictures differ by more than antialiasing noise. +fn differing(a: &[u8], b: &[u8]) -> usize { + a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 8).count() +} + +#[test] +fn a_path_effect_draws_live_and_leaves_the_document_path() { + let (mut s, id) = stroked(json!([{"x": 10, "y": 50}, {"x": 190, "y": 50}]), false); + let solid = picture(&s); + let path = node(&s, id).path_data().cloned(); + s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 20, "gap1": 20}})).unwrap(); + assert_eq!(node(&s, id).path_data().cloned(), path, "only the effect holds the geometry"); + let dashed = picture(&s); + let at = |pic: &[u8], x: usize| pic[(50 * 200 + x) * 4]; + assert!(at(&solid, 45) < 40 && at(&dashed, 45) > 200, "a gap: {}", at(&dashed, 45)); + assert!(at(&dashed, 15) < 40, "a dash"); + // Hiding it draws the path again. + s.execute("effect.setParams", &json!({"index": 0, "visible": false})).unwrap(); + assert_eq!(picture(&s), solid); +} + +#[test] +fn a_path_effect_moves_the_bounds_the_object_is_measured_by() { + let (mut s, id) = stroked(json!([{"x": 50, "y": 50}, {"x": 150, "y": 50}]), false); + s.execute("effect.apply", &json!({"effect": "addon.path.extend", "params": {"startLength": 0, "endLength": 30}})).unwrap(); + let drawn = preview::bounds_of(&s.doc().unwrap().doc, &[id], true).unwrap(); + assert!((drawn.x1 - 180.0).abs() < 3.0, "{drawn:?}"); + let pic = picture(&s); + assert!(pic[(50 * 200 + 170) * 4] < 40, "and draws there"); +} + +#[test] +fn expand_appearance_writes_the_outline_the_effect_draws() { + for (effect, params) in effects() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + let n = node(&s, id); + let before = n.path_data().cloned().unwrap(); + let live = apply_geometry_bez(&n.appearance.effects, &before.to_bezpath(), before.bounds().unwrap(), &GeomContext::of(&n)); + let pic = picture(&s); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let expanded = node(&s, id); + assert!(expanded.appearance.effects.is_empty(), "{effect} {params}"); + let (out, want) = (outline(&expanded), live); + assert_eq!(out.elements().len(), want.elements().len(), "{effect} {params}"); + for (a, b) in out.elements().iter().zip(want.elements()) { + assert!(a.is_finite() && format!("{a:.4?}") == format!("{b:.4?}"), "{effect} {params}: {a:?} {b:?}"); + } + assert_eq!(differing(&pic, &picture(&s)), 0, "{effect} {params}"); + } +} + +#[test] +fn path_effects_round_trip_through_a_save_and_scale_their_distances() { + for (effect, params) in effects() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects, node(&s, id).appearance.effects, "{effect} {params}"); + assert_eq!(picture_of(&restored), picture(&s), "{effect} {params}"); + } + let mut e = new_effect("addon.path.dashes", &json!({"dash1": 10})).unwrap(); + scale_effect(&mut e, 2.0); + assert_eq!((e.params["dash1"].as_f64(), e.params["gap1"].as_f64()), (Some(20.0), Some(12.0))); +} + +#[test] +fn undo_takes_a_path_effect_off_in_one_step() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": "addon.path.reverse"})).unwrap(); + assert_eq!(node(&s, id).appearance.effects.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); +} + +/// A file's effects skip the checks `effect.apply` makes: whatever they hold, the object draws and +/// expands. +#[test] +fn path_effects_with_junk_params_from_a_file_draw_and_expand() { + let junk = [ + json!(null), + json!("x"), + json!({"dash1": 1e308, "gap1": -1e308, "offset": "x", "alignCorners": 3}), + json!({"count": 1e308, "maxCount": 1e308, "distance": -1e308, "spacing": "distance", "target": []}), + json!({"startLength": 1e308, "endPercent": -1e308, "endShape": {}, "mode": 1, "ratio": "x", "handles": "shorter", "length": 1e308}), + json!({"tolerance": 1e308, "protectCorners": "no", "anchors": null, "startPercent": 1e308, "startShape": "arc"}), + ]; + let ids = effect_catalog().into_iter().map(|e| e.id).filter(|id| id.starts_with("addon.path.")); + let stack: Vec = + ids.flat_map(|id| junk.iter().map(move |params| Effect { id: id.into(), params: params.clone(), visible: true })).collect(); + let (mut s, id) = wave(); + s.edit("Junk", |d, _| { + if let Some(n) = d.node_mut(id) { + n.appearance.effects = stack; + } + Ok(()) + }) + .unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + picture_of(&vectorcraft_format::load(&saved).unwrap()); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); +} + +#[test] +fn exports_write_the_dashes_as_paths() { + let (mut s, _) = wave(); + s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 10, "gap1": 10}})).unwrap(); + let v = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let moves = svg.iter().filter(|c| **c == b'M').count(); + assert!(moves > 5, "the exported art holds the dashes: {moves}"); +} diff --git a/crates/engine/src/cmd/addons/pathtools/shared_edge.rs b/crates/engine/src/cmd/addons/pathtools/shared_edge.rs new file mode 100644 index 000000000..e5d61c47b --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shared_edge.rs @@ -0,0 +1,164 @@ +//! The Shared Edge Merge Tool's command and Keep Shared Points Together: with that option on, the +//! add-on's own commands that move an anchor (Slide Anchor Along Path, Extend Path, Set Anchor) take +//! the anchors coincident with it along. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point, Vec2}; +use vectorcraft_pathops::addons::pathtools as pt; +use vectorcraft_tools::addons::shared_edge::{DIAMETER, DIAMETER_RANGE, ID}; + +use super::super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, number_or, switch_or, text_param}; +use super::{edit_whole, set_path}; +use crate::{Result, Session}; + +#[cfg(test)] +mod tests; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.mergeEdge", + "Merge Shared Edge", + [], + None, + "{a, b: two path ids (they needn't be selected), at: [x, y] (where), diameter?: pt 0.25..200 (20; how near `at` the edge is looked for), tolerance?: pt 0..3 (0.002; how near two anchors count as coincident), follow?: \"average\" (half way between the two)|\"a\"|\"b\" (keep that path's outline), ends?: bool (false: true makes an end even where the paths could merge)} where the paths touch at two coincident anchors, one each side of `at`, replace the stretches between them with one shared edge; stretches much longer one than the other (by over 10%) or leaving an end at over 120° aren't merged. Otherwise make an end there: two near anchors meet, and an anchor only one path has there is matched by a new one on the other. Each path stays closed or open; one undo step → {a, b, made: \"edge\"|\"end\"|\"nothing\" (merged already)}", + has_doc, + merge_edge + ), + cmd!( + "addon.pathtools.movePointsTogether", + "Keep Shared Points Together", + [], + None, + "{enabled?: bool (default: the opposite of now)} Keep Shared Points Together: whether anchors coincident (within the Shared Edge Merge Tool's `tolerance`) with one that Slide Anchor Along Path, Extend Path or Set Anchor moves move with it, on any editable path (the `together` option of tool `sharedEdge`, kept with the preferences; off by default) → {enabled}", + always, + move_points_together + ), + ] +} + +/// A path a merge may edit. +fn merge_path<'a>(d: &'a Document, id: NodeId, cmd: &str) -> Result<&'a PathData> { + if !d.is_editable(id) { + return Err(bad(cmd, "the path is locked or hidden")); + } + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, guide: false, .. }) => Ok(path), + _ => Err(bad(cmd, "`a` and `b` must be two different paths")), + } +} + +fn merge_edge(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.mergeEdge"; + let a = checked_id(s, p.get("a").unwrap_or(&Value::Null), C)?; + let b = checked_id(s, p.get("b").unwrap_or(&Value::Null), C)?; + if a == b { + return Err(bad(C, "`a` and `b` must be two different paths")); + } + let [x, y] = p.get("at").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + let at = Point::new(x, y); + let diameter = number_or(p, "diameter", DIAMETER, C)?; + if !(DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(&diameter) { + return Err(bad(C, "`diameter` must be from 0.25 to 200 pt")); + } + let tolerance = number_or(p, "tolerance", pt::COINCIDENCE, C)?; + if !(0.0..=pt::MAX_COINCIDENCE).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 0 to 3 pt")); + } + let follow = match text_param(p, "follow", C)? { + None => pt::Follow::Average, + Some(f) => pt::Follow::parse(f).ok_or_else(|| bad(C, "`follow` must be \"average\", \"a\" or \"b\""))?, + }; + let ends = switch_or(p, "ends", false, C)?; + let d = &s.doc()?.doc; + let (pa, pb) = (merge_path(d, a, C)?, merge_path(d, b, C)?); + let radius = diameter / 2.0; + let edge = if ends { None } else { pt::stretches_at(pa, pb, at, radius, tolerance) }; + let plan = edge.or_else(|| pt::ends_at(pa, pb, at, radius)).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?; + let (na, nb) = pt::apply_merge(pa, pb, &plan, follow).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?; + let changed: Vec<(NodeId, PathData)> = + [(a, pa, na), (b, pb, nb)].into_iter().filter(|(_, old, new)| *old != new).map(|(id, _, new)| (id, new)).collect(); + let made = match plan { + _ if changed.is_empty() => "nothing", + pt::MergePlan::Edge { .. } => "edge", + pt::MergePlan::Ends { .. } => "end", + }; + edit_whole(s, "Merge Shared Edge", changed)?; + Ok(json!({ "a": a.0, "b": b.0, "made": made })) +} + +fn move_points_together(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.movePointsTogether"; + let now = s.tool_options_of(ID)["together"].as_bool().unwrap_or(false); + let enabled = switch_or(p, "enabled", !now, C)?; + s.set_tool_options(Some(ID), &Map::from_iter([("together".to_string(), json!(enabled))])); + Ok(json!({ "enabled": enabled })) +} + +/// Anchors to move along with others: each with how far. +pub(super) type SharedPoints = Vec<(NodeId, AnchorRef, Vec2)>; + +/// With Keep Shared Points Together on, the anchors of editable paths coincident with an anchor +/// that `changed` (paths in their new form) moves, and not moving themselves. Subpaths whose +/// anchors `changed` adds or removes move none: their anchors stay put. +pub(super) fn shared_points<'a>(s: &Session, changed: impl IntoIterator) -> Result { + let options = s.tool_options_of(ID); + if options["together"].as_bool() != Some(true) { + return Ok(vec![]); + } + let tol = options["tolerance"].as_f64().unwrap_or(pt::COINCIDENCE); + let d = &s.doc()?.doc; + let mut moved: BTreeSet<(NodeId, AnchorRef)> = BTreeSet::new(); + let mut from: Vec<(Point, Vec2)> = vec![]; + for (id, new) in changed { + let Some(old) = d.node(id).and_then(|n| n.path_data()) else { continue }; + for (si, (o, n)) in old.subpaths.iter().zip(&new.subpaths).enumerate().filter(|(_, (o, n))| o.anchors.len() == n.anchors.len()) { + for (ai, (x, y)) in o.anchors.iter().zip(&n.anchors).enumerate().filter(|(_, (x, y))| x.p != y.p) { + moved.insert((id, (si, ai))); + from.push((x.p, y.p - x.p)); + } + } + } + let mut out = vec![]; + if from.is_empty() { + return Ok(out); + } + from.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + let editable = d.editable(); + d.walk(|n| { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return }; + if !editable(n.id) { + return; + } + for (si, ai, a) in path.anchors() { + let start = from.partition_point(|(p, _)| p.x < a.p.x - tol); + let mut near = from.iter().skip(start).take_while(|(p, _)| p.x <= a.p.x + tol); + if let Some((_, by)) = near.find(|(p, _)| p.distance(a.p) <= tol) + && !moved.contains(&(n.id, (si, ai))) + { + out.push((n.id, (si, ai), *by)); + } + } + }); + Ok(out) +} + +/// Move the anchors [`shared_points`] found; what they are on stops being a live shape. +pub(super) fn move_shared(d: &mut Document, shared: SharedPoints) { + let mut by_path: BTreeMap> = BTreeMap::new(); + for (id, at, by) in shared { + by_path.entry(id).or_default().push((at, by)); + } + for (id, anchors) in by_path { + let Some(mut np) = d.node(id).and_then(|n| n.path_data()).cloned() else { continue }; + for ((si, ai), by) in anchors { + if let Some(a) = np.anchor_mut(si, ai) { + a.translate(by); + } + } + set_path(d, id, np); + } +} diff --git a/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs b/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs new file mode 100644 index 000000000..3ffe2fea7 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs @@ -0,0 +1,204 @@ +//! The Shared Edge Merge Tool's command and Keep Shared Points Together: one undo step each, bad +//! params refused, the tool's click through the session, and only the add-on's commands affected. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{PathData, Point}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::{Session, ViewInfo}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn points(s: &Session, id: NodeId) -> Vec { + path(s, id).anchors().map(|(_, _, a)| a.p).collect() +} + +/// Two closed shapes with a bent edge each between (100, 100) and (200, 100). +fn shapes(s: &mut Session) -> (NodeId, NodeId) { + let a = line(s, &[(100.0, 100.0), (150.0, 104.0), (200.0, 100.0), (200.0, 50.0), (100.0, 50.0)], true); + let b = line(s, &[(200.0, 100.0), (150.0, 96.0), (100.0, 100.0), (100.0, 150.0), (200.0, 150.0)], true); + (a, b) +} + +#[test] +fn merge_edge_replaces_both_stretches_as_one_undo_step() { + let mut s = session(); + let (a, b) = shapes(&mut s); + s.execute("select.none", &json!({})).unwrap(); + let (pa, pb) = (path(&s, a), path(&s, b)); + let n = steps(&s); + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap(); + assert_eq!(r, json!({"a": a.0, "b": b.0, "made": "edge"})); + assert_eq!(steps(&s), n + 1); + assert!(path(&s, a).subpaths[0].closed && path(&s, b).subpaths[0].closed); + assert_eq!(points(&s, a)[..3], [Point::new(100.0, 100.0), Point::new(150.0, 100.0), Point::new(200.0, 100.0)]); + assert_eq!(points(&s, b)[..3], [Point::new(200.0, 100.0), Point::new(150.0, 100.0), Point::new(100.0, 100.0)]); + // Nothing left to do: no step. + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap(); + assert_eq!(r["made"], json!("nothing")); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s, a), path(&s, b)), (pa, pb)); +} + +#[test] +fn merge_edge_follows_the_path_asked_and_makes_ends() { + let mut s = session(); + let (a, b) = shapes(&mut s); + let pa = path(&s, a); + s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100], "follow": "a"})).unwrap(); + assert_eq!(path(&s, a), pa); + assert_eq!(points(&s, b)[1], Point::new(150.0, 104.0)); + // Ends asked for: the near anchors of two lines meet, one undo step. + let c = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)], false); + let d = line(&mut s, &[(54.0, 200.0), (90.0, 240.0)], false); + let n = steps(&s); + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": c.0, "b": d.0, "at": [52, 200], "ends": true})).unwrap(); + assert_eq!((r["made"].clone(), steps(&s)), (json!("end"), n + 1)); + assert_eq!((points(&s, c)[1], points(&s, d)[0]), (Point::new(52.0, 200.0), Point::new(52.0, 200.0))); +} + +#[test] +fn merge_edge_refuses_bad_params_and_locked_paths() { + let mut s = session(); + let (a, b) = shapes(&mut s); + let (i, j) = (a.0, b.0); + let n = steps(&s); + for p in [ + json!({}), + json!({"a": i, "b": i, "at": [125, 100]}), + json!({"a": i, "b": j}), + json!({"a": i, "b": j, "at": [1]}), + json!({"a": i, "b": j, "at": [1, "x"]}), + json!({"a": i, "b": j, "at": [125, 100], "diameter": 0.1}), + json!({"a": i, "b": j, "at": [125, 100], "diameter": 201}), + json!({"a": i, "b": j, "at": [125, 100], "tolerance": 3.5}), + json!({"a": i, "b": j, "at": [125, 100], "tolerance": -1}), + json!({"a": i, "b": j, "at": [125, 100], "follow": "up"}), + json!({"a": i, "b": j, "at": [125, 100], "ends": 1}), + json!({"a": i, "b": u64::MAX, "at": [125, 100]}), + json!({"a": i, "b": j, "at": [390, 290]}), + ] { + assert!(s.execute("addon.pathtools.mergeEdge", &p).is_err(), "{p}"); + } + assert_eq!(steps(&s), n); + s.execute("select.set", &json!({"ids": [i]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.execute("addon.pathtools.mergeEdge", &json!({"a": i, "b": j, "at": [125, 100]})).is_err()); +} + +#[test] +fn the_tool_merges_unselected_paths_on_a_click_with_shift_following_the_upper() { + let mut s = session(); + let (lower, upper) = shapes(&mut s); + s.execute("select.none", &json!({})).unwrap(); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("sharedEdge", view).unwrap(); + let keep = path(&s, upper); + let n = steps(&s); + let shifted = |kind| { + let mut e = PointerEvent::new(kind, 125.0, 100.0); + e.mods = Mods { shift: true, ..Mods::default() }; + e + }; + s.pointer(&shifted(PointerKind::Down), view).unwrap(); + s.pointer(&shifted(PointerKind::Up), view).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(path(&s, upper), keep); + assert_eq!(points(&s, lower)[1], Point::new(150.0, 96.0)); +} + +#[test] +fn keep_shared_points_together_is_off_by_default_and_a_preference_of_the_tool() { + let mut s = session(); + assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(false)); + assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(), json!({"enabled": true})); + assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(true), "kept with another tool active"); + assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({})).unwrap(), json!({"enabled": false})); + assert!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": 1})).is_err()); +} + +#[test] +fn a_slide_takes_coincident_anchors_along_in_one_step_when_the_option_is_on() { + let mut s = session(); + let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false); + let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false); + let far = line(&mut s, &[(200.0, 101.0), (250.0, 200.0)], false); + let slide = json!({"id": a.0, "anchor": 1, "t": 0.5}); + let n = steps(&s); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0), "off: it stays"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert_eq!(steps(&s), n + 1); + let moved = points(&s, a)[1]; + assert!(moved.distance(Point::new(250.0, 100.0)) < 1e-6, "{moved:?}"); + assert!(points(&s, b)[0].distance(moved) < 1e-9); + assert_eq!(points(&s, b)[1], Point::new(200.0, 200.0)); + assert_eq!(points(&s, far)[0], Point::new(200.0, 101.0), "not coincident"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0)); + // A wider coincidence reaches it, and a locked path stays. + s.set_tool_options(Some("sharedEdge"), &serde_json::Map::from_iter([("tolerance".to_string(), json!(2))])); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert!(points(&s, far)[0].distance(Point::new(250.0, 101.0)) < 1e-6); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0)); +} + +#[test] +fn set_anchor_and_extend_take_coincident_anchors_along_but_built_in_moves_do_not() { + let mut s = session(); + let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0)], false); + let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false); + s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(); + // Set Anchor on one selected anchor. + s.select(|_, sel| { + sel.set([a]); + sel.anchors.insert(a, [(0, 1)].into_iter().collect()); + }) + .unwrap(); + let n = steps(&s); + s.execute("addon.pathtools.setAnchor", &json!({"x": 220, "y": 90})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(points(&s, b), [Point::new(220.0, 90.0), Point::new(200.0, 200.0)]); + // An edit that moves no anchor moves none. + s.execute("addon.pathtools.corner", &json!({})).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(220.0, 90.0)); + // Extend Path moves an end only when it adds no anchor. + s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10, "mode": "bezier"})).unwrap(); + let end = points(&s, a)[1]; + assert!(points(&s, b)[0].distance(end) < 1e-9); + let end = points(&s, b)[0]; + s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10})).unwrap(); + assert_eq!(points(&s, a).len(), 3); + assert_eq!(points(&s, b)[0], end, "the end it left stays with what shares it"); + // The app's own moves are untouched by the option. + s.select(|_, sel| { + sel.set([a]); + sel.anchors.insert(a, [(0, 1)].into_iter().collect()); + }) + .unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 5})).unwrap(); + assert_eq!(points(&s, b)[0], end); +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e158db5f6..32bcc0c3b 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -4,7 +4,8 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; +use vectorcraft_geom::shapes::CornerKind; use vectorcraft_geom::{AnchorKind, PathData, Point}; use vectorcraft_tools::{PointerEvent, PointerKind}; @@ -456,3 +457,417 @@ fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0)); } + +// ---------- Corner Tool ---------- + +/// A square (100, 100)–(300, 300), plain and selected: anchors from the top left, clockwise. +fn square(s: &mut Session) -> NodeId { + let id = line(s, &[(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)], true); + select(s, &[id]); + id +} + +fn live(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).unwrap().live_shape().cloned() +} + +fn drawn(s: &Session, id: NodeId, corner: usize) -> f64 { + LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap().drawn_radius(corner) +} + +#[test] +fn corner_set_cuts_live_corners_as_one_undo_step() { + let mut s = session(); + let id = square(&mut s); + let plain = path(&s, id); + let n = steps(&s); + // The selected path: all four. + let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap(); + assert_eq!(r, json!({"ids": [id.0], "corners": 4})); + assert_eq!((steps(&s), path(&s, id).anchor_count()), (n + 1, 8)); + let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).map(|c| (c.drawn_radius(3), c.kind(3))).unwrap(); + assert_eq!(lc, (20.0, CornerKind::Chamfer)); + // The same again changes nothing: no step. + s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap(); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s, id), live(&s, id)), (plain, None)); +} + +#[test] +fn corner_set_goes_by_anchors_the_selection_and_items_and_fits() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + // Named anchors, of each path by `items`, at once; an open end takes none. + let items = json!([{"id": id.0, "anchors": [1]}, {"id": other.0, "anchors": [0, 1]}]); + let n = steps(&s); + let r = s.execute("addon.pathtools.cornerSet", &json!({"items": items, "radius": 5})).unwrap(); + assert_eq!((r["corners"].clone(), steps(&s)), (json!(2), n + 1)); + assert_eq!((drawn(&s, id, 1), drawn(&s, id, 0), drawn(&s, other, 1)), (5.0, 0.0, 5.0)); + // `ids` with `anchors`, keeping the kind each has. + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [1], "radius": 9})).unwrap(); + assert_eq!(drawn(&s, id, 1), 9.0); + // Direct-selected anchors: only their corners, which stay selected as they gain anchors. + select_anchors(&mut s, id, &[0]); + s.execute("addon.pathtools.cornerSet", &json!({"radius": 7})).unwrap(); + assert_eq!((drawn(&s, id, 0), drawn(&s, id, 2)), (7.0, 0.0)); + let sel = s.doc().unwrap().selection.anchors[&id].clone(); + let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap(); + assert_eq!(c.corners_of(&sel), BTreeSet::from([0])); + // A radius that doesn't fit is drawn at what does. + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [2], "radius": 5000})).unwrap(); + assert!(drawn(&s, id, 2) <= 200.0 - 7.0 + 1e-9, "{}", drawn(&s, id, 2)); +} + +#[test] +fn corner_remove_makes_corners_sharp_and_a_path_plain_again() { + let mut s = session(); + let id = square(&mut s); + let plain = path(&s, id); + s.execute("addon.pathtools.cornerSet", &json!({"anchors": [0, 1], "radius": 10, "kind": "invertedRound"})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "anchors": [0]}]})).unwrap(); + assert_eq!((r["corners"].clone(), steps(&s), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), n + 1, 0.0, 10.0)); + s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap(); + assert_eq!((steps(&s), path(&s, id), live(&s, id)), (n + 2, plain, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(drawn(&s, id, 1), 10.0); +} + +#[test] +fn corner_commands_refuse_bad_params() { + let mut s = session(); + let id = square(&mut s); + let smooth = circle(&mut s); + let i = id.0; + refuses( + &mut s, + "addon.pathtools.cornerSet", + &[ + json!({"ids": [i]}), + json!({"ids": [i], "radius": -1}), + json!({"ids": [i], "radius": 1e308}), + json!({"ids": [i], "radius": "big"}), + json!({"ids": [i], "radius": 5, "kind": "spiky"}), + json!({"ids": [i], "anchors": "all", "radius": 5}), + json!({"ids": [i], "anchors": [-1], "radius": 5}), + json!({"ids": [i], "anchors": [9], "radius": 5}), + json!({"ids": [u64::MAX], "radius": 5}), + json!({"items": 3, "radius": 5}), + json!({"items": [{"anchors": [0]}], "radius": 5}), + json!({"ids": [smooth.0], "radius": 5}), + ], + ); + refuses(&mut s, "addon.pathtools.cornerRemove", &[json!({"anchors": [9], "ids": [i]}), json!({"ids": [smooth.0]})]); + // Locked: left alone. + s.execute("object.lock", &json!({"ids": [i]})).ok(); + if !s.doc().unwrap().doc.is_editable(id) { + refuses(&mut s, "addon.pathtools.cornerSet", &[json!({"items": [{"id": i}], "radius": 5})]); + } +} + +#[test] +fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() { + let mut s = session(); + let id = square(&mut s); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("cornerTool", view).unwrap(); + s.set_tool_option("radius", &json!(15)); + let n = steps(&s); + let press = |s: &mut Session, k, x, y| s.pointer(&PointerEvent::new(k, x, y), view).unwrap(); + // A click on the top right anchor. + press(&mut s, PointerKind::Down, 300.0, 100.0); + press(&mut s, PointerKind::Up, 300.0, 100.0); + assert_eq!((steps(&s), drawn(&s, id, 1)), (n + 1, 15.0)); + // A drag from the bottom right anchor, previewed, one step. + press(&mut s, PointerKind::Down, 300.0, 300.0); + press(&mut s, PointerKind::Drag, 280.0, 280.0); + assert_eq!(drawn(&s, id, 2), 20.0, "the preview shows"); + press(&mut s, PointerKind::Drag, 270.0, 270.0); + press(&mut s, PointerKind::Up, 270.0, 270.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 2, 30.0)); + // A drag on the marker resizes; a double-click on it removes. + press(&mut s, PointerKind::Down, 270.0, 270.0); + press(&mut s, PointerKind::Drag, 260.0, 260.0); + press(&mut s, PointerKind::Up, 260.0, 260.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 3, 40.0)); + press(&mut s, PointerKind::DoubleClick, 260.0, 260.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 4, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(drawn(&s, id, 2), 40.0); + // A click on a marker selects the corner and loads its values. + press(&mut s, PointerKind::Down, 285.0, 115.0); + press(&mut s, PointerKind::Up, 285.0, 115.0); + assert_eq!(steps(&s), n + 3, "selecting is no edit"); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(2)); + // The bottom right one, resized by its marker, loads its radius. + press(&mut s, PointerKind::Down, 260.0, 260.0); + press(&mut s, PointerKind::Up, 260.0, 260.0); + assert_eq!(s.tool_options_of("cornerTool")["radius"], json!(40.0)); + // An Alt-marquee over the left side: both anchors, in one step. + let alt = vectorcraft_tools::Mods { alt: true, ..Default::default() }; + for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 350.0), (PointerKind::Up, 150.0, 350.0)] { + s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), view).unwrap(); + } + assert_eq!((steps(&s), drawn(&s, id, 0), drawn(&s, id, 3)), (n + 4, 40.0, 40.0)); +} + +// ---------- Slide Anchor Along Path ---------- + +#[test] +fn slide_anchor_moves_it_along_the_path_as_one_undo_step() { + let mut s = session(); + let e = circle(&mut s); + let before = path(&s, e); + let area = kurbo::Shape::area(&before.to_bezpath()).abs(); + let want = before.subpaths[0].segment(3); + let n = steps(&s); + let r = s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0.5})).unwrap(); + assert!(r["strayed"].as_f64().unwrap() < 0.05, "{r}"); + let after = path(&s, e); + assert_eq!((steps(&s), after.anchor_count()), (n + 1, 16)); + assert!(after.subpaths[0].anchors[3].p.distance(kurbo::ParamCurve::eval(&want, 0.5)) < 1e-9); + assert!((kurbo::Shape::area(&after.to_bezpath()).abs() / area - 1.0).abs() < 1e-3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, e), before); + // No move, no step. + s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn slide_anchor_refuses_bad_params() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let i = l.0; + refuses( + &mut s, + "addon.pathtools.slideAnchor", + &[ + json!({"id": i, "anchor": 0, "t": 0.5}), + json!({"id": i, "anchor": 2, "t": -0.5}), + json!({"id": i, "anchor": 9, "t": 0.5}), + json!({"id": i, "anchor": 1, "t": 1.5}), + json!({"id": i, "anchor": 1}), + json!({"id": i, "t": 0.5}), + json!({"id": i, "subpath": 4, "anchor": 1, "t": 0.5}), + json!({"id": u64::MAX, "anchor": 1, "t": 0.5}), + ], + ); +} + +#[test] +fn the_slide_tool_previews_and_commits_one_step() { + let mut s = session(); + let l = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("slideAnchor", view).unwrap(); + let n = steps(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 100.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 120.0), view).unwrap(); + let at = |s: &Session| path(s, l).subpaths[0].anchors[1].p; + assert!(at(&s).distance(Point::new(250.0, 100.0)) < 1e-3, "the preview shows"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), view).unwrap(); + assert!(steps(&s) == n + 1 && at(&s).distance(Point::new(280.0, 100.0)) < 1e-3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(at(&s), Point::new(200.0, 100.0)); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +#[test] +fn divide_into_dashes_cuts_a_path_into_open_pieces_keeping_its_look() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + // 30 + 10 + 30 + 10 + 20: the gaps are left empty. + let pieces = ids(&r); + assert_eq!((pieces.len(), r["dashes"].clone()), (3, json!(3))); + let st = s.doc().unwrap(); + let spans: Vec<(f64, f64)> = pieces.iter().map(|id| path(&s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect(); + let want = [(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]; + assert!(spans.iter().zip(want).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{spans:?}"); + assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.stroke_width() == 3.0), "the stroke stays"); + assert_eq!(st.selection.objects, pieces); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, l).anchor_count(), 2); + // Kept gaps; fitted 25s with no gap. + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10, "removeGaps": false})).unwrap(); + assert_eq!((ids(&r).len(), r["dashes"].clone()), (5, json!(3))); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "fit": true})).unwrap(); + let spans: Vec = ids(&r).iter().map(|id| path(&s, *id).bounds().unwrap().width()).collect(); + assert_eq!(spans.len(), 3); + assert!(spans.iter().all(|w| (w - 100.0 / 3.0).abs() < 1e-6), "{spans:?}"); +} + +#[test] +fn divide_into_dashes_divides_only_the_selected_segments() { + let mut s = session(); + let u = line(&mut s, &[(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)], false); + // The middle side, between anchors 1 and 2. + select_anchors(&mut s, u, &[1, 2]); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 10})).unwrap(); + let pieces = ids(&r); + assert_eq!((pieces.len(), r["dashes"].clone()), (6, json!(4)), "the first side, four dashes, the last side"); + assert_eq!(path(&s, pieces[0]).bounds().unwrap().width(), 40.0); + let layer: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, pieces, "in the path's place, in order"); +} + +#[test] +fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false); + select(&mut s, &[l]); + refuses( + &mut s, + "addon.pathtools.divideDashes", + &[ + json!({"dash": 0}), + json!({"dash": -5}), + json!({"dash": 1e308}), + json!({"dash": "10"}), + json!({"gap": -1}), + json!({"gap": 2e6}), + json!({"fit": "yes"}), + json!({"removeGaps": 1}), + json!({"dash": 0.5}), + ], + ); + // 200000 pieces refused above; 1000 is fine. + assert_eq!(s.execute("addon.pathtools.divideDashes", &json!({"dash": 100})).unwrap()["dashes"], json!(1000)); + s.execute("select.none", &json!({})).unwrap(); + refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]); +} + +/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors. +fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> { + ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect() +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6) +} + +#[test] +fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r)); + assert_eq!(r["dashes"], json!(3)); + let st = s.doc().unwrap(); + for id in ids(&r) { + let node = st.doc.node(id).unwrap(); + let stroke = node.appearance.stroke().unwrap(); + assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off"); + assert!(!path(&s, id).subpaths[0].closed); + } + assert_eq!(st.selection.objects, ids(&r)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some()); + // The offset shifts the pattern as the stroke draws it: 20 into the first dash. + s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap(); + assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r)); +} + +#[test] +fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() { + let mut s = session(); + let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()); + select(&mut s, &[rect]); + s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap(); + let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + // 320 round: eight dashes of 20, a dash bending round each corner it meets. + let pieces = ids(&r); + assert_eq!(pieces.len(), 8); + let st = s.doc().unwrap(); + let layer: Vec = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + if had_fill { + let kept = st.doc.node(rect).unwrap(); + assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle"); + assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes"); + } else { + assert_eq!(layer, pieces); + } + assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled"); + s.execute("edit.undo", &json!({})).unwrap(); + // Aligned to corners: a dash centred on each corner, the four sides each starting and ending + // on half a dash; 12 dashes along the sides. + s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)]; + let mut centred = 0; + for id in ids(&r) { + let sp = path(&s, id).subpaths[0].clone(); + if let [a, b, c] = sp.anchors.as_slice() + && corners.iter().any(|k| b.p.distance(*k) < 1e-6) + { + assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}"); + centred += 1; + } + } + assert_eq!((ids(&r).len(), centred), (16, 4)); +} + +#[test] +fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100"); + assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + // Under a butt cap a dot paints nothing, so there is nothing to expand. + s.execute("stroke.set", &json!({"cap": "butt"})).unwrap(); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]); + // A compound path's dashes are paths of their own with its stroke. + let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false); + let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false); + select(&mut s, &[a, b]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap(); + let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap(); + // Each 50 line: 20, gap, 20. + let st = s.doc().unwrap(); + assert_eq!(ids(&r).len(), 4); + for id in ids(&r) { + let n = st.doc.node(id).unwrap(); + assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path"); + assert_eq!(n.appearance.stroke_width(), 2.0); + assert!(n.appearance.stroke().unwrap().dash.is_none()); + } + assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it"); +} + +#[test] +fn expand_dashes_refuses_without_dashes_and_past_the_limit() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]); + select(&mut s, &[l]); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]); + // 125000 dashes refused; 1000 are fine. + s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap(); + refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]); + s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap(); + assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000)); +} diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs new file mode 100644 index 000000000..0cd8e21ea --- /dev/null +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -0,0 +1,165 @@ +//! Type Tools (`addon.type.*`), a built-in add-on: Text Tweaker gives one character attribute to +//! the selected characters letter by letter, from a start value to an end value along a shape +//! (straight, eased, a bow, a spike, or at random), while the text stays live. Values are set as +//! the Character panel sets them (`text.setRangeStyle`), in its units and ranges +//! (). Its submenu +//! (Object › Plug-ins › Type Tools) and dialog are the UI's. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_color::recolor::Rng; +use vectorcraft_doc::{NodeId, TextRun}; +use vectorcraft_text::edit; + +use super::super::menucmds::whole_or; +use super::super::object::choice; +use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; +use super::super::typecmd::refresh_bounds; +use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// An attribute Text Tweaker sets: its `text.setRangeStyle` param, the values it takes and its +/// default start and end. +type Attribute = (&'static str, (f64, f64), (f64, f64)); + +/// The attributes Text Tweaker sets. +pub const ATTRIBUTES: [Attribute; 8] = [ + ("size", (0.1, 1296.0), (12.0, 36.0)), + ("baselineShift", (-1296.0, 1296.0), (0.0, 12.0)), + ("rotation", (-360.0, 360.0), (0.0, 30.0)), + ("hScale", (1.0, 10000.0), (100.0, 150.0)), + ("vScale", (1.0, 10000.0), (100.0, 150.0)), + ("tracking", (-1000.0, 10000.0), (0.0, 200.0)), + ("leading", (0.1, 5000.0), (12.0, 36.0)), + ("strokeWidth", (0.0, 1000.0), (0.0, 2.0)), +]; +/// The shapes the values follow from the first character to the last. +pub const SHAPES: [&str; 5] = ["straight", "ease", "bow", "spike", "random"]; +/// The most characters one run changes. +const MAX_CHARS: usize = 100_000; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.type.textTweaker", + "Text Tweaker", + ["Object", "Plug-ins", "Type Tools"], + None, + "{attribute?: \"size\" (pt 0.1..1296) | \"baselineShift\" (pt −1296..1296) | \"rotation\" (° −360..360) | \"hScale\" (% 1..10000) | \"vScale\" (% 1..10000) | \"tracking\" (1/1000 em −1000..10000) | \"leading\" (pt 0.1..5000) | \"strokeWidth\" (pt 0..1000) (\"size\"), from?: the first character's value, to?: the last character's value (each in the attribute's units and range; defaults: size 12 → 36, baselineShift 0 → 12, rotation 0 → 30, hScale and vScale 100 → 150, tracking 0 → 200, leading 12 → 36, strokeWidth 0 → 2), shape?: \"straight\" (an even ramp) | \"ease\" (slow at both ends) | \"bow\" (from at both ends, rising smoothly to `to` in the middle) | \"spike\" (from at both ends, straight up to `to` in the middle) | \"random\" (each character anywhere from `from` to `to`) (\"straight\"), seed?: whole number (0; random: the same seed and text give the same values), skipSpaces?: true (spaces and line breaks keep their values and don't count), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} give the characters one character attribute letter by letter, from `from` to `to` along the shape, as the Character panel sets it, as one undo step; the text stays live and editable. The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: all its text), else every selected type object; each object gets its own ramp. Stroke weight only shows on characters with a stroke → {count (characters changed), ids}", + has_selection, + text_tweaker + )] +} + +/// Where along `shape` (0 at `from`, 1 at `to`) character `i` of `n` lands; `random` for the +/// random shape. +fn amount(shape: &str, i: usize, n: usize, random: f64) -> f64 { + let t = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + match shape { + "ease" => t * t * (3.0 - 2.0 * t), + "bow" => (std::f64::consts::PI * t).sin(), + "spike" => 1.0 - (2.0 * t - 1.0).abs(), + "random" => random, + _ => t, + } +} + +/// A random number from 0 to 1. +fn unit(rng: &mut Rng) -> f64 { + (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64 +} + +fn text_tweaker(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.type.textTweaker"; + let attribute = choice(p, C, "attribute", &ATTRIBUTES.map(|a| a.0))?.unwrap_or("size"); + let Some(&(key, (lo, hi), (from0, to0))) = ATTRIBUTES.iter().find(|a| a.0 == attribute) else { + return Err(bad(C, format!("unknown attribute `{attribute}`"))); + }; + let mut ends = [0.0; 2]; + for (end, (k, default)) in ends.iter_mut().zip([("from", from0), ("to", to0)]) { + let v = number_or(p, k, default, C)?; + if !(lo..=hi).contains(&v) { + return Err(bad(C, format!("`{k}` must be from {lo} to {hi} for {key}"))); + } + *end = v; + } + let [from, to] = ends; + let shape = choice(p, C, "shape", &SHAPES)?.unwrap_or("straight"); + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let skip = switch_or(p, "skipSpaces", true, C)?; + let setup = s.doc()?.doc.setup.clone(); + // At a caret, all of the object's text. + let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?; + // Each object's changed characters: (byte, its change), worked out before the edit. + let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![]; + let mut count = 0; + for (id, a, b, _) in ranges { + let t = text_ref(s, id)?; + let text = t.plain_text(); + let chars: Vec = + text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect(); + count += chars.len(); + if count > MAX_CHARS { + return Err(bad(C, format!("too many characters: at most {MAX_CHARS}"))); + } + let n = chars.len(); + let mut changes = Vec::with_capacity(n); + for (i, at) in chars.into_iter().enumerate() { + let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; + let value = from + (to - from) * amount(shape, i, n, random); + let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; + change.localize(&t, C)?; + changes.push((at, change)); + } + if !changes.is_empty() { + plans.push((id, changes)); + } + } + if plans.is_empty() { + return Ok(json!({ "count": 0, "ids": [] })); + } + let ids: Vec = plans.iter().map(|(id, _)| id.0).collect(); + s.edit("Text Tweaker", |d, _| { + for (id, changes) in &plans { + let t = text_mut(d, *id).ok_or(EngineError::NoNode(*id))?; + t.runs = tweak(std::mem::take(&mut t.runs), changes); + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "count": count, "ids": ids })) +} + +/// `runs` with each character at a byte of `changes` (in order) changed: its run split into +/// characters, the changes applied, and runs that came out alike merged again. +fn tweak(runs: Vec, changes: &[(usize, CharChange)]) -> Vec { + let mut out = Vec::with_capacity(runs.len() + changes.len() * 2); + let mut next = changes.iter().peekable(); + let mut off = 0; + for r in runs { + let end = off + r.text.len(); + if r.inline.is_some() || next.peek().is_none_or(|(at, _)| *at >= end) { + // An inline graphic is one character: its style changes whole. + if let Some((_, change)) = next.next_if(|(at, _)| (off..end).contains(at)) { + let mut r = r; + change.apply(&mut r.style); + out.push(r); + } else { + out.push(r); + } + off = end; + continue; + } + for (i, c) in r.text.char_indices() { + let mut piece = TextRun::new(c.to_string(), r.style.clone()); + if let Some((_, change)) = next.next_if(|(at, _)| *at == off + i) { + change.apply(&mut piece.style); + } + out.push(piece); + } + off = end; + } + edit::normalize(&mut out); + out +} diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs new file mode 100644 index 000000000..47a1b92b9 --- /dev/null +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -0,0 +1,195 @@ +//! Text Tweaker: the values each shape gives each character, what it skips and changes, one undo +//! step, and bad params refused without a change. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; + +use super::amount; +use crate::Session; + +const C: &str = "addon.type.textTweaker"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, words: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 40, "y": 100, "text": words, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// Each character of `id` with its style. +fn styles(s: &Session, id: NodeId) -> Vec<(char, CharStyle)> { + obj(s, id).runs.iter().flat_map(|r| r.text.chars().map(|c| (c, r.style.clone())).collect::>()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9) +} + +#[test] +fn the_shapes_run_from_the_start_value_to_the_end_value() { + let at = |shape, n| (0..n).map(|i| amount(shape, i, n, 0.5)).collect::>(); + assert!(near(&at("straight", 5), &[0.0, 0.25, 0.5, 0.75, 1.0])); + assert!(near(&at("ease", 5), &[0.0, 0.15625, 0.5, 0.84375, 1.0]), "slow at both ends"); + let bow = at("bow", 5); + assert!(near(&[bow[0], bow[2], bow[4]], &[0.0, 1.0, 0.0]) && (bow[1] - 0.5f64.sqrt()).abs() < 1e-9, "a smooth arc"); + assert!(near(&at("spike", 5), &[0.0, 0.5, 1.0, 0.5, 0.0]), "straight up and down"); + assert!(near(&at("random", 3), &[0.5; 3])); + assert!(near(&at("straight", 1), &[0.0]), "one character takes the start value"); +} + +#[test] +fn a_straight_ramp_sizes_each_letter_and_skips_spaces() { + let mut s = session(); + let id = text(&mut s, "ab cd"); + select(&mut s, &[id]); + let n = steps(&s); + let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap(); + assert_eq!(out, json!({"count": 4, "ids": [id.0]})); + assert_eq!(steps(&s), n + 1, "one undo step"); + let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect(); + assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size"); + assert_eq!(obj(&s, id).plain_text(), "ab cd", "the text stays live"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(styles(&s, id).iter().all(|(_, st)| st.size == 20.0), "undone"); +} + +#[test] +fn spaces_can_count_too() { + let mut s = session(); + let id = text(&mut s, "a b"); + select(&mut s, &[id]); + s.execute(C, &json!({"attribute": "baselineShift", "from": 0, "to": 10, "skipSpaces": false})).unwrap(); + let shifts: Vec = styles(&s, id).iter().map(|(_, st)| st.baseline_shift).collect(); + assert_eq!(shifts, [0.0, 5.0, 10.0]); +} + +#[test] +fn every_attribute_is_set_as_the_character_panel_sets_it() { + let mut s = session(); + for (attribute, get) in [ + ("size", (|st: &CharStyle| st.size) as fn(&CharStyle) -> f64), + ("baselineShift", |st| st.baseline_shift), + ("rotation", |st| st.rotation), + ("hScale", |st| st.h_scale), + ("vScale", |st| st.v_scale), + ("tracking", |st| st.tracking), + ("leading", |st| st.leading.unwrap_or(-1.0)), + ("strokeWidth", |st| st.stroke_width), + ] { + let id = text(&mut s, "xyz"); + select(&mut s, &[id]); + s.execute(C, &json!({"attribute": attribute, "from": 2, "to": 6})).unwrap(); + let got: Vec = styles(&s, id).iter().map(|(_, st)| get(st)).collect(); + assert!(near(&got, &[2.0, 4.0, 6.0]), "{attribute}: {got:?}"); + } +} + +#[test] +fn each_object_gets_its_own_ramp() { + let mut s = session(); + let (a, b) = (text(&mut s, "abc"), text(&mut s, "de")); + select(&mut s, &[a, b]); + let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap(); + assert_eq!(out["count"], json!(5)); + let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::>(); + assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0])); +} + +#[test] +fn a_range_changes_only_its_characters() { + let mut s = session(); + let id = text(&mut s, "abcde"); + s.execute(C, &json!({"id": id.0, "start": 1, "end": 4, "attribute": "tracking", "from": 100, "to": 300})).unwrap(); + let tracking: Vec = styles(&s, id).iter().map(|(_, st)| st.tracking).collect(); + assert_eq!(tracking, [0.0, 100.0, 200.0, 300.0, 0.0]); +} + +#[test] +fn random_values_stay_in_range_and_repeat_with_the_seed() { + let mut s = session(); + let id = text(&mut s, "randomness"); + select(&mut s, &[id]); + let run = |s: &mut Session, seed| { + s.execute(C, &json!({"attribute": "vScale", "from": 50, "to": 150, "shape": "random", "seed": seed})).unwrap(); + styles(s, id).iter().map(|(_, st)| st.v_scale).collect::>() + }; + let first = run(&mut s, 7); + assert!(first.iter().all(|v| (50.0..=150.0).contains(v)), "{first:?}"); + assert!(first.windows(2).any(|w| w[0] != w[1]), "values vary"); + assert_eq!(run(&mut s, 7), first, "same seed, same values"); + assert_ne!(run(&mut s, 8), first, "another seed, other values"); +} + +#[test] +fn values_follow_the_text_transform_as_the_character_panel_does() { + let mut s = session(); + let id = text(&mut s, "ab"); + select(&mut s, &[id]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute(C, &json!({"attribute": "size", "from": 20, "to": 40})).unwrap(); + let sizes: Vec = styles(&s, id).iter().map(|(_, st)| st.size).collect(); + // Shown sizes are the asked ones: stored sizes are half, the text being scaled twice. + let scale = obj(&s, id).style_scale().unwrap().points; + assert!(near(&sizes.iter().map(|v| v * scale).collect::>(), &[20.0, 40.0]), "{sizes:?} at {scale}"); +} + +#[test] +fn empty_text_and_only_spaces_change_nothing() { + let mut s = session(); + let id = text(&mut s, " "); + select(&mut s, &[id]); + let n = steps(&s); + assert_eq!(s.execute(C, &json!({})).unwrap(), json!({"count": 0, "ids": []})); + assert_eq!(steps(&s), n, "no step"); +} + +#[test] +fn bad_params_are_refused_without_a_change() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + for p in [ + json!({"attribute": "colour"}), + json!({"attribute": 3}), + json!({"shape": "zigzag"}), + json!({"attribute": "size", "from": 0}), + json!({"attribute": "size", "to": 5000}), + json!({"attribute": "hScale", "from": -1}), + json!({"from": "big"}), + json!({"to": f64::MAX}), + json!({"seed": -1}), + json!({"skipSpaces": "yes"}), + json!({"ids": [999_999]}), + ] { + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + assert_eq!(steps(&s), n, "{p}"); + } +} + +#[test] +fn it_needs_a_selection() { + let mut s = session(); + text(&mut s, "abc"); + select(&mut s, &[]); + assert!(s.execute(C, &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c52b0003f..dc83d24bd 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -27,7 +27,7 @@ use vectorcraft_doc::appearance::SharedStack; use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; -use super::edit::{outermost, selected_roots}; +use super::edit::{outermost, roots_of, selected_roots}; use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; @@ -37,6 +37,7 @@ use super::{ }; use crate::EngineError; +/// The Appearance panel's commands: fills and strokes, their order and copies, and the active item. pub fn specs() -> Vec { vec![ cmd!( @@ -44,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids` and no `target: \"contents\"`, the new fill is a copy of that row, right above it, and becomes the active row", has_doc, |s, p| add_item(s, p, true) ), @@ -71,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", has_doc, reduce_basic ), @@ -107,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it; not with aligned rows of a mixed selection), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them", has_doc, duplicate_item ), @@ -116,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -125,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -255,17 +256,36 @@ impl ItemTarget { if fits == Some(true) { self } else { ItemTarget::Top } } - /// This target for each of `ids` in `d` (in their order): an aligned item (a row of the first - /// target's stack) is, in each other target, the item equal to it there (see + /// This target for each of `ids` (in their order): an aligned item (a row of the first + /// target's stack, or for the active item of the first selected object's, see + /// [`ItemTarget::anchor`]) is, in each other target, the item equal to it there (see /// [`SharedStack`]); `None` for a target that has none, which the edit leaves alone. A row the /// targets don't share, and any other target, stands for itself in every target. - pub(crate) fn per_target(self, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec> { + pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec> { let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] }; - let Some(shared) = shared_stack(d, ids) else { return vec![Some(self); ids.len()] }; + let Some(st) = s.active().filter(|_| ids.len() >= 2) else { return vec![Some(self); ids.len()] }; + let k = self.anchor(s, ids); + let mut order = ids.to_vec(); + order.swap(0, k); + let Some(shared) = shared_stack(&st.doc, &order) else { return vec![Some(self); ids.len()] }; if !shared.has_item(index) { return vec![Some(self); ids.len()]; } - (0..ids.len()).map(|k| shared.item_in(index, k).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect() + let mut per: Vec> = + (0..order.len()).map(|j| shared.item_in(index, j).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect(); + per.swap(0, k); + per + } + + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of + /// is: the first selected object (or the compound path holding it) for the active item, which + /// need not paint first; the first target otherwise, or when that object is not among them. + pub(crate) fn anchor(self, s: &Session, ids: &[NodeId]) -> usize { + let ItemTarget::Item { explicit: false, .. } = self else { return 0 }; + let Some(st) = s.active() else { return 0 }; + let Some(first) = st.selection.subjects().first() else { return 0 }; + let root = roots_of(&st.doc, vec![*first]).first().copied().unwrap_or(*first); + ids.iter().position(|id| *id == root).unwrap_or(0) } /// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one. @@ -407,7 +427,7 @@ pub(crate) fn edit_items_then( return Ok(()); } let before = s.doc()?.doc.clone(); - let per = item.per_target(&before, ids); + let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); for (id, item) in ids.iter().zip(&per) { @@ -429,7 +449,7 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or /// null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; @@ -439,7 +459,7 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; - Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`index`]. @@ -470,12 +490,45 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result { fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?; let defaults = s.paint.clone(); + // The panel's active Fill row, when the new fill goes on the selection: the new fill is a copy + // of that row, right above it in each target that has it (others get a copy of their top fill). // Where the new item went in on the active item's object, when it is an image. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + // The active row is a row of the active object's stack, which need not come first in `ids` + // (they are in paint order): match the others against it. + let anchor = active.and_then(|a| ids.iter().position(|id| *id == a)); + let selected = match (s.appearance_item(), anchor) { + (Some(index), Some(_)) if fill && p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + ItemTarget::Item { index, explicit: false, aligned: true }.of_kind(s, true) + } + _ => ItemTarget::Top, + }; + let per: Vec> = match (selected, anchor) { + (ItemTarget::Item { index, .. }, Some(k)) => { + let mut order = ids.clone(); + order.swap(0, k); + let mut per: Vec> = + items_per_target(&s.doc()?.doc, &order, &[index], true).into_iter().map(|v| v.first().copied()).collect(); + per.swap(0, k); + per + } + _ => vec![None; ids.len()], + }; let mut inserted = None; + // Where the new fill went in on the active item's object, when it went above the active row. + let mut above = None; s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { - for id in &ids { + for (id, row) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { + if let Some(i) = n.appearance.item_of_kind(*row, true) + && let Some(paint) = n.appearance.paint_at(Some(i), true).cloned() + { + n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint))); + if active == Some(*id) { + above = Some(i + 1); + } + continue; + } // A copy of the top fill (stroke); without one, type's characters' and otherwise the // default paint, so a new fill on a group or type shows. let paint = match (n.appearance.paint_at(None, fill), &n.kind) { @@ -509,8 +562,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } Ok(()) })?; - // The rows above the new item move up by one. - s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a)))); + // A fill added above the active row becomes the active row; otherwise the rows above the new + // item move up by one. + s.remap_appearance_item(&ids, |a| above.or(Some(a + usize::from(inserted.is_some_and(|k| k <= a))))); ok() } @@ -580,11 +634,30 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { let ids = require_targets(s, p, "appearance.reduceToBasic")?; // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The Appearance panel's active row (a row of the first selected object's stack, and in each + // other target the row equal to it there), when the command acts on that selection. The targets + // are in paint order, so the first selected object can be any of them. + let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f)); + let active = match (s.appearance_item(), first) { + (Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + let mut from = ids.clone(); + from.swap(0, k); + let mut rows = items_per_target(&before, &from, &[index], true); + rows.swap(0, k); + rows + } + _ => vec![Vec::new(); ids.len()], + }; super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| { - for id in &ids { + for (id, at) in ids.iter().zip(&active) { if let Some(n) = d.node_mut(*id) { - // The topmost visible fill and stroke stay, without their own transparency or effects. - let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); + // The active row stays when it is a visible fill or stroke; otherwise the topmost + // visible one of each kind does. Both lose their own transparency and effects. + let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned(); + let top = |fill: bool| match &picked { + Some(i) if i.is_fill() == fill => Some(i.clone()), + _ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(), + }; let (fill, stroke) = (top(true), top(false)); // Other objects always keep a fill row (None when there was none); a group only // keeps the rows it had. @@ -664,7 +737,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { /// The paint-order item indices a command acts on (`indices`, else `index`), ascending and /// without repeats. -fn item_indices(p: &Value, cmd: &str) -> Result> { +pub(super) fn item_indices(p: &Value, cmd: &str) -> Result> { let mut v = match p.get("indices") { Some(a) => a .as_array() @@ -678,6 +751,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result> { Ok(v) } +/// `appearance.removeItem`: removes fill/stroke `index`, or every one of `indices`, from the selected objects in one undo step. fn remove_item(s: &mut Session, p: &Value) -> Result { let idx = item_indices(p, "appearance.removeItem")?; let ids = require_targets(s, p, "appearance.removeItem")?; @@ -702,16 +776,22 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { ok() } +/// `appearance.duplicateItem`: copies fill/stroke `index`, or the rows `indices` as one block at `to` (with the Contents row when `contents` says so), in one undo step. fn duplicate_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.duplicateItem"; let idx = item_indices(p, C)?; let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?; - if to.is_some() && idx.len() > 1 { - return Err(bad(C, "`to` takes a single `index`")); + let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?; + if contents.is_some() && to.is_none() { + return Err(bad(C, "`contents` goes with `to`")); } let ids = require_targets(s, p, C)?; - // Where the first object's copy landed (the active item lives in the first object's stack). + // Where the first target's copy landed, and where it landed on the active row's object (the + // first selected one, not always the first target): copies placed at `to` land at a different + // index on stacks of other lengths. let mut first_at = None; + let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + let mut active_at = None; // Aligned rows of a mixed selection are copied right above themselves in each object. if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); @@ -721,31 +801,54 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut any = false; for (id, idx) in ids.iter().zip(&per) { let Some(n) = d.node_mut(*id) else { continue }; + let has_contents = n.contents_label().is_some(); let ap = &mut n.appearance; + if let Some(t) = to { + // The copies land together at `to`, in their order; every item must exist. + if idx.iter().any(|i| *i >= ap.items.len()) { + return Err(bad(C, format!("no item at one of the indices {idx:?}"))); + } + let copies: Vec<_> = idx.iter().filter_map(|&i| ap.items.get(i).cloned()).collect(); + let at = t.min(ap.items.len()); + first_at.get_or_insert(at); + if active == Some(*id) { + active_at = Some(at); + } + for (k, item) in copies.into_iter().enumerate() { + ap.insert_item(at + k, item); + } + if let Some(c) = contents.filter(|_| has_contents) { + ap.set_contents_at(c); + } + any = true; + continue; + } let mut idx = idx.clone(); idx.sort_unstable(); // From the top down, so the lower indices stay valid. for &i in idx.iter().rev() { if let Some(item) = ap.items.get(i).cloned() { - let at = to.map_or(i + 1, |t| t.min(ap.items.len())); - first_at.get_or_insert(at); - ap.insert_item(at, item); + first_at.get_or_insert(i + 1); + ap.insert_item(i + 1, item); any = true; } } } if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) } })?; - // Rows at or above each copy move up by one. + // Rows at or above each copy move up by one (by the block, for copies placed at `to`). + let m = idx.len(); + let landed = active_at.or(first_at); s.remap_appearance_item(&ids, |a| { Some(match to { - Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)), + Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 }, None => a + idx.iter().filter(|i| **i < a).count(), }) }); ok() } +/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; let ids = s.edit("Show All Hidden Attributes", |d, _| { @@ -758,6 +861,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// `appearance.moveItem`: moves fill/stroke `from` (or the Contents row) to `to`, or the rows `indices` as one block; the active item stays on its row. fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; let to = index_param(p, "to", C)?; @@ -765,11 +869,17 @@ fn move_item(s: &mut Session, p: &Value) -> Result { if p.get("from").and_then(Value::as_str) == Some("contents") { return move_contents(s, &ids, to); } - let from = index_param(p, "from", C)?; // Where an aligned row would go in each of several different stacks isn't one place. if aligned(p) && appearances_differ(&s.doc()?.doc, &ids) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); } + if p.get("indices").is_some() { + if p.get("from").is_some() { + return Err(bad(C, "give `from` or `indices`, not both")); + } + return move_items(s, p, &ids, to); + } + let from = index_param(p, "from", C)?; let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?; let mut landed = to; // When every object's stack comes out as it was, the document and its undo history stay as they @@ -800,6 +910,50 @@ fn move_item(s: &mut Session, p: &Value) -> Result { Ok(json!({ "index": landed })) } +/// `appearance.moveItem {indices}`: move several items as one block (the lowest landing at `to`), +/// placing the Contents row with `contents` when given, as one undo step. The active row moves with +/// its row; the rows the block passes are renumbered. +fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result { + const C: &str = "appearance.moveItem"; + let idx = item_indices(p, C)?; + let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?; + // Where the block landed on the first target (stacks of other lengths clamp `to` differently): + // the result, as the panel lists the first object's stack. + let mut first_landed = None; + // Where it landed on the active row's object: the active row is in its stack. + let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + let mut active_landed = None; + let before = s.doc()?.doc.clone(); + super::overprint::edit_counted(s, "Reorder Appearance", |d| { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + let landed = n.appearance.move_items(&idx, to).ok_or_else(|| bad(C, format!("no item at one of the indices {idx:?}")))?; + first_landed.get_or_insert(landed); + if active == Some(*id) { + active_landed = Some(landed); + } + if let Some(k) = contents.filter(|_| n.contents_label().is_some()) { + n.appearance.set_contents_at(k); + } + } + Ok(count_changed(d, &before, ids)) + })?; + let m = idx.len(); + let landed = first_landed.unwrap_or(to); + let at = active_landed.unwrap_or(landed); + s.remap_appearance_item(ids, |a| { + Some(match idx.iter().position(|i| *i == a) { + Some(k) => at + k, + None => { + // Its place among the unmoved rows, then above the block if it is at or above it. + let r = a - idx.iter().filter(|i| **i < a).count(); + if r < at { r } else { r + m } + } + }) + }); + Ok(json!({ "index": landed })) +} + /// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that /// has one above its bottom `to` items. fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result { @@ -840,11 +994,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); + let defaults_only = switch_or(p, "defaultsOnly", false, C)?; + if defaults_only && (reverse || append || p.get("ids").is_some()) { + return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); + } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; - let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None }; - let selected = match ids_param(p, "ids") { - Some(v) => outermost(&s.doc()?.doc, v), - None => selected_roots(s)?, + let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { + super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) + } else { + None + }; + let selected = if defaults_only { + vec![] + } else { + match ids_param(p, "ids") { + Some(v) => outermost(&s.doc()?.doc, v), + None => selected_roots(s)?, + } }; // Reversed, the first selected object's attributes go onto the clicked object; with nothing // selected, what the Eyedropper picked up last does (#4382). @@ -864,7 +1030,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, }; let picked = Picked::of(&node); - if !reverse { + if !reverse && !defaults_only { s.eyedropper_sample = Some(node); } let range = range.filter(|(id, _, _)| targets.contains(id)); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 975b8e93f..133fb1fce 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings}; +use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset}; use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; /// The longest asset name kept (characters). @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Asset Export Settings", [], None, - "{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step", + "{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step", has_doc, settings_set ), @@ -223,6 +223,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if let Some(o) = p.as_object() { overlay(&mut q, o); } + // Create Sub-folders off puts every file in the folder itself, mobile and density rows too. + if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() { + q.insert("folders".into(), json!("none")); + } if !q.contains_key("assets") { let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); if all.is_empty() { @@ -236,11 +240,22 @@ fn export(s: &mut Session, p: &Value) -> Result { fn settings_set(s: &mut Session, p: &Value) -> Result { const C: &str = "assets.settings.set"; let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?; - if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) { + if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && k.as_str() != "exportPreset") { return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", ")))); } let mut settings = s.doc()?.doc.export_settings.clone(); - overlay(&mut settings, o); + let mut given = o.clone(); + if let Some(name) = given.remove("exportPreset") { + let name = name.as_str().ok_or_else(|| bad(C, "`exportPreset` is the name of a preset of document.exportPresets.list"))?; + with_screen_export_preset(s, C, &mut settings, name)?; + } + // Create Sub-folders is Export for Screens' folder per row: the dialog opens on the choice. + match given.get("subfolders").and_then(Value::as_bool) { + Some(true) => _ = settings.insert("folders".into(), json!("rows")), + Some(false) if settings.get("folders").and_then(Value::as_str) == Some("rows") => _ = settings.insert("folders".into(), json!("none")), + _ => {} + } + overlay(&mut settings, &given); check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?; store_screen_settings(s, settings)?; Ok(json!({ "settings": shared_settings(s)? })) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 9bb04dac9..8f36e16f6 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -107,10 +107,14 @@ impl BrushLibrary { /// `name`: the brushes and every resource their art uses. pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { let all = brush::library(src); - if let Some(n) = names.iter().find(|n| !all.iter().any(|b| b.name == **n)) { + // Names looked up in sets: scanning the lists for each brush made a library of N brushes + // cost N² comparisons. + let have: HashSet<&str> = all.iter().map(|b| b.name.as_str()).collect(); + if let Some(n) = names.iter().find(|n| !have.contains(n.as_str())) { return Err(format!("no brush `{n}`")); } - let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name)); + let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); + let picked = all.iter().filter(|b| names.is_empty() || wanted.contains(b.name.as_str())); let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect()); let mut doc = clip.to_document(); for l in &mut doc.layers { @@ -120,7 +124,8 @@ impl BrushLibrary { } // The brushes picked and those their art paints with, in the panel order of the document // they came from. - let brushes: Vec = all.into_iter().filter(|b| clip.brushes.iter().any(|c| c.name == b.name)).collect(); + let kept: HashSet<&str> = clip.brushes.iter().map(|c| c.name.as_str()).collect(); + let brushes: Vec = all.iter().filter(|b| kept.contains(b.name.as_str())).cloned().collect(); brush::store(&mut doc, &brushes); doc.title = name.clone(); Ok(Self { name, brushes, doc }) @@ -335,7 +340,7 @@ fn scatter_shapes() -> BrushLibrary { a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold)))); a }); - doc.symbols.push(Symbol { name: "Sparkle".into(), art: Arc::new(sparkle) }); + doc.symbols.push(Symbol { dynamic: false, name: "Sparkle".into(), art: Arc::new(sparkle) }); let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Sparkle".into(), xf: Affine::scale(0.3) }); let random = |a: f64, b: f64| (a, b); let base = Scatter { colorization: Colorization::Tints, ..Default::default() }; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8ef171d32..adfb221ef 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -19,12 +19,13 @@ use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, ids_param, number_or, ok, + CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, }; use crate::EngineError; mod brushes; +mod dynamic; mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; @@ -32,10 +33,12 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -pub(crate) use symbols::{natural_size, normalize, place_xf, set_natural_size}; +use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +use symbols::selected_instances; +pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol}; use symbols::{ - symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place, - symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update, + symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, + symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -125,7 +128,7 @@ pub fn specs() -> Vec { "Select All Unused", [], None, - "{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles and graphic styles count, and so do strokes in the art of a brush that is used", + "{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and graphic styles count, and so do strokes in the art of a brush that is used", has_doc, brush_unused ), @@ -134,7 +137,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art). scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -157,13 +160,21 @@ pub fn specs() -> Vec { brush_freehand ), // ----- symbols ----- - cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list), + cmd!( + query "symbol.list", + "Symbols", + [], + None, + "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic, nineSlice, type: static|dynamic}], current}", + has_doc, + symbol_list + ), cmd!( "symbol.new", "New Symbol…", [], None, - "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}", has_selection, symbol_new ), @@ -172,7 +183,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), @@ -190,7 +201,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}", has_selection, symbol_break_link ), @@ -199,16 +210,25 @@ pub fn specs() -> Vec { "Edit Symbol", [], None, - "{id?, ids?: [id…] (instead of id)} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}", - has_selection, - symbol_edit + "{id?, ids?: [id…] (instead of id), name?, x?, y?, artboard?: index=0 (the active artboard; the app passes it), viewCenter?: [x, y] (the app passes the view's center)} edit a symbol's art in symbol editing mode: the instance `id` (default: the selected instances, which must share one symbol) where it shows the art, else the symbol `name` (default: the current symbol) centered on (x, y) (default: the center of artboard `artboard`, or of the first artboard when there is no such artboard; in a document without artboards, viewCenter, else (0, 0)); every instance follows each edit and the instance stays linked (symbol.breakLink expands one); refused while a pattern or an opacity mask is edited → {name, layer, id?}", + has_doc, + super::symboledit::enter + ), + cmd!( + "symbol.exitEditing", + "Exit Symbol Editing Mode", + [], + None, + "{} leave symbol editing mode; after an edit, select the instance it was entered from; leaving without having changed the art adds no undo step and restores the selection from before entry (object.exitIsolation also leaves it) → {name?}", + has_doc, + super::symboledit::leave ), cmd!( "symbol.update", "Redefine Symbol", [], None, - "{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}", + "{name, ids?|id?} replace the symbol's art with the selection (instances update, keeping their appearance changes to the objects at paths the new art still has; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}", has_selection, symbol_update ), @@ -217,7 +237,7 @@ pub fn specs() -> Vec { "Delete Symbol", [], None, - "{name, expandInstances?: true} delete a symbol; its instances are expanded (or deleted with false)", + "{name | names: [name…], expandInstances?: true} delete a symbol (or several, in one undo step); its instances are expanded (or deleted with false)", has_doc, symbol_delete ), @@ -227,10 +247,28 @@ pub fn specs() -> Vec { "Replace Symbol", [], None, - "{name, ids?|id?} swap the symbol of the selected instances → {ids}", + "{name, ids?|id?} swap the symbol of the selected instances (an instance given a different symbol drops its appearance changes; one given its own symbol keeps them) → {ids}", has_selection, symbol_replace ), + cmd!( + "symbol.instance.setAppearance", + "Symbol Instance Appearance", + [], + None, + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + has_selection, + symbol_instance_set_appearance + ), + cmd!( + "symbol.instance.reset", + "Reset Symbol Instance", + [], + None, + "{ids?|id?} drop the selected dynamic symbol instances' own appearance changes, so they show the symbol's appearance again → {ids: the instances that had changes}", + has_selection, + symbol_instance_reset + ), cmd!( "symbol.setCurrent", "Current Symbol", @@ -240,6 +278,15 @@ pub fn specs() -> Vec { has_doc, symbol_set_current ), + cmd!( + query "symbol.unused", + "Select All Unused", + [], + None, + "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", + has_doc, + symbol_unused + ), cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances), cmd!( "symbol.spray", @@ -267,7 +314,7 @@ fn ids_json(ids: &[NodeId]) -> Value { } pub(crate) fn roots(s: &Session, p: &Value) -> Result> { - match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + match named_ids(p)? { Some(mut v) => { // Explicit ids: only existing, non-layer objects, none inside another of them. let d = &s.doc()?.doc; diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 66dd546e9..9cc52c82c 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -362,14 +362,15 @@ fn art_brushes(n: &Node, out: &mut Vec) { } /// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph -/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush -/// that a counted stroke paints with. +/// designs, pattern tiles and the tiles that pattern editing's Cancel puts back, and graphic styles +/// count, and so do the strokes in the art of every brush that a counted stroke paints with. pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; let lib = brush::library(d); let mut todo = vec![]; let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); - trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); + let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); + trees.chain(patterns.flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); let mut used = BTreeSet::new(); while let Some(name) = todo.pop() { @@ -389,7 +390,77 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result { let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); if let Some(params) = p.get("params") { + let original = v.clone(); merge(&mut v, params); + if lib[i].kind.type_id() == "pattern" { + let tiles = [ + ("side_tile", "side"), + ("outer_corner_tile", "outer_corner"), + ("inner_corner_tile", "inner_corner"), + ("start_tile", "start"), + ("end_tile", "end"), + ]; + // Resolve all Original choices against the brush before any tile can clear the art + // another corner borrows. Keep the matching frame with each copied tile. + let originals: Vec<_> = tiles + .iter() + .map(|(_, art)| { + let from = if art.ends_with("corner") + && original.get(art).is_none_or(Value::is_null) + && original.get(if *art == "outer_corner" { "outer_source" } else { "inner_source" }).is_none_or(Value::is_null) + { + if *art == "outer_corner" { "inner_corner" } else { "outer_corner" } + } else { + *art + }; + (original.get(from).cloned().unwrap_or(Value::Null), original.get(format!("{from}_frame")).cloned().unwrap_or(Value::Null)) + }) + .collect(); + for ((tile, art), (original_art, original_frame)) in tiles.into_iter().zip(originals) { + let frame = format!("{art}_frame"); + let swatch = format!("{art}_swatch"); + let Some(choice) = params.get(tile) else { + if params.get(art).is_some() { + if params.get(frame.as_str()).is_none() { + v[frame.as_str()] = Value::Null; + } + if params.get(swatch.as_str()).is_none() { + v[swatch.as_str()] = Value::Null; + } + } + continue; + }; + let (choice, swatch_only) = if let Some(name) = choice.as_object() { + (name.get("pattern").and_then(Value::as_str).ok_or_else(|| bad(C, "invalid pattern tile choice"))?, true) + } else { + (choice.as_str().ok_or_else(|| bad(C, "invalid pattern tile choice"))?, false) + }; + if art == "side" && choice == "none" && !swatch_only { + return Err(bad(C, "You must choose a side pattern.")); + } + if choice == "original" && !swatch_only { + v[art] = original_art; + v[frame.as_str()] = original_frame; + v[swatch.as_str()] = Value::Null; + } else if !swatch_only && (choice == "none" || (art.ends_with("corner") && brush::CornerSource::parse(choice).is_some())) { + v[art] = Value::Null; + v[frame.as_str()] = Value::Null; + v[swatch.as_str()] = Value::Null; + if art.ends_with("corner") { + v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = json!(choice); + } + } else { + let pattern = s.doc()?.doc.pattern(choice).ok_or_else(|| bad(C, format!("no pattern named `{choice}`")))?; + let copy = Node::new(NodeId(0), NodeKind::Group { children: pattern.art.clone(), clip: false }); + v[art] = serde_json::to_value(copy).unwrap_or_default(); + v[frame.as_str()] = serde_json::to_value(pattern.tile).unwrap_or_default(); + v[swatch.as_str()] = json!(choice); + if art.ends_with("corner") { + v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = Value::Null; + } + } + } + } } let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(nn) = &new_name { diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs new file mode 100644 index 000000000..baa721c45 --- /dev/null +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -0,0 +1,151 @@ +//! Dynamic symbols (Symbol Options › Symbol Type): an instance of a dynamic symbol changes the +//! appearance of objects in the symbol's art and stays linked to the symbol +//! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance +//! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still +//! there ([`fit_overrides`]). + +use std::sync::Arc; + +use vectorcraft_color::BlendMode; +use vectorcraft_doc::symbol_overrides::{self, SymbolOverride}; + +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, ids_json, json, roots, selected_instances}; + +/// The `type` param, Symbol Options › Symbol Type: `Some(true)` for dynamic. +pub(super) fn type_param(p: &Value, cmd: &str) -> Result> { + let Some(v) = p.get("type").filter(|v| !v.is_null()) else { return Ok(None) }; + match v.as_str() { + Some("static") => Ok(Some(false)), + Some("dynamic") => Ok(Some(true)), + _ => Err(bad(cmd, format!("type must be static or dynamic, not {v}"))), + } +} + +/// The `type` a symbol reports. +pub(super) fn type_id(dynamic: bool) -> &'static str { + if dynamic { "dynamic" } else { "static" } +} + +/// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or +/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art. +pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { + for id in ids { + let art = match d.node(*id) { + Some(n) if !n.extras().symbol_overrides.is_empty() => match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).filter(|s| s.dynamic).map(|s| s.art.clone()), + _ => continue, + }, + _ => continue, + }; + let Some(n) = d.node_mut(*id) else { continue }; + let mut e = n.extras_mut(); + match art { + Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art), + None => e.symbol_overrides.clear(), + } + } +} + +/// The instance `id` names (else the one selected object) and its symbol's art; the symbol must +/// be dynamic. +fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc)> { + let d = &s.doc()?.doc; + let not_one = || EngineError::Other("select a symbol instance".into()); + let [id] = roots(s, p)?[..] else { return Err(not_one()) }; + let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node(id).map(|n| &n.kind) else { return Err(not_one()) }; + let sym = d.symbols.iter().find(|x| x.name == *symbol).ok_or_else(|| bad(cmd, format!("no symbol named `{n}`", n = symbol)))?; + if !sym.dynamic { + let name = &sym.name; + return Err(bad(cmd, format!("`{name}` is a static symbol: set its Symbol Type to Dynamic first"))); + } + Ok((id, sym.art.clone())) +} + +/// The `path` param: child indices from the art's root, naming an object of `art`. +fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { + let path: Option> = + p.get("path").and_then(Value::as_array).and_then(|a| a.iter().map(|i| i.as_u64().and_then(|i| u32::try_from(i).ok())).collect()); + let path = path.ok_or_else(|| bad(cmd, "path must be a list of child indices"))?; + if symbol_overrides::part(art, &path).is_none() { + let shown = json!(path); + return Err(bad(cmd, format!("no object at path {shown} in the symbol's art"))); + } + Ok(path) +} + +/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or +/// an object of `paint.setFill`'s paint params sets it. +fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { + let Some(v) = p.get(key) else { return Ok(None) }; + if v.is_null() { + return Ok(Some(None)); + } + let v = if v.is_string() { json!({ "color": v }) } else { v.clone() }; + let paint = crate::cmd::paint::paint_from(s, &v)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; + Ok(Some(Some(paint))) +} + +/// The change `p` asks of the override `old` (for the object at `path`). +fn changed(s: &Session, p: &Value, old: SymbolOverride, cmd: &str) -> Result { + let mut o = old; + if let Some(fill) = paint_change(s, p, "fill", cmd)? { + o.fill = fill; + } + if let Some(stroke) = paint_change(s, p, "stroke", cmd)? { + o.stroke = stroke; + } + check_numbers(p, &["opacity"], cmd)?; + if let Some(v) = p.get("opacity") { + o.opacity = v.as_f64().map(crate::cmd::opacitymask::percent); + } + if let Some(v) = p.get("blend") { + o.blend = match v { + Value::Null => None, + b => Some(b.as_str().and_then(BlendMode::parse).ok_or_else(|| bad(cmd, format!("unknown blend mode {b}")))?), + }; + } + Ok(o) +} + +pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.setAppearance"; + if !["fill", "stroke", "opacity", "blend"].iter().any(|k| p.get(k).is_some()) { + return Err(bad(C, "give fill, stroke, opacity and/or blend")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + let d = &s.doc()?.doc; + let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); + let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; + let count = s.edit("Symbol Instance Appearance", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + })?; + Ok(json!({ "id": id.0, "overrides": count })) +} + +pub(super) fn symbol_instance_reset(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let d = &s.doc()?.doc; + let changed: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.extras().symbol_overrides.is_empty())).collect(); + if !changed.is_empty() { + s.edit("Reset Symbol Instance", |d, _| { + for id in &changed { + if let Some(n) = d.node_mut(*id) { + n.extras_mut().symbol_overrides.clear(); + } + } + Ok(()) + })?; + } + Ok(json!({ "ids": ids_json(&changed) })) +} diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 8e8ba163e..9a3606311 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,3 +1,4 @@ +use super::dynamic::{fit_overrides, type_id, type_param}; use super::{ Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, @@ -46,7 +47,8 @@ fn export_type_param(p: &Value, cmd: &str) -> Result> { /// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural /// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it -/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). +/// on with guides `g` (the symbol's current ones, else a quarter in from each edge, when none are +/// given). fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { let on = match p.get("nineSlice") { None | Some(Value::Null) => None, @@ -106,6 +108,37 @@ fn instances_of(d: &Document, name: Option<&str>) -> Vec { v } +/// The symbols an instance in `n` (its opacity masks included) places, pushed onto `out`. +fn art_symbols(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + out.push(symbol.clone()); + } + }); +} + +/// Select All Unused (#4918): the symbols nothing places. Instances in the art (opacity masks +/// included), graph designs, pattern tiles and the tiles that pattern editing's Cancel puts back, +/// and the art of the document's brushes count, and so do the instances in the art of every symbol +/// a counted instance places. A symbol placed only inside an unused symbol is unused too. +pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let mut todo = vec![]; + let lib = brush::library(d); + let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); + let trees = d.layers.iter().map(|l| &**l).chain(d.graph_designs.iter().map(|g| &*g.art)); + trees.chain(patterns.flat_map(|p| p.art.iter().map(|n| &**n))).chain(lib.iter().flat_map(|b| b.art())).for_each(|n| art_symbols(n, &mut todo)); + let mut used = std::collections::BTreeSet::new(); + while let Some(name) = todo.pop() { + if !used.contains(&name) { + d.symbols.iter().filter(|sym| sym.name == name).for_each(|sym| art_symbols(&sym.art, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = d.symbols.iter().map(|sym| sym.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + fn symbol_names(d: &Document) -> Vec { d.symbols.iter().map(|s| s.name.clone()).collect() } @@ -128,12 +161,32 @@ pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id(), "nineSlice": nine_slice_json(d, &sym.name), + "type": type_id(sym.dynamic), }) }) .collect(); Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) } +/// Store `art` ([`normalize`]d, of natural size `size`) as symbol `name`, which becomes the +/// current symbol. +pub(crate) fn store_symbol(d: &mut Document, art: &Node, name: &str, size: (f64, f64)) { + let art = d.reid(art); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(d, name, Some(size)); + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); +} + +/// Replace the root nodes `ids` with one instance of symbol `name` (already stored) placed by +/// `xf`. Frames of threaded text among them leave their threads with the text they show. Returns +/// the instance's id. +pub(crate) fn instance_in_place(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { + let threaded = crate::cmd::threads::frames(d); + let id = replace_with_instance(d, ids, name, xf)?; + crate::cmd::threads::leave_threads(d, &threaded); + Ok(id) +} + /// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; @@ -160,26 +213,29 @@ pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); let export = export_type_param(p, C)?.unwrap_or_default(); let nine = nine_slice_param(p, C, None)?.flatten(); + let dynamic = type_param(p, C)?.unwrap_or(false); // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); let id = s.edit("New Symbol", |d, sel| { - let art = d.reid(&art); - d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); - set_natural_size(d, &name, Some(size)); + store_symbol(d, &art, &name, size); + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.dynamic = dynamic; + } d.set_symbol_registration(&name, reg); d.set_symbol_export_type(&name, export); d.set_symbol_nine_slice(&name, nine); - d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); - let threaded = crate::cmd::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place)?; - // Frames of threaded text in the symbol leave their threads with the text they show. - crate::cmd::threads::leave_threads(d, &threaded); + let id = instance_in_place(d, &ids, &name, place)?; sel.set([id]); Ok(id) })?; - Ok( - json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), - ) + Ok(json!({ + "name": name, + "id": id.0, + "registration": reg.0, + "exportType": export.id(), + "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value), + "type": type_id(dynamic), + })) } pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { @@ -188,9 +244,10 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { let export = export_type_param(p, C)?; let reg = registration_param(p, C)?; let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; + let dynamic = type_param(p, C)?; let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { - return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); + if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() && dynamic.is_none() { + return Err(bad(C, "pass newName, registration, exportType, nineSlice and/or type")); } if let Some(ref n) = new_name && n != &name @@ -218,6 +275,14 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); } + // Symbol editing mode follows the new name, and so does its layer. + if let Some(e) = d.symbol_edit.as_mut().filter(|e| e.symbol == name) { + e.symbol = n.clone(); + let layer = e.layer; + if let Some(l) = d.node_mut(layer) { + l.name = Some(n.clone()); + } + } } if let Some(r) = reg { d.set_symbol_registration(&final_name, r); @@ -228,6 +293,13 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { if let Some(g) = nine { d.set_symbol_nine_slice(&final_name, g); } + if let Some(dynamic) = dynamic + && let Some(sym) = d.symbols.iter_mut().find(|x| x.name == final_name) + { + // A static symbol's instances drop their own appearance changes. + sym.dynamic = dynamic; + fit_overrides(d, &instances_of(d, Some(&final_name))); + } Ok(()) })?; let d = &s.doc()?.doc; @@ -236,6 +308,7 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { "registration": d.symbol_registration(&final_name).0, "exportType": d.symbol_export_type(&final_name).id(), "nineSlice": nine_slice_json(d, &final_name), + "type": type_id(d.symbols.iter().any(|x| x.name == final_name && x.dynamic)), })) } @@ -266,17 +339,19 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0 })) } -/// The plain art of instance `inst` in document space (fresh ids). -fn expanded_instance(d: &mut Document, inst: &Node) -> Option { +/// The plain art of instance `inst` in document space (fresh ids), with the instance's own +/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice +/// guides when it has them. +fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; - let mut art = (*sym.art).clone(); + let mut art = if own { vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst) } else { (*sym.art).clone() }; if let Some(f) = inst.appearance.fill() && let Some(c) = f.paint.color() { brush::tint_node(&mut art, &c, f.opacity); } - art.transform(*xf, false); + let art = d.place_symbol_art(symbol, &art, *xf)?; let mut art = d.reid(&art); if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { if !matches!(art.kind, NodeKind::Group { .. }) { @@ -289,11 +364,35 @@ fn expanded_instance(d: &mut Document, inst: &Node) -> Option { Some(art) } -fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { +/// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its +/// symbol's object count with the art of its opacity masks, or +/// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus +/// the group that opacity or a blending mode adds. 0 for any other object. +fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 }; + let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 }; + let dynamic = own && !inst.extras().symbol_overrides.is_empty(); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if dynamic => m.most_placed(&vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst), d), + Some(m) => m.most_placed(&sym.art, d), + None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64, + None => sym.art.count_with_masks() as u64, + }; + most.saturating_add(1) +} + +/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the +/// art's ids. Fails before any edit when the instances together would make more than +/// [`MAX_EXPAND_NODES`] objects. +fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { + let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own))); + if total > MAX_EXPAND_NODES as u64 { + return Err(EngineError::Other("Too many pieces to make at once".into())); + } let mut out = vec![]; for id in ids { let Some(inst) = d.node(*id).cloned() else { continue }; - let Some(art) = expanded_instance(d, &inst) else { continue }; + let Some(art) = expanded_instance(d, &inst, own) else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; let aid = art.id; d.remove(*id)?; @@ -303,7 +402,10 @@ fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { Ok(out) } -fn selected_instances(s: &Session, p: &Value) -> Result> { +/// How many objects [`break_links`] may make at once. +const MAX_EXPAND_NODES: usize = 1_000_000; + +pub(super) fn selected_instances(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let mut out = vec![]; for id in roots(s, p)? { @@ -324,86 +426,151 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no symbol instances selected".into())); } let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids)?; + let out = break_links(d, &ids, true)?; sel.set(out.iter().copied()); Ok(out) })?; Ok(json!({ "ids": ids_json(&out) })) } -pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result { - let ids = selected_instances(s, p)?; - let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; - let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { - Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), - _ => return Err(EngineError::Other("select a symbol instance".into())), - }; - let out = s.edit("Edit Symbol", |d, sel| { - let out = break_links(d, &[first])?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "name": name, "ids": ids_json(&out) })) -} - pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result { const C: &str = "symbol.update"; let name = name_param(p, C)?.to_string(); if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { return Err(bad(C, format!("no symbol named `{name}`"))); } + // Redefining the symbol being edited leaves symbol editing mode in the same step. + let session = s.doc()?.doc.symbol_edit.clone().filter(|e| e.symbol == name); + if session.is_some() { + crate::cmd::settle_interaction(s)?; + s.end_text_edit(); + } let ids = roots(s, p)?; + // The art on the editing layer can't redefine it: the symbol would hold an instance of itself. + if let Some(se) = &session { + let d = &s.doc()?.doc; + if ids.iter().any(|i| d.ancestry(*i).is_some_and(|a| a.contains(&se.layer))) { + return Err(EngineError::Other("a symbol can't hold an instance of itself".into())); + } + } let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); let (art, size, c) = normalize(art)?; let old = natural_size(&s.doc()?.doc, &name); + let editing = session.is_some(); let id = s.edit("Redefine Symbol", |d, sel| { + if editing { + crate::cmd::symboledit::finish(d, sel); + } let art = d.reid(&art); if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { sym.art = Arc::new(art); } set_natural_size(d, &name, Some(size)); let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); - for i in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { + let instances = instances_of(d, Some(&name)); + for i in &instances { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(*i).map(|n| &mut n.kind) { *xf *= fix; } } + // Each instance keeps its appearance changes to the objects the new art still has. + fit_overrides(d, &instances); let reg = d.symbol_registration(&name); - let threaded = crate::cmd::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; - // Frames of threaded text in the symbol leave their threads with the text they show. - crate::cmd::threads::leave_threads(d, &threaded); + let id = instance_in_place(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; sel.set([id]); Ok(id) })?; + if let Some(se) = &session { + crate::cmd::symboledit::leave_editing(s, se)?; + } Ok(json!({ "name": name, "id": id.0 })) } +/// How many levels of nested instances Delete Symbol expands before it gives up. +const MAX_DELETE_ROUNDS: usize = 256; + pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "symbol.delete"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + // `names` deletes several symbols in one undo step (the Symbols panel's Select All Unused, #4918). + let names: Vec = match p.get("names") { + Some(v) => { + let a = v.as_array().ok_or_else(|| bad(C, "names must be an array of symbol names"))?; + let mut names = Vec::with_capacity(a.len()); + for n in a { + let n = n.as_str().ok_or_else(|| bad(C, "names must be an array of symbol names"))?; + if !names.iter().any(|x: &String| x == n) { + names.push(n.to_string()); + } + } + if names.is_empty() { + return Err(bad(C, "names is empty")); + } + names + } + None => vec![name_param(p, C)?.to_string()], + }; + let d = &s.doc()?.doc; + if let Some(name) = names.iter().find(|n| !d.symbols.iter().any(|x| &x.name == *n)) { return Err(bad(C, format!("no symbol named `{name}`"))); } let expand = switch_or(p, "expandInstances", true, C)?; - s.edit("Delete Symbol", |d, _| { - let inst = instances_of(d, Some(&name)); - if expand { - break_links(d, &inst)?; - } else { - remove_each(d, &inst)?; + // Deleting the symbol being edited leaves symbol editing mode in the same step. + let session = s.doc()?.doc.symbol_edit.clone().filter(|e| names.contains(&e.symbol)); + let editing = session.is_some(); + if editing { + crate::cmd::settle_interaction(s)?; + s.end_text_edit(); + } + let label = if names.len() == 1 { "Delete Symbol" } else { "Delete Symbols" }; + s.edit(label, |d, sel| { + if editing { + crate::cmd::symboledit::finish(d, sel); } - d.symbols.retain(|x| x.name != name); - set_natural_size(d, &name, None); - d.set_symbol_registration(&name, SymbolRegistration::CENTER); - d.clear_symbol_export_type(&name); - d.set_symbol_nine_slice(&name, None); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.remove(CURRENT_SYMBOL); + // Every instance goes before any definition, whatever the order of `names`: expanding an + // instance of one symbol can bring out instances of another being deleted (nested in its + // art), which expand in turn while their definitions are still there. Each round expands + // one level of nesting, so the rounds are capped. + let of_names = |d: &Document| { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && names.contains(symbol) + { + v.push(n.id); + } + }); + v + }; + for _ in 0..=MAX_DELETE_ROUNDS { + let inst = of_names(d); + if inst.is_empty() { + break; + } + if expand { + break_links(d, &inst, true)?; + } else { + remove_each(d, &inst)?; + } + } + if !of_names(d).is_empty() { + return Err(EngineError::Other("the symbols are nested too deeply to expand their instances".into())); + } + for name in &names { + d.symbols.retain(|x| &x.name != name); + set_natural_size(d, name, None); + d.set_symbol_registration(name, SymbolRegistration::CENTER); + d.clear_symbol_export_type(name); + d.set_symbol_nine_slice(name, None); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.remove(CURRENT_SYMBOL); + } } Ok(()) })?; + if let Some(se) = &session { + crate::cmd::symboledit::leave_editing(s, se)?; + } ok() } @@ -420,7 +587,7 @@ pub(super) fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { let nine = d.symbol_nine_slice(&name); s.edit("Duplicate Symbol", |d, _| { let art = d.reid(&sym.art); - d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: sym.dynamic, name: new.clone(), art: Arc::new(art) }); set_natural_size(d, &new, Some(size)); d.set_symbol_registration(&new, reg); d.set_symbol_export_type(&new, export); @@ -443,15 +610,21 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { s.edit("Replace Symbol", |d, _| { let new = natural_size(d, &name); for id in &ids { - let old = match d.node(*id).map(|n| &n.kind) { - Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), + let (old, other) = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => (natural_size(d, symbol), *symbol != name), _ => continue, }; - if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::SymbolInstance { symbol, xf } = &mut n.kind { *symbol = name.clone(); *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); } + // An override's path names an object of the old symbol's art. + if other && !n.extras().symbol_overrides.is_empty() { + n.extras_mut().symbol_overrides.clear(); + } } + fit_overrides(d, &ids); Ok(()) })?; Ok(json!({ "ids": ids_json(&ids) })) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index bdfed0175..56ff8a636 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,7 +29,7 @@ use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, ids_param, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids, number_or, point_param, str_param, switch_or, }; @@ -220,7 +220,7 @@ fn has_multi_or_ids(s: &Session) -> std::result::Result<(), String> { } fn root_ids(s: &Session, p: &Value) -> Result> { - let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + let ids = match named_ids(p)? { Some(v) => v, None => selected_roots(s)?, }; diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 308e300ec..00bcc1c83 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use vectorcraft_color::Swatch; use vectorcraft_doc::ImageObject; @@ -220,18 +222,16 @@ fn trace_params(s: &Session, c: &str, p: &Value) -> Result { Ok(params) } -/// Document swatch `w`'s solid color with the paint a trace fills with it: a global or spot color, -/// or a tint of one, linked to its swatch as `swatch_solid` (paint.rs) links it; any other color -/// unlinked. The color is the one the paint shows (a Lab spot color's CMYK equivalent when the Spot -/// Colors options use CMYK values). +/// Document swatch `w`'s displayed solid color and unlinked trace paint. A tint uses its tinted +/// color; a Lab spot color uses its CMYK equivalent when the Spot Colors options use CMYK values. fn trace_swatch_paint(d: &Document, w: &Swatch) -> Option<(Color, Paint)> { let linked = d.swatch_link(&w.name).and_then(|(base, tint)| d.tint_paint(&base, tint)); let color = linked.as_ref().and_then(Paint::color).or_else(|| w.paint.color())?; - Some((color, linked.unwrap_or_else(|| Paint::solid(color)))) + Some((color, Paint::solid(color))) } /// Document Library with `colorGroup`: the solid colors of the active document's color group -/// `name` (so named exactly, else ignoring case), each with its paint ([`trace_swatch_paint`]). +/// `name` (so named exactly, else ignoring case), each with its unlinked paint ([`trace_swatch_paint`]). /// Errors name command `c`. ([`trace_params`] has checked that the library is the document's /// swatches.) fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { @@ -247,9 +247,7 @@ fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result Result> { if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); @@ -557,6 +555,12 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result = HashMap::new(); + for (rgb, paint) in &library { + by_color.entry(*rgb).or_insert(paint); + } let id = s.edit(label, |d, sel| { let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; let mut image = if is_trace_group(&src) { @@ -584,12 +588,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result = std::collections::HashMap::new(); + for (i, b) in lib.iter().enumerate() { + at.entry(b.name.clone()).or_insert(i); + } for b in &self.brushes { - match lib.iter_mut().find(|x| x.name == b.name) { + match at.get(&b.name).and_then(|&i| lib.get_mut(i)) { Some(x) => *x = b.clone(), - None => lib.push(b.clone()), + None => { + at.insert(b.name.clone(), lib.len()); + lib.push(b.clone()); + } } } vectorcraft_brush::store(&mut d, &lib); diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index c4d5a0712..b2e1114e1 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -125,6 +125,10 @@ pub struct Flavour { /// Longest side (px) of the PNG Copy offers: larger art is scaled down to fit. const COPY_PNG_SIDE: f64 = 4096.0; +/// The smallest scale (pixels a point) the PNG encoder renders at. +const MIN_COPY_PNG_SCALE: f64 = 0.01; +/// What Copy says when the art is too large for its PNG. +const TOO_LARGE_FOR_PNG: &str = "The art is too large to copy as a picture: other apps get no PNG of it"; /// Most bytes of text `clipboard.importText` takes. const MAX_TEXT: usize = 1 << 20; /// Most bytes of images the SVG markup may embed and still go out as plain text. A paste reads the @@ -288,12 +292,24 @@ impl Clipboard { let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only); // Art without an area (a lone guide-like line) has no page or picture. let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0); - [(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect() + [(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area && self.png_scale().is_some())] + .into_iter() + .filter_map(|(m, on)| on.then_some(m)) + .collect() + } + + /// The scale (pixels a point) of the PNG Copy offers: at most [`COPY_PNG_SIDE`] pixels a side, + /// and `None` when even [`MIN_COPY_PNG_SCALE`], the encoder's smallest, can't bring it that small + /// (rather than a PNG of up to a gigabyte of pixels). + fn png_scale(&self) -> Option { + let side = self.bounds().map_or(1.0, |b| b.width().max(b.height())); + Some((COPY_PNG_SIDE / side).min(1.0)).filter(|s| *s >= MIN_COPY_PNG_SCALE) } /// What Copy offers other apps, best first, with Include SVG Code (`svg`) and On Copy: PDF /// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large - /// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side. + /// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side, and + /// left out when even [`MIN_COPY_PNG_SCALE`] can't bring it that small. pub fn flavours(&self, svg: bool, pdf: bool) -> Vec { self.flavours_with_warnings(svg, pdf).0 } @@ -305,6 +321,9 @@ impl Clipboard { let svg = types.contains(&SVG).then(|| self.svg()).flatten(); let mut out = vec![]; let mut warnings: Vec = vec![]; + if !self.is_empty() && self.png_scale().is_none() { + warnings.push(TOO_LARGE_FOR_PNG.to_string()); + } let mut keep = |(bytes, notes): (Vec, Vec)| { for w in notes { if !warnings.contains(&w) { @@ -318,11 +337,10 @@ impl Clipboard { TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes), SVG => svg.clone().map(String::into_bytes), PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep), - _ => { - let side = self.bounds().map_or(1.0, |b| b.width().max(b.height())); - let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0); - self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes))) - } + _ => self + .png_scale() + .and_then(|scale| self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten()) + .map(|(png, _, _, notes)| keep((png, notes))), }; out.extend(data.map(|data| Flavour { mime, data })); } @@ -413,14 +431,38 @@ fn flavours(s: &mut Session, _: &Value) -> Result { Ok(json!({ "flavours": s.clipboard_flavour_types() })) } +/// A bitmap read as `clipboard.importImage` reads its `dataBase64` ([`read_image`]), for +/// [`Session::import_image_read`]. +pub struct ImageRead(fileio::RasterImage); + +/// Read `bytes` as `clipboard.importImage` reads its `dataBase64`, on any thread: the formats it +/// stores as PNG are decoded and encoded here. +pub fn read_image(bytes: &[u8]) -> Result { + let mut img = fileio::raster_image(bytes).map_err(|e| bad("clipboard.importImage", e.to_string()))?; + img.blob = links::readable_and_kept(img.blob, links::kept_unread(bytes), false).map_err(|e| bad("clipboard.importImage", e.to_string()))?; + Ok(ImageRead(img)) +} + +impl Session { + /// `clipboard.importImage` with `p` (its `mime` and `center`) for an image [`read_image`] read + /// (off the UI thread in the desktop app), which it loads instead of reading `dataBase64`. + pub fn import_image_read(&mut self, read: ImageRead, p: &Value) -> Result { + self.image_hand_off = Some(read); + let r = self.execute("clipboard.importImage", p); + self.image_hand_off = None; + r + } +} + fn import_image(s: &mut Session, p: &Value) -> Result { const C: &str = "clipboard.importImage"; if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) { return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText"))); } - let data = data_param(p, C)?; - let mut img = fileio::raster_image(&data).map_err(|e| bad(C, e.to_string()))?; - img.blob = links::readable_and_kept(img.blob, links::kept_unread(&data), false).map_err(|e| bad(C, e.to_string()))?; + let ImageRead(img) = match s.image_hand_off.take() { + Some(read) => read, + None => read_image(&data_param(p, C)?)?, + }; let (sx, sy) = pt_per_px(img.ppi); let (w, h) = (img.width, img.height); let image = ImageObject { diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index e6bd96371..6a436ab31 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -14,7 +14,7 @@ //! uses its resources as they are now. use std::cell::OnceCell; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; use serde_json::{Map, Value, json}; @@ -149,6 +149,39 @@ struct Defs<'a> { char_styles: &'a [TextStyleDef], para_styles: &'a [TextStyleDef], brushes: Brushes<'a>, + /// Where each name is defined, built the first time a kind is followed. + index: DefIndex, +} + +/// The positions of a kind's definitions by name. +type NameIndex = HashMap>; + +/// [`Defs`]' definitions by name (graphic styles by id), each kind indexed the first time a +/// reference to it is followed: finding a name's definitions by scanning them all made a +/// document or library of N definitions that use each other cost N² comparisons. +#[derive(Default)] +struct DefIndex { + swatches: OnceCell, + symbols: OnceCell, + patterns: OnceCell, + styles: OnceCell>>, + char_styles: OnceCell, + para_styles: OnceCell, + brushes: OnceCell, +} + +/// The items of `list` named `name`, in order, `index` holding their positions (built from +/// `list` the first time). +fn named<'l, T>(index: &OnceCell, list: &'l [T], name_of: impl Fn(&T) -> &str, name: &str) -> impl Iterator { + let index = index.get_or_init(|| { + let mut by: NameIndex = HashMap::new(); + for (i, item) in list.iter().enumerate() { + by.entry(name_of(item).to_string()).or_default().push(i); + } + by + }); + let at: &[usize] = index.get(name).map_or(&[], Vec::as_slice); + at.iter().filter_map(move |&i| list.get(i)) } /// The brushes references lead to: a document's library, read the first time a brush is used @@ -178,6 +211,7 @@ impl<'a> Defs<'a> { char_styles: &d.char_styles, para_styles: &d.para_styles, brushes: Brushes::Lazy(d, brushes), + index: DefIndex::default(), } } } @@ -219,8 +253,16 @@ impl Used { fn note<'a>(&mut self, defs: &Defs<'a>, r: Ref<'_>, todo: &mut Vec>) { let (res, name) = match r { Ref::Style(id) => { - if self.styles.insert(id) { - todo.extend(defs.styles.iter().filter(|g| g.id != 0 && g.id == id).map(|g| Source::Appearance(&g.appearance))); + if self.styles.insert(id) && id != 0 { + let index = defs.index.styles.get_or_init(|| { + let mut by: HashMap> = HashMap::new(); + for (i, g) in defs.styles.iter().enumerate() { + by.entry(g.id).or_default().push(i); + } + by + }); + let at = index.get(&id).map_or(&[][..], Vec::as_slice); + todo.extend(at.iter().filter_map(|&i| defs.styles.get(i)).map(|g| Source::Appearance(&g.appearance))); } return; } @@ -237,15 +279,16 @@ impl Used { return; } set.insert(name.to_string()); + let ix = &defs.index; match res { // A tint swatch's base, a gradient swatch's linked stops. - Res::Swatch => todo.extend(defs.swatches.iter().filter(|w| w.name == name).map(|w| Source::Paint(&w.paint))), - Res::Symbol => todo.extend(defs.symbols.iter().filter(|s| s.name == name).map(|s| Source::Node(&s.art))), - Res::Pattern => todo.extend(defs.patterns.iter().filter(|p| p.name == name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))), - Res::CharStyle => todo.extend(defs.char_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))), - Res::ParaStyle => todo.extend(defs.para_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))), + Res::Swatch => todo.extend(named(&ix.swatches, &defs.swatches, |w| &w.name, name).map(|w| Source::Paint(&w.paint))), + Res::Symbol => todo.extend(named(&ix.symbols, defs.symbols, |s| &s.name, name).map(|s| Source::Node(&s.art))), + Res::Pattern => todo.extend(named(&ix.patterns, defs.patterns, |p| &p.name, name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))), + Res::CharStyle => todo.extend(named(&ix.char_styles, defs.char_styles, |s| &s.name, name).map(|s| Source::Attrs(&s.attrs))), + Res::ParaStyle => todo.extend(named(&ix.para_styles, defs.para_styles, |s| &s.name, name).map(|s| Source::Attrs(&s.attrs))), // A scatter or art brush's art, a pattern brush's tiles. - Res::Brush => todo.extend(defs.brushes.get().iter().filter(|b| b.name == name).flat_map(|b| b.art().into_iter().map(Source::Node))), + Res::Brush => todo.extend(named(&ix.brushes, defs.brushes.get(), |b| &b.name, name).flat_map(|b| b.art().into_iter().map(Source::Node))), _ => {} } } @@ -649,6 +692,7 @@ impl Clipboard { char_styles: &self.char_styles, para_styles: &self.para_styles, brushes: Brushes::Ready(&self.brushes), + index: DefIndex::default(), }; Used::of(&defs, &self.nodes) } @@ -671,7 +715,7 @@ impl Clipboard { self.import_swatches(d, &used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art // they may brush; the others once those are in. - let mut added_brushes = self.import_brushes(d, &used, same_doc, false, &mut out); + let mut added_brushes: HashSet = self.import_brushes(d, &used, same_doc, false, &mut out).into_iter().collect(); // Character and paragraph styles (their colours relinked first). for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] { for def in mine.iter().filter(|s| used.has(r, &s.name)) { @@ -683,14 +727,14 @@ impl Clipboard { } // Symbols and patterns, compared once relinked. Those added are relinked at the end, when // every new name and graphic style id is known. - let mut added_symbols = vec![]; + let mut added_symbols = HashSet::new(); for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) { let r = &out.relink; let p = place(&mut d.symbols, def, same_doc, |a, b| same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art))); added_symbols.extend(p.new_name(&def.name)); out.placed(Res::Symbol, &def.name, &p); } - let mut added_patterns = vec![]; + let mut added_patterns = HashSet::new(); for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) { // Tile options alike, and the art once relinked. let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 49b729c8a..4ab859740 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", has_selection, |s, p| recolor(s, p, "Invert Colors", &invert) ), @@ -178,10 +178,10 @@ impl Scope { /// One recolouring pass, the colour visitor Edit Colors and Recolor Artwork share: `f` maps the /// solid colours and gradient stops of fills, strokes and text runs, gradient-mesh points, the -/// pixels of embedded images and the art of patterns used as fills or strokes. A recoloured image -/// becomes a new image and a recoloured pattern a new pattern swatch ("Dots 2"); the originals stay -/// as they are for other objects. Colours that change lose their swatch link. Only nodes that -/// change are kept as copies. +/// pixels of embedded images without a colorize paint and the art of patterns used as fills or +/// strokes. A recoloured image becomes a new image and a recoloured pattern a new pattern swatch +/// ("Dots 2"); the originals stay as they are for other objects. Colours that change lose their +/// swatch link. Only nodes that change are kept as copies. pub(crate) struct Recolor<'a> { f: &'a dyn Fn(Color) -> Color, /// Maps solid colours and gradient stops with their link and tint in place of `f`. @@ -335,13 +335,24 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) run_pass(s, p, label, Recolor::new(Scope::of(p), f)) } +/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch +/// link and tint. +pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { + run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links)) +} + /// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, }; - let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); + let n = s.edit(label, |d, _| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; + Ok(n) + })?; Ok(json!({ "changed": n })) } @@ -451,6 +462,7 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { fn fill_color(n: &Node) -> Option { match &n.kind { NodeKind::Text(t) => t.first_style().fill.color(), + NodeKind::Image(_) => n.extras().colorize.as_deref().and_then(Paint::color).or_else(|| n.appearance.fill_paint().color()), NodeKind::Mesh(m) => { let k = m.points.len() as f32; let sum = m.points.iter().map(|p| p.color.to_rgb()).reduce(|a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]])?; @@ -487,6 +499,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { BlendOrder::Vertical => "Blend Vertically", }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let changed = s.edit(label, |d, _| { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { @@ -504,6 +517,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; changed += Recolor::new(fills, &*f).run(d, &[*id]); } + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) })?; Ok(json!({ "changed": changed })) diff --git a/crates/engine/src/cmd/compoundshape.rs b/crates/engine/src/cmd/compoundshape.rs index 7c5aa3f5b..52c19a9a5 100644 --- a/crates/engine/src/cmd/compoundshape.rs +++ b/crates/engine/src/cmd/compoundshape.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ShapeMode}; use vectorcraft_render::effects; use super::edit::{roots_of, selected_roots}; -use super::pathops::{pf_leaves, shape_node, style_of}; +use super::pathops::{Style, pf_leaves, shape_node, style_of}; use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_multi}; const MODE_NAMES: [&str; 4] = ["add", "subtract", "intersect", "exclude"]; @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Make Compound Shape", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}", + "{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type, blends, envelopes) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed; a blend as its steps, an envelope as its distorted art, an object with a warp or other geometry effect as it draws), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}", has_multi, make ), @@ -140,19 +140,24 @@ fn make(s: &mut Session, p: &Value) -> Result { let mut members = Vec::with_capacity(roots.len()); for id in &roots { let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; - if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Text(_)) { - return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes and type")); + if !matches!( + n.kind, + NodeKind::Path { guide: false, .. } + | NodeKind::Compound { .. } + | NodeKind::Group { .. } + | NodeKind::Text(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + ) { + return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes")); } members.push(n); } // The paint and transparency of the topmost member in Add, Intersect or Exclude mode: as they // all have `mode`, the topmost, or the backmost when they all subtract. let order: Vec<&Node> = if mode == ShapeMode::Subtract { members } else { members.into_iter().rev().collect() }; - let style = order.into_iter().find_map(|n| { - let mut lv = vec![]; - pf_leaves(n, &mut lv); - lv.last().map(style_of) - }); + let hook = |c: &Node| effects::outline_object(Some(d), c); + let style = order.into_iter().find_map(|n| top_style(d, n, &hook)); let id = s.edit("Make Compound Shape", |d, sel| { let gid = d.alloc_id(); let id = d.wrap(&roots, |children| { @@ -177,6 +182,32 @@ fn make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0 })) } +/// The paint and transparency of the frontmost shape `n` adds to a compound shape's area, from the +/// same objects the area takes: a blend, an envelope or an object with a warp gives those of the +/// art it draws, without its geometry effects; a symbol instance those of its symbol's art (with +/// `hook`); type its first style; a compound shape its own; a group those of its frontmost member +/// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or +/// a warped group, also takes that object's opacity and blend mode. +fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Optionabc{}", + "abc{}", body.concat() ); survive(&svg, || vectorcraft_svg::import(&svg).ok())?; @@ -1228,6 +1230,16 @@ fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> { }) } +fn export_presets(what: &str, data: &str) -> Result<(), TestCaseError> { + survive_library(what, "document.exportPresets.import", data, |s, r| { + if let Some(name) = r["imported"].get(0) { + let _ = no_panic(s.execute("assets.settings.set", &json!({"exportPreset": name}))); + let _ = no_panic(s.execute("document.exportPresets.export", &json!({"names": [name]}))); + let _ = no_panic(s.execute("document.exportForScreens", &json!({"exportPreset": name, "range": "1"}))); + } + }) +} + /// Swatch exchange (`.ase`) bytes loaded as a swatch library, then added to the rich document. fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> { survive(what, || { @@ -1279,11 +1291,12 @@ proptest! { #![proptest_config(config())] #[test] - fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", ", "{\"name\": \"L\", "])) { + fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", ", "{\"format\": \"vcexportpresets\", ", "{\"name\": \"L\", "])) { let data = format!("{head}{s}"); swatches("swatch library garbage", &data)?; styles("style library garbage", &data)?; flattener_presets("flattener preset garbage", &data)?; + export_presets("export preset garbage", &data)?; libraries("library garbage", &data)?; } @@ -1305,6 +1318,12 @@ proptest! { flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?; } + #[test] + fn mutated_export_presets_never_panic(cut in 0usize..5_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + let text = saved("document.exportPresets.export", json!({"names": ["Logo Package", "Print-Ready PDF"]})); + export_presets("mutated export presets", &mutate_text(&text, cut, &edits))?; + } + #[test] fn mutated_libraries_never_panic(keep in 0.0f64..1.2, edits in prop::collection::vec(arb_edit(), 0..10)) { // Cut anywhere in the file (whole most of the time), the edits anywhere in it. @@ -1779,8 +1798,101 @@ proptest! { } } +/// Pixar picture files, 3 × 2 pixels: RGBA dumped in 8 bits, RGB dumped in 12 bits, RGBA encoded +/// with every packet kind and a block end, and gray in 2 × 1 tiles with a null tile. +fn pxr_samples() -> Vec<(&'static str, Vec)> { + let pxr = |tile: (u16, u16), format: u16, storage: u16, tiles: &[Option>]| { + let mut f = vec![0x80, 0xe8, 0, 0]; + f.resize(416, 0); + for v in [2, 3, tile.1, tile.0, format, storage, 16, 1, 0, 0] { + f.extend(u16::to_le_bytes(v)); + } + f.resize(512, 0); + let mut at = (512 + tiles.len() * 8).next_multiple_of(16); + let mut data: Vec = vec![]; + for t in tiles { + match t { + Some(bytes) => { + f.extend((at as u32).to_le_bytes()); + f.extend((bytes.len() as u32).to_le_bytes()); + data.extend(bytes); + at += bytes.len(); + } + None => f.extend([0; 8]), + } + } + f.resize((512 + tiles.len() * 8).next_multiple_of(16), 0); + f.extend(data); + f + }; + let rgba: Vec = (0..24u8).map(|i| i.wrapping_mul(41)).collect(); + let wide: Vec = (0..18u16).flat_map(|i| (i * 300).to_le_bytes()).collect(); + let mut packets = vec![0x01, 0x10, 1, 2, 3, 4, 5, 6, 7, 8, 0x00, 0x20, 0, 9, 9, 9, 9]; + packets.extend([0x00, 0x00]); + packets.resize(32, 0); + packets.extend([0x00, 0x30, 200, 1, 2, 3, 0x01, 0x40, 100, 0, 4, 5, 6, 0, 7, 8, 9]); + vec![ + ("a.pxr", pxr((3, 2), 15, 2, &[Some(rgba)])), + ("b.pxr", pxr((3, 2), 14, 3, &[Some(wide)])), + ("c.pxr", pxr((3, 2), 15, 0, &[Some(packets)])), + ("d.pxr", pxr((2, 1), 8, 2, &[Some(vec![1, 2]), Some(vec![3, 4]), None, Some(vec![5, 6])])), + ] +} + +proptest! { + #![proptest_config(config())] + + /// Mutated Pixar picture files (header fields, the tile pointer table, samples and packets) + /// never crash placing, opening or pasting them. + #[test] + fn mutated_pxr_files_place_without_panics(which in 0usize..4, cut in 0usize..640, edits in prop::collection::vec((400usize..640, any::()), 0..12)) { + let (name, mut bytes) = pxr_samples().swap_remove(which); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at) { + *x = b; + } + } + bytes.truncate(cut.max(4)); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8}); + let _ = no_panic(s.execute("file.place.info", &p)); + let _ = no_panic(s.execute("file.place", &p)); + let _ = no_panic(s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)}))); + let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))); + }); + prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); + } +} + // ---------- SVG saved with hidden layers and editing data ---------- +proptest! { + #![proptest_config(config())] + + /// Mutated JP2 boxes and raw codestream markers must return errors rather than unwind. + #[test] + fn mutated_jpeg2000_files_do_not_panic(raw in any::(), cut in 0usize..300, edits in prop::collection::vec((0usize..300, any::()), 0..8)) { + let (name, mut bytes) = if raw { + ("a.j2k", include_bytes!("fixtures/jpeg2000/checker-raw.j2k").to_vec()) + } else { + ("a.jp2", include_bytes!("fixtures/jpeg2000/checker.jp2").to_vec()) + }; + for (at, byte) in edits { + if let Some(slot) = bytes.get_mut(at) { *slot = byte; } + } + bytes.truncate(cut); + let result = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let params = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)}); + let _ = no_panic(s.execute("document.open", ¶ms)); + let _ = no_panic(s.execute("file.place", ¶ms)); + }); + prop_assert!(result.is_ok(), "{name}: panicked: {:?}", result.err()); + } +} + /// The rich document with a hidden layer, saved as SVG with its editing data. fn saved_svg() -> String { let mut d = rich_doc(); @@ -2045,10 +2157,11 @@ proptest! { /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its /// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which - /// the editing data reader reads from the program too. + /// the editing data reader reads from the program too, or after a setup without a layer, which + /// it reads with the art on one layer. #[test] fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::(), layers in any::()) { - let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" }; + let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def\n%%BeginSetup\n%%EndSetup" } else { "%%EndComments" }; let (open, close) = if layers { ("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--") } else { @@ -2598,11 +2711,12 @@ fn ascii85(data: &[u8]) -> String { const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n"; /// The swatches in editing data: process, spot (one the art doesn't use), global, a gradient that -/// isn't defined and a pattern, a colour group, and versioned content read once. -const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n1 1 1 1 ([Registration]) 0 Xs\n([Registration])\nPc\n\ +/// isn't defined and a pattern, a colour group, versioned content read once, and the None swatch +/// and a swatch without a name, as version 7 files write them. +const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\nPn\nPc\n%AI17_Begin_Content_if_version_gt:24 15\n1 1 1 1 ([Registration]) 0 Xs\n([Registration])\nPc\n\ 0 0 0 1 k\n(Black)\nPc\n0.1 0.9 0.8 0 (Unused ink) 0 x\n(Unused ink)\nPc\n0 0 0 1 0 0 0 (Black global) 0 1 Xk\n(Black global)\nPc\n\ Bb\n2 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(G)\nPc\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\n\ - %AI17_Alternate_Content\n0 0 0 1 k\n(Black)\nPc\n%AI17_End_Versioned_Content\n1 (Greys) 1 Pg\n0 0 0 0.5 k\n(K50)\nPc\nPB\n%AI5_EndPalette\n"; + %AI17_Alternate_Content\n0 0 0 1 k\n(Black)\nPc\n%AI17_End_Versioned_Content\n1 (Greys) 1 Pg\n0 0 0 0.5 k\n(K50)\nPc\n0.75 g\nPc\nPB\n%AI5_EndPalette\n"; /// The editing data of a file with every sort of object the layers reader knows, and some it doesn't. fn editing_text() -> String { @@ -2619,6 +2733,9 @@ fn editing_text() -> String { square(40), "50 10 m 60 10 L 60 20 L 50 20 L h W f".into(), "Q".into(), + // Smooth and corner anchors by the case of each segment's operator, back to the start. + "10 50 m 20 60 30 50 40 50 c 45 50 50 55 v 55 60 60 60 Y 60 50 l 50 40 30 40 10 50 C s".into(), + "70 50 m 80 50 l 80 60 L 70 60 y h S".into(), "1 Xw".into(), "u".into(), "/AI11Text :\n0 /FreeUndo ,\n;".into(), @@ -2652,6 +2769,39 @@ fn zstd(text: &str) -> Vec { ruzstd::encoding::compress_to_vec(text.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest) } +/// A one-page `.ai` whose page's content stream has decode parameters the import refuses (#4366), +/// carrying `private` in one stream with the entries `filter`, under the key `{key}1` of its +/// `PieceInfo`; with an object stream of entries `object_stream` when they aren't empty. +fn refused_ai(private: &[u8], key: &str, filter: &str, object_stream: &str) -> Vec { + let stream = |dict: &str, data: &[u8]| [format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(); + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /PieceInfo << /Illustrator << /Private << /{key}1 5 0 R /NumBlock 1 >> >> >> >>") + .into_bytes(), + stream("/DecodeParms << /Predictor 12 /Columns 0 >>", b"0 g 10 10 20 20 re f"), + stream(filter, private), + ]; + if !object_stream.is_empty() { + objects.push(stream(object_stream, b"8 0 << >>")); + } + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offsets = vec![]; + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n", i + 1).bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes()); + for off in offsets { + out.extend(format!("{off:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes()); + out +} + /// An EPS drawing a square whose private data holds `editing`. fn editing_eps(editing: &str) -> Vec { let lines: Vec = ascii85(&zstd(editing)).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); @@ -2672,8 +2822,9 @@ fn the_editing_fixture_reads_its_palette() { let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect(); assert_eq!(swatches, ["[None]", "Black", "Unused ink", "Black global", "Dots"]); assert!(doc.swatch("Unused ink").is_some_and(|s| s.spot && s.global)); - let groups: Vec<(&str, usize)> = doc.swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect(); - assert_eq!(groups, [("Greys", 1)]); + let groups: Vec<(&str, Vec<&str>)> = + doc.swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|s| s.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Greys", vec!["K50", "Gray K=25"])], "the swatch without a name is named by its values"); } /// The fuzzed editing data, as it is, reads its CMYK image with its ink amounts and its alpha @@ -2727,6 +2878,48 @@ fn frames_document_text() -> String { ) } +/// The text document of vertical type (key 1 of what each frame carries at 2) as an editing copy +/// keeps it: point type in two columns with a Tate-chu-yoko run (key 35 at 3), area type and type +/// on a path. +fn vertical_document_text() -> String { + let area = "<< /0 << /0 [ 0 0 ] /1 << /0 [ 8151.5 8171.5 8151.5 8171.5 8201.5 8171.5 8201.5 8171.5 8201.5 8171.5 8201.5 8171.5 8201.5 8221.5 8201.5 8221.5 8201.5 8221.5 8201.5 8221.5 8151.5 8221.5 8151.5 8221.5 8151.5 8221.5 8151.5 8221.5 8151.5 8171.5 8151.5 8171.5 ] >> /2 << /0 1 /1 2 >> >> >>"; + let path = "<< /0 << /0 [ 0 0 ] /1 << /0 [ 8151.5 8171.5 8151.5 8171.5 8191.5 8161.5 8191.5 8171.5 8191.5 8171.5 8211.5 8181.5 8231.5 8171.5 8231.5 8171.5 ] >> /2 << /0 2 /1 2 /6 [ 0.5 2.0 ] >> >> >>"; + let point = "<< /0 << /0 [ 0 0 ] /2 << /1 2 /2 [ 1 0 0 1 3 4 ] >> >> >>"; + let run = |n: usize, own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 12.0 {own}>> >> >> /1 {n} >>"); + let story = |text: &str, frame: usize, runs: &str, layout: &str| { + format!("<< /0 << /0 ({text}) /6 << /0 [ {runs} ] >> >> /1 << /0 [ << /0 {frame} >> ] {layout}>> >>") + }; + let columns = "/2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8200.0 8180.0 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ \ + << /99 /L /6 [ << /99 /S /0 << /0 [ 0.0 0.0 ] >> >> ] >> << /99 /L /0 << /0 [ -6.0 14.4 ] >> /6 [ << /99 /S /0 << /0 [ 0.0 0.0 ] >> >> ] >> \ + ] >> ] >> ] >> ] >> ] "; + format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {point} {area} {path} ] >> >>\n\ + /1 << /1 [ {} {} {} ] /2 << /1 12.0 /35 1 /47 0 /48 0 >> >>\n", + story("Day 12\\rnext\\r", 0, &format!("{} {} {}", run(4, ""), run(2, "/35 3 /47 -2 /48 3 "), run(6, "/35 2 ")), columns), + story("Vertical area type wraps\\r", 1, &run(25, ""), ""), + story("On a path\\r", 2, &run(10, "/35 3 "), ""), + ) +} + +/// The text objects of [`vertical_document_text`]: one per story. +const VERTICAL_OBJECTS: &str = "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n1 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n2 /StoryIndex ,\n;\n"; + +/// The fuzzed vertical fixture, as it is, reads as vertical point, area and path type, with the +/// Tate-chu-yoko run marked. +#[test] +fn the_vertical_fixture_reads_as_vertical_type() { + let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&vertical_document_text(), VERTICAL_OBJECTS)).unwrap(); + let mut found = vec![]; + l.doc.walk(|n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + let tcy: Vec<&str> = t.runs.iter().filter(|r| r.style.tate_chu_yoko).map(|r| r.text.as_str()).collect(); + found.push((t.plain_text(), t.vertical, tcy.join("|"))); + } + }); + let want = [("Day 12\nnext", true, "12"), ("Vertical area type wraps", true, ""), ("On a path", true, "On a path")]; + assert_eq!(found, want.map(|(a, b, c)| (a.to_string(), b, c.to_string())), "{:?}", l.warnings); +} + /// The text objects of [`frames_document_text`]: story 0 in its two frames, story 1 in its one (a /// text object names its frame among its story's). const FRAME_OBJECTS: &str = "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n1 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n1 /StoryIndex ,\n;\n"; @@ -2774,19 +2967,170 @@ fn text_eps(document: &str) -> Vec { /// An EPS with the text `objects` on a hidden layer, their stories in `document`. fn text_eps_of(document: &str, objects: &str) -> Vec { + editing_eps(&text_editing_of(document, objects)) +} + +/// The editing data of [`text_eps_of`]. +fn text_editing_of(document: &str, objects: &str) -> String { let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); - let editing = format!( + format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\ %AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n{objects}LB\n%AI5_EndLayer--\n\ %AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", lines.join("\n") + ) +} + +/// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first +/// baseline, and keys besides, each with a value of any kind, in or out of range. +fn arb_area_options() -> impl Strategy { + let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]); + let values = prop::sample::select(vec![ + "0", + "1", + "2", + "3", + "2.5", + "-3", + "1e9", + "1e400", + "NaN", + "inf", + "-inf", + "(x)", + "true", + "/Name", + "[ 1 2 ]", + "<< /0 0 /1 30 >>", + "<< /0 3 /1 NaN >>", + "<< /0 4 /1 -inf >>", + "<< /0 99 /1 1e300 >>", + "<< /0 << /0 1 >> /1 [ ] >>", + "<< /1 30 >>", + ]); + prop::collection::vec((keys, values), 0..8).prop_map(|kv| kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::>().join(" ")) +} + +/// A text document of `kinds.len()` stories, story `i` in frame `i` of kind `kinds[i]` (0 point type, +/// 1 area type, 2 type on a path, others a kind the reader doesn't know), with a line break inside +/// its paragraph (`\x03`) at each of `soft` (by character, wrapping, the story's end included) and +/// its paragraph runs `runs` characters long (the whole story when there are none). +fn stories_document_text(kinds: &[u8], soft: &[usize], runs: &[usize]) -> String { + let frames: Vec = kinds + .iter() + .enumerate() + .map(|(i, kind)| { + // Six rows 15 apart on the page (the canvas is y down), then the same rows again a little + // to the right. + let (x, y) = (8150.0 + (i / 6) as f64 * 5.0, 8150.0 + (i % 6) as f64 * 15.0); + match kind { + 0 => format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {x} {y} ] >> >> >>"), + 1 => format!( + "<< /0 << /0 [ 0 0 ] /1 << /0 [ {x} {y} {x} {y} {0} {y} {0} {y} {0} {y} {0} {y} {0} {1} {0} {1} {0} {1} {0} {1} {x} {1} {x} {1} ] >> /2 << /0 1 >> >> >>", + x + 70.0, + y + 12.0 + ), + 2 => format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {x} {y} {x} {y} {0} {y} {0} {y} ] >> /2 << /0 2 /6 [ 0 0.9 ] >> >> >>", x + 70.0), + k => format!("<< /0 << /0 [ 0 0 ] /2 << /0 {k} /2 [ 1 0 0 1 {x} {y} ] >> >> >>"), + } + }) + .collect(); + let stories: Vec = (0..kinds.len()) + .map(|i| { + let mut chars: Vec = format!("Line {i} one two\rand three\r").chars().collect(); + for at in soft { + chars.insert(at % (chars.len() + 1), '\x03'); + } + let n = chars.len(); + let text: String = chars.iter().map(|c| match c { + '\r' => "\\r".to_string(), + '\x03' => "\\003".to_string(), + c => c.to_string(), + }).collect(); + let lengths: Vec = if runs.is_empty() { vec![n] } else { runs.to_vec() }; + let paras: String = lengths + .iter() + .enumerate() + .map(|(j, len)| format!("<< /0 << /0 << /0 () /5 << /0 {} /2 2 /4 {j} >> /6 0 >> >> /1 {len} >> ", j % 3)) + .collect(); + format!( + "<< /0 << /0 ({text}) /5 << /0 [ {paras}] >> /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 8.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 {i} >> ] >> >>" + ) + }) + .collect(); + format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n", + frames.join(" "), + stories.join(" ") + ) +} + +/// An EPS whose page draws `page` (PostScript), with the text `objects` on a layer that shows and +/// the stories in `document`. +fn shown_text_eps(document: &str, objects: &str, page: &str) -> Vec { + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + let editing = format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Words) Ln\n{objects}LB\n%AI5_EndLayer--\n\ + %AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", + lines.join("\n") ); - editing_eps(&editing) + let stream = ascii85(&zstd(&editing)); + let private: Vec = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont {page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n", + private.join("\n") + ) + .into_bytes() +} + +/// The page of [`stories_document_text`]'s first story's first line, at its place. +const FIRST_LINE: &str = "8.5 91.5 moveto (Line 0 one two) show"; + +/// The text objects of `pairs` (story, frame among the story's). +fn story_objects(pairs: &[(u32, u32)]) -> String { + pairs.iter().map(|(story, frame)| format!("/AI11Text :\n{frame} /FrameIndex ,\n{story} /StoryIndex ,\n;\n")).collect() +} + +/// The fixture, as it is (point type, area type and type on a path, a line break inside each +/// paragraph), opens with its layers and every story's type from its text document. +#[test] +fn the_shown_stories_fixture_opens_with_its_layers() { + let doc = stories_document_text(&[0, 1, 2], &[4], &[]); + // Each story's first line where the page draws it: the area type's below its frame's top, the + // type on a path on its path. + let page = format!("{FIRST_LINE} 8.5 69.5 moveto (Line 1) show 8.5 61.5 moveto (Line 2) show"); + let l = vectorcraft_engine::cmd::fileio::load("x.eps", &shown_text_eps(&doc, &story_objects(&[(0, 0), (1, 0), (2, 0)]), &page)).unwrap(); + let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, [Some("Words".to_string())], "{:?}", l.warnings); + let mut texts = vec![]; + l.doc.walk(|n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + texts.push(t.plain_text()); + } + }); + assert_eq!(texts.len(), 3, "{texts:?} {:?}", l.warnings); + assert!(texts.iter().all(|t| t.contains('\u{2028}') && t.contains('\n')), "{texts:?}"); } proptest! { #![proptest_config(config())] + /// An Illustrator EPS with text objects that show, in frames of every kind and some the reader + /// doesn't know, naming stories and frames that may not exist, with line breaks inside their + /// paragraphs and paragraph runs of any length: read as its layers, or as its page. + #[test] + fn eps_shown_stories_never_panic( + kinds in prop::collection::vec(0u8..4, 1..20), + pairs in prop::collection::vec((0u32..24, 0u32..3), 1..24), + soft in prop::collection::vec(0usize..40, 0..5), + runs in prop::collection::vec(0usize..40, 0..5), + ) { + let kinds: Vec = kinds.into_iter().map(|k| if k == 3 { 9 } else { k }).collect(); + let bytes = shown_text_eps(&stories_document_text(&kinds, &soft, &runs), &story_objects(&pairs), FIRST_LINE); + survive("EPS with shown stories", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + } + /// An Illustrator EPS whose text document is damaged or hostile: read as its type, or without it. #[test] fn eps_text_document_never_panics(cut in 0usize..1_500, edits in prop::collection::vec((0usize..1_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1'])), 0..12)) { @@ -2804,17 +3148,72 @@ proptest! { survive("mutated EPS text frames", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; } + /// The threaded area type of the same text document with hostile Area Type Options in each of its + /// frames, opened as an EPS document and as a `.ai`'s editing data. + #[test] + fn text_frames_with_hostile_area_options_never_panic(first in arb_area_options(), second in arb_area_options()) { + let text = frames_document_text().replacen("/0 1 /7 18", &format!("/0 1 {first}"), 1).replacen("/0 1 /7 18", &format!("/0 1 {second}"), 1); + let editing = text_editing_of(&text, FRAME_OBJECTS); + let bytes = editing_eps(&editing); + survive("EPS text frames with hostile area options", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat(); + survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + + /// The same for vertical type and Tate-chu-yoko runs: orientations and baseline directions of + /// any value, type or size. + #[test] + fn eps_vertical_text_never_panics(cut in 0usize..2_000, edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1', '2', '3'])), 0..12)) { + let text = mutate_text(&vertical_document_text(), cut, &edits); + let bytes = text_eps_of(&text, VERTICAL_OBJECTS); + survive("mutated EPS vertical text", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + } + /// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page. #[test] - fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) { + fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'l', 'c', 'C', 'v', 'V', 'y', 'Y', 'k', 'x'])), 0..12)) { let text = mutate_text(&editing_text(), cut, &edits); let bytes = editing_eps(&text); survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; } + /// A `.ai` whose envelopes are damaged or hostile: any kind, warp style, options word, + /// Fidelity and grid size, corners out of range or repeated, the contents block cut short. + #[test] + fn ai_envelope_never_panics( + kind in prop::sample::select(vec![0i64, 1, 2, 3, 4, -1, 1 << 40]), + style in prop::sample::select(vec![0i64, 1, 8, 15, 16, -3, i64::MAX]), + word in prop::sample::select(vec![0i64, 1, 131073, 65536, 0x2_0007, 1 << 18, -1, i64::MAX]), + fidelity in prop::sample::select(vec!["50", "0", "100", "101", "-1", "1e308", "nan"]), + size in prop::sample::select(vec!["[1 1]", "[3 2]", "[0 0]", "[51 1]", "[100000 100000]", "[-1 2]", "[1e308 1]"]), + corner in prop::sample::select(vec!["10 10", "1e308 -1e308", "nan 0", "10 10 10 10", "0"]), + edit in 0i64..2, + cut in 0usize..2_000, + edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8), + ) { + use vectorcraft_testkit::ai; + let n = |x: &str, y: &str| format!("%_[0.5 1 {x} {y} {x} {y} 1 {x} {y} 1] /N X#\n"); + let mesh = format!( + "%_/Mesh X!\n%_2 /Version X#\n%_/Cartesian /Type X#\n%_{size} /Size X#\n%_/Data X#\n%_[0 0] /P X#\n{}{}{}%_[{corner}] /N X#\n%_/E X#\n%_[0 0] /P X#\n%_/E X#\n%_/End X!\n", + n("10", "10"), + n("10", "90"), + n("90", "90") + ); + let content = "%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_f\n%_U\n"; + let keys = format!( + "%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n" + ); + let art = format!( + "%_u\n{mesh}%_U\n%_/ArtDictionary :\n{keys}%_X=\n{content}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\nu\n1 0 0 1 0 0 Xa\n12 12 m\n12 88 L\n88 88 L\nf\nU\n(Adobe Deform Plugin) 1 0 0 XP\n0 0 1 1 0 0 Xa\n0 0 m\n5 5 L\nf\n" + ); + let data = mutate_text(&ai::editing_data(100.0, 100.0, &ai::layer("L", &art)), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai envelope", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose editing data is damaged or hostile. #[test] - fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::()), 0..4)) { + fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'l', 'c', 'C', 'v', 'V', 'y', 'Y', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::()), 0..4)) { let text = mutate_text(&editing_text(), cut, &edits); let mut private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); for (at, b) in damage { @@ -2826,6 +3225,77 @@ proptest! { survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose PDF part can't be read (#4366), its private data damaged or hostile, under + /// any key and filter: it opens from its editing data, or with the error of its PDF part. + #[test] + fn ai_whose_pdf_part_cant_be_read_never_panics( + cut in 0usize..3_000, + edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', '%', 'u', 'U', 'L'])), 0..8), + junk in prop::collection::vec(any::(), 0..64), + key in prop::sample::select(vec!["AIPrivateData", "AIPDFPrivateData", "AIPrivateData2", "Other"]), + filter in prop::sample::select(vec!["", "/Filter /FlateDecode", "/Filter /Bogus", "/Filter [/FlateDecode /FlateDecode]", "/DP << /Predictor 2 >>", "/Filter 7"]), + ) { + let text = mutate_text(&editing_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text), &junk].concat(); + let bytes = refused_ai(&private, key, filter, ""); + let plain = vectorcraft_engine::cmd::fileio::LoadOptions { editing_data: false, ..Default::default() }; + let page_error = vectorcraft_engine::cmd::fileio::load_with("x.ai", &bytes, &plain).err().map(|e| e.to_string()); + prop_assert!(page_error.is_some()); + let _ = catch_quiet(|| vectorcraft_pdf::illustrator_data_without_pdf_part(&bytes, None)).expect("the private data read panicked"); + let mut opened = None; + survive("a .ai whose PDF part can't be read", || { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", &bytes); + opened = Some(l.as_ref().map(|l| l.editing_data).map_err(|e| e.to_string())); + l.ok().map(|l| l.doc) + })?; + match opened { + Some(Ok(editing)) => prop_assert!(editing), + Some(Err(e)) => prop_assert_eq!(Some(e), page_error), + None => {} + } + } + + /// The same file with a damaged table of objects and an object stream the reader would fail + /// on: refused, as before. + #[test] + fn ai_with_a_hostile_object_stream_is_refused(damage in prop::collection::vec((0usize..200, any::()), 0..6)) { + let private = [&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat(); + let mut bytes = refused_ai(&private, "AIPrivateData", "/Filter /FlateDecode", "/Type /ObjStm /N 1 /First 4 /DecodeParms << /Predictor 12 /Columns 0 >>"); + let table = bytes.windows(5).rposition(|w| w == b"xref\n").unwrap_or(0); + for (at, b) in damage { + let n = bytes.len() - table; + if let Some(x) = bytes.get_mut(table + at % n) { + *x = b; + } + } + let read = catch_quiet(|| vectorcraft_pdf::illustrator_data_without_pdf_part(&bytes, None)).expect("the private data read panicked"); + prop_assert!(read.is_none()); + let opened = catch_quiet(|| vectorcraft_engine::cmd::fileio::load("x.ai", &bytes).is_ok()).expect("the open panicked"); + prop_assert!(!opened); + } + + /// A `.ai` whose strokes have any width (0, which draws a hairline, negative, huge, not a number) on a + /// path, on a gradient-filled path that `1 BB` strokes and on type in any rendering mode, + /// damaged or hostile. + #[test] + fn ai_line_widths_never_panic( + width in prop::sample::select(vec!["0", "0.0", "-0", "-1", "1e308", "1e-308", "nan", "inf", "2"]), + render in prop::sample::select(vec!["0", "1", "2", "5", "6", "7", "-1", "99"]), + cut in 0usize..2_000, + edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '1', '-', '.', ' ', '\n', '(', ')', 'w', 'B', 'S', 'T'])), 0..8), + ) { + use vectorcraft_testkit::ai; + let gradient = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let art = format!( + "0 1 1 0 1 0 0 XA\n{width} w\n10 10 m\n90 90 L\nS\n{}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\nf\n1 BB\n\ + 0 To\n1 0 0 1 10 50 0 Tp\nTP\n{render} Tr\n/_Helvetica 9 Tf\n(hi) Tx\nTO\n", + ai::rect(20.0, 20.0, 80.0, 80.0) + ); + let data = mutate_text(&ai::editing_data(100.0, 100.0, &format!("{gradient}{}", ai::layer("L", &art))), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai line widths", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning /// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't /// handle, and then the layers aren't read). @@ -2838,13 +3308,193 @@ proptest! { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); survive("mutated .ai variable library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + + /// A `.ai` whose variable library holds values of every kind, values of the wrong type, elements + /// nested deep, and more variables and data sets than are read, then is damaged: its layers are + /// read, or it comes in as its PDF part (an unknown operator), with or without layers. + #[test] + fn ai_variable_library_values_never_panic( + cut in 0usize..40_000, + edits in prop::collection::vec((0usize..40_000, prop::sample::select(vec!['0', '1', '2', '3', '9', ' ', '\n', '(', ')', '\\', '/', ':', ';', ',', '%', '_', 'Z'])), 0..16), + locked in prop::sample::select(vec!["none", "variableAndTypes", "", "other"]), + extra_variables in 0usize..300, + extra_rows in 0usize..1_100, + depth in 0usize..300, + bad in prop::sample::select(vec![0u8, 1, 2, 3, 4]), + layers in prop::sample::select(vec![0u8, 1, 2]), + ) { + use vectorcraft_testkit::ai::{self, XmlValue}; + let mut nested = ai::xml_text(XmlValue::Str("deep")); + for _ in 0..depth { + nested = ai::xml_element("p", &[], &[nested]); + } + // A value of the wrong type, a second value, a value for no variable, wrong-typed children. + let wrong = match bad { + 0 => ai::xml_element("v_visible", &[], &[ai::xml_text(XmlValue::Str("true"))]), + 1 => ai::xml_element("v_text", &[], &[ai::xml_text(XmlValue::Bool(true))]), + 2 => ai::xml_element("v_nope", &[], &[ai::xml_text(XmlValue::Raw("5 /Int"))]), + 3 => ai::xml_element("v_graph", &[], &[ai::xml_text(XmlValue::Str("a,b\n1,2"))]), + _ => ai::xml_element("v_file", &[], &[]).replacen("%_/Array :", "%_/Dictionary :", 1), + }; + let mut library = ai::sample_variable_sets(locked, &[ai::xml_element("v_text", &[], &[nested]), wrong]); + if extra_variables + extra_rows > 0 { + let declared: Vec = (0..extra_variables).map(|i| ai::xml_element("variable", &[("varName", &format!("x{i}")), ("trait", "textcontent")], &[])).collect(); + let rows: Vec = (0..extra_rows).map(|i| ai::xml_element("v:sampleDataSet", &[("dataSetName", &format!("r{}", i % 900))], &[])).collect(); + let set = ai::xml_element("variableSet", &[], &[ai::xml_element("variables", &[], &declared), ai::xml_element("v:sampleDataSets", &[], &rows)]); + library = ai::xml_element("variableSets", &[], &[set]); + } + let art = match layers { + 0 => ai::layer("L", "0 g\n10 10 m\n20 20 L\nS\n"), + 1 => ai::layer("L", "1 Zz\n"), + _ => String::new(), + }; + let data = ai::editing_data(100.0, 100.0, &format!("{}{art}", ai::variable_library_data(&library))); + let text = mutate_text(&data, cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("hostile .ai variable library values", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + + /// A `.ai` whose Objects on Path record and group are damaged or hostile. + #[test] + fn ai_objects_on_path_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, prop::sample::select(vec!['0', '1', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U'])), 0..12)) { + let text = mutate_text(&on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + // Trusted, so that the layers read are built whatever the empty page shows. + survive("mutated .ai Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } + + /// Style lists (`%AI9_BeginArtStyleList`) of any entries, repeated, empty, malformed or not + /// defined, with or without their end, and objects whose `XW` has any code and any style name. + #[test] + fn ai_style_lists_and_style_links_never_panic( + listed in prop::collection::vec(prop_oneof![ + prop::sample::select(vec!["(A)", "(B)", "()", "(Caf\\303\\251)", "(A(B))", "(x\\)", "(", ")", "<41>", "/A", "1"]).prop_map(str::to_string), + "[ -~]{0,12}", + ], 0..12), + ended in any::(), + objects in prop::collection::vec((prop_oneof![arb_num(), prop::sample::select(vec!["1".to_string(), "6".into(), "9".into()])], prop::sample::select(vec!["A", "B", "", "Caf\u{e9}", "Missing"]), any::()), 0..6), + ) { + use vectorcraft_testkit::ai; + let style = |name: &str| { + format!( + "/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 1 0 0 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n; /Part ,\n;\n" + ) + }; + let end = if ended { "%AI9_EndArtStyleList\n" } else { "" }; + let header = format!( + "%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n{}\n{end}", + style("A"), + style("B"), + style("Caf\u{e9}"), + listed.join("\n") + ); + let art: String = objects + .iter() + .map(|(code, name, group)| { + let object = if *group { "%_0 Ae\n%_u\n%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n%_U\n" } else { "%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n" }; + format!("0 Ae\nu\n0 0 m\n0 9 L\n9 9 L\nf\nU\n{object}{code} ({name}) XW\n") + }) + .collect(); + let data = ai::editing_data(200.0, 100.0, &format!("{header}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{art}LB\n%AI5_EndLayer--\n")); + survive("style lists and links", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("style lists and links in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + + /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of + /// objects, pivots and angles out of range or not finite, spacings, order tags that disagree, + /// groups nested or cut short, and versioned content left open. + #[test] + fn ai_objects_on_path_records_never_panic( + listed in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7), + drawn in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7), + members in 0usize..7, + pivot in prop::sample::select(vec!["0", "4", "8", "9", "-1", "2.5", "1e400"]), + rotation in prop::sample::select(vec!["0", "0.5235987755982988", "-3.2", "1e400", "-1e400", "1e300"]), + spacing in prop::sample::select(vec!["", "%_56.5 /Real (Spacing) ,\n", "%_0 /Bool (IsEquiSpaced) ,\n", "%_1 /Bool (Flip) ,\n"]), + tags in prop::array::uniform3(prop::sample::select(vec!["0", "1", "2", "-1", "7", "(x)", "1e400"])), + versioned in any::(), + nest in 0usize..3, + cut in 0usize..2_000, + ) { + let data = on_path_data(&listed, &drawn, members, pivot, rotation, spacing, tags, versioned, nest); + let cut_at = data.len().saturating_sub(cut); + let cut_at = (0..=cut_at).rev().find(|i| data.is_char_boundary(*i)).unwrap_or(0); + for data in [data.as_str(), &data[..cut_at]] { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(data)].concat(); + survive("hostile Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } + } +} + +/// The data [`ai_objects_on_path_never_panics`] damages, as it is, opens with its Objects on Path group. +#[test] +fn the_objects_on_path_fuzz_data_opens_live() { + let text = on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + // Trusted: the empty page it is read beside isn't compared with its art. + let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + let g = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}")); + assert!(g.extras().objects_on_path.is_some(), "{g:?}"); +} + +/// The ids of the objects of an Objects on Path group in the fuzz cases. +const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"]; + +/// Editing data with an Objects on Path record naming `listed` for `members` objects (Pivot +/// `pivot`, Rotation `rotation`, then the entries `more`) and a group of a path and squares whose +/// dictionaries give the ids `drawn` (the first the path's, the others with the order tags `tags` +/// in turn), inside `nest` more groups, in versioned content with a copy after it when `versioned` +/// is set. +#[allow(clippy::too_many_arguments)] +fn on_path_data( + listed: &[&str], + drawn: &[&str], + members: usize, + pivot: &str, + rotation: &str, + more: &str, + tags: [&str; 3], + versioned: bool, + nest: usize, +) -> String { + use vectorcraft_testkit::ai; + let options = ai::on_path_options(members, 0, 0.0, more) + .replace("%_0 /Int (PivotPosition)", &format!("%_{pivot} /Int (PivotPosition)")) + .replace("%_0 /Real (Rotation)", &format!("%_{rotation} /Real (Rotation)")); + let record = ai::on_path_record("Adobe Align On Path", listed, &options); // brand-ok: the record's name in the files + let mut group = String::from("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n20 20 m\n100 80 L\n180 20 L\nS\n"); + for (i, id) in drawn.iter().enumerate() { + if i == 0 { + group += &ai::on_path_member(id, None, 0.0); + } else { + let x = 10.0 + 30.0 * i as f64; + let tag = tags.get(i - 1).map_or_else(|| (i - 1).to_string(), |t| t.to_string()); + group += &format!( + "0 1 1 0 1 0 0 Xa\n{}f\n%_/ArtDictionary :\n%_{tag} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({rotation}) /String (BBAccumRotation) ,\n%_;\n%_\n", + ai::rect(x, 40.0, x + 10.0, 46.0) + ); + } + } + group += "U\n"; + for _ in 0..nest { + group = format!("0 Ae\nu\n{group}U\n"); + } + let art = if versioned { + format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{group}%AI17_End_Versioned_Content\n") + } else { + group + }; + ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("L", &art))) } /// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named, /// not included, so its program can't be run and its layers are read from it. const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\ %%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\ - %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n%AI3_Note:a (note)\n%AI5_NoteMore:more\n1 Ap\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n%%Note:\n0 Ap\n\ u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\ %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"; @@ -2863,10 +3513,155 @@ proptest! { /// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its /// layers, or refused. #[test] - fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) { + fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K', 'p', ':', '\u{e9}'])), 0..12)) { let text = mutate_text(NAMED_PROLOG_AI, cut, &edits); survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; } + + /// Multi-layer masks (`Mb` `W n` `Md` … `MB`) over any number of layers, with tokens + /// dropped or put anywhere (the mask operators, layer ends, groups, clipping paths) and the + /// data cut short. + #[test] + fn ai_multi_layer_masks_never_panic( + layers in 1usize..5, + aliases in any::(), + edits in prop::collection::vec((0usize..64, prop::sample::select(vec!["", "Mb", "Md", "MB", "Mi", "LB", "u", "U", "q", "Q", "W n", "h W n"])), 0..4), + cut in prop::option::of(0usize..64), + ) { + use vectorcraft_testkit::ai; + let (begin, define) = if aliases { ("Mv", "ME") } else { ("Mb", "Md") }; + let mut tokens: Vec = vec![]; + for i in 0..layers { + if i > 0 { + tokens.push(format!("LB\n%AI5_EndLayer--\n%AI5_BeginLayer\n1 1 1 1 0 1 1 79 128 255 Lb\n(L{i}) Ln\n")); + } else { + tokens.extend([begin.to_string(), "40 40 m 120 40 L 120 120 L 40 120 L h W n".into(), define.to_string()]); + } + let x = 10 * i; + tokens.push(format!("0.5 0 0 0 k {x} {x} m {} {x} L {} {} L f", x + 140, x + 140, x + 140)); + } + tokens.extend(["MB".to_string(), "150 150 m 190 150 L 190 190 L f".into()]); + for (at, with) in &edits { + let at = at % (tokens.len() + 1); + if with.is_empty() { + if at < tokens.len() { + tokens.remove(at); + } + } else { + tokens.insert(at, with.to_string()); + } + } + if let Some(cut) = cut { + tokens.truncate(cut); + } + let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 1 1 79 128 255 Lb\n(L0) Ln\n{}\nLB\n", tokens.join("\n"))); + survive("multi-layer masks", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("multi-layer masks in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + + /// The same file with placed EPS files in the version 7 form (no `%AI5_BeginPlace`), one + /// linked and one included whole with a nested document and counted data in it, damaged. + #[test] + fn ps_form_ai_with_placed_files_never_panics(cut in 0usize..1_600, edits in prop::collection::vec((0usize..1_600, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '[', ']', '%', '`', '~', 'E', 'D'])), 0..12)) { + let placed = "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n%%IncludeFile: a.eps\n~\n\ + [-1 0 0 1 100 0] 10 20 50 50 4 4 0 (b.eps) `\n%%BeginDocument: b.eps\n%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 50 50\n\ + %%BeginDocument: c.eps\n~\n%%EndDocument\n%%BeginData: 14 Binary Bytes\n%%EndDocument\n0 0 1 setrgbcolor 10 20 40 30 rectfill\n%%EndDocument\n~\n"; + let fixture = NAMED_PROLOG_AI.replacen("0 Ap\nu\n", &format!("0 Ap\n{placed}u\n"), 1); + prop_assert!(fixture.contains("(b.eps)")); + let text = mutate_text(&fixture, cut, &edits); + survive("mutated PostScript-form .ai with placed files", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + +/// A `.ai` in the legacy format without layers (Illustrator 3) whose prolog is included: its +/// program runs, and its art is read on one layer. +const LAYERLESS_AI: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 200 100\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n\ + %%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /S {stroke} def /A {pop} def /k {setcmykcolor} def /u {} def /U {} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n1 A\n0 0 0 1 k\n10 10 m\n90 10 L\n90 90 L\nf\n0 A\nu\n110 10 m\n190 90 L\n150 90 L\nf\nU\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + +/// The fixture, as it is, opens through the reader: its first path keeps its lock. +#[test] +fn the_layerless_fixture_opens_through_the_reader() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", LAYERLESS_AI.as_bytes()).unwrap(); + assert_eq!(l.doc.layers.len(), 1, "{:?}", l.warnings); + let art = l.doc.layers[0].children().unwrap(); + assert_eq!(art.len(), 2, "{:?}", l.warnings); + assert!(art[0].locked && !art[1].locked); +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` in the legacy format without layers, damaged: read through the reader, as its page, + /// or refused. + #[test] + fn layerless_ai_never_panics(cut in 0usize..800, edits in prop::collection::vec((0usize..800, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '{', '}', '%', 'u', 'U', 'L', 'f', 'A', 'k'])), 0..12)) { + let text = mutate_text(LAYERLESS_AI, cut, &edits); + survive("mutated layerless .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + +/// A pattern's definition in the version 7 form (§3.4): a layer whose `_ &` fills the tile's box, +/// and one whose tile string has CR-separated lines, comments and escapes. +const LEGACY_PATTERN: &str = "%AI3_BeginPattern: (P)\n(P) 0 0 10 10 [\n%AI3_Tile\n(0 O 0 R 0 0 0 0.5 k) @ _ &\n\ + (0 O 1 R 0 0 0 1 \\(Ink\\) 0 X) @ (\r%AI6_BeginPatternLayer\r1 w u\r0 5 m\\r10 5 L\rS\rU\r%AI6_EndPatternLayer\r) &\n] E\n%AI3_EndPattern\n"; + +/// [`NAMED_PROLOG_AI`] with `pattern` in its setup, filling the square. +fn legacy_pattern_ai(pattern: &str) -> String { + NAMED_PROLOG_AI + .replace("%AI5_Begin_NonPrinting", &format!("{pattern}%AI5_Begin_NonPrinting")) + .replace("\n10 10 m\n", "\n(P) 5 5 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n10 10 m\n") + .replace("10 10 L\nS\n", "10 10 L\nB\n") +} + +/// The fixture, as it is, opens with the pattern filling the square. +#[test] +fn the_legacy_pattern_fixture_opens_with_its_pattern() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", legacy_pattern_ai(LEGACY_PATTERN).as_bytes()).unwrap(); + assert_eq!(l.doc.pattern("P").map(|p| p.art.len()), Some(2), "{:?}", l.warnings); + let square = &l.doc.layers[0].children().unwrap()[0]; + assert!(matches!(square.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Pattern { .. }))); +} + +proptest! { + #![proptest_config(config())] + + /// That `.ai` with its pattern's definition damaged (its strings, `@`, `&`, `_`, the list's + /// brackets and its `E`): read, without the pattern where it can't be read whole, or refused. + #[test] + fn ps_form_ai_legacy_pattern_never_panics(cut in 0usize..500, edits in prop::collection::vec((0usize..500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '\r', '(', ')', '\\', '[', ']', '%', '@', '&', '_', 'E', 'u', 'U', 'q', 'Q', 'm', 'f', 'S', 'k', 'X'])), 0..12)) { + let text = legacy_pattern_ai(&mutate_text(LEGACY_PATTERN, cut, &edits)); + survive("PostScript-form .ai with a damaged version 7 pattern", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + +/// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or +/// anything else. +fn arb_layer_operand() -> impl Strategy { + prop_oneof![Just("0".to_string()), Just("1".to_string()), (-2i32..300).prop_map(|v| v.to_string()), arb_num()] +} + +/// A coordinate of a guide path: the ends of the canvas around the template box's centre (100 50), +/// the edges of the artboards, near them, or anything else. +fn arb_guide_coord() -> impl Strategy { + prop_oneof![ + prop::sample::select(vec!["-8091.5", "8291.5", "-8141.5", "8241.5", "0", "50", "100", "200", "-8091.505", "8291.5001", "1e308"]) + .prop_map(str::to_string), + arb_num(), + ] +} + +/// A value of a `/Document` setting: a number of any type, several numbers, a string or a +/// dictionary. +fn arb_ai_setting() -> impl Strategy { + prop_oneof![ + (arb_num(), prop::sample::select(vec!["/Int", "/Real", "/Bool", "/String", ""])).prop_map(|(n, t)| format!("{n} {t}")), + (arb_num(), arb_num()).prop_map(|(a, b)| format!("{a} {b} /RealPoint\n%_")), + Just("(text) /String".to_string()), + Just("/Dictionary :\n%_1 /Int (x) ,\n%_;\n%_".to_string()), + ] } /// The operators, operands and section comments of hostile editing data. @@ -2895,6 +3690,9 @@ fn arb_ai_token() -> impl Strategy { "b", "W", "(b) *", + "(N) *", + "(S) *", + "-8091.5 50 m 8291.5 50 L (N) *", "u", "U", "*u", @@ -2905,6 +3703,13 @@ fn arb_ai_token() -> impl Strategy { "Lb", "Ln", "(name) Ln", + "\n%AI5_BeginLayer\n1 1 1 1 0 1 1 79 128 255 Lb\n", + "Mb", + "Mv", + "Md", + "ME", + "Mi", + "MB", "g", "G", "k", @@ -2919,6 +3724,13 @@ fn arb_ai_token() -> impl Strategy { "O", "R", "XR", + // A path's note and Show Center (version 7 §5.3.1, §12). + "Ap", + "1 Ap", + "\n%AI3_Note: a (note) \n", + "\n%AI3_Note:\n", + "\n%AI5_NoteMore:more\n", + "\n%%Note:old\n", "w", "J", "j", @@ -3018,6 +3830,13 @@ fn arb_ai_token() -> impl Strategy { "9 Ta", "1e308 -1e308 1e308 Ti", "0 1 Tk", + "-1e308 0 1e308 TC", + "1e308 -1 5 TW", + "/x 100 200 TW", + "1 Tq", + "7 Tq", + "1 1 1e308 -1e308 4e9 Th", + "1 1 (2) 3 4 Th", "[39/quotesingle 128/Euro/eacute/nosuch 300/x TE", "[1e308/a -1/b TE", "\n%AI3_BeginEncoding: _Helvetica Helvetica\n[/_Helvetica/Helvetica 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n", @@ -3047,8 +3866,24 @@ fn arb_ai_token() -> impl Strategy { "\n%AI26_EndPlacedObjectPreview\n", "[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `", "[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `", + "\n%AI28_BeginPlacedRelativePath\n", + "\n%AI28_BeginPlacedRelativePath\n(src/)\n%AI28_EndPlacedRelativePath\n", + "\n%AI28_BeginPlacedRelativePath\n(..\\\\..\\\\art\\\\)\n%AI28_EndPlacedRelativePath\n", + "\n%AI28_BeginPlacedRelativePath\n(C:\\\\x\\001)\n%AI28_EndPlacedRelativePath\n", + "\n%_%AI28_BeginPlacedRelativePath\n%_(src/)\n%_%AI28_EndPlacedRelativePath\n", + "\n%AI28_EndPlacedRelativePath\n", "`", "~", + // The version 7 form (§16): an 11-value `` ` `` with no `%AI5_BeginPlace`, its file + // linked or included whole, a nested document and counted data in that. + "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `", + "[0 0 0 0 0 0] 0 0 0 0 (a.eps) `", + "\n%%IncludeFile: a.eps\n", + "\n%%BeginDocument: a.eps\n", + "\n%%EndDocument\n", + "\n%%BeginData: 14 Binary Bytes\n", + "\n%%BeginBinary: 99999999999\n", + "\n%%BeginData: 3 Hex Lines\n", "\n%AI5_EndPlace\n", "Xm", "50 -50 -100 -100 5000 -2000 Xm", @@ -3072,6 +3907,12 @@ fn arb_ai_token() -> impl Strategy { "[1 1] /P X#", "(Adobe Path Blends) 1 0 92 XP", "(Adobe Path Blends) 1 0 999999 XP", + "(Adobe Deform Plugin) 1 0 0 XP", + "%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_X=\n", + "%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n", + "%_1 /Int (AIDeformStyle) ,\n%_9 /Int (DeformStyle) ,\n%_1e308 /Real (DeformValue) ,\n%_;\n", + "%_1 /Bool (AIDeformEditModeKey) ,", + "[1e308 1e308] /Size X#", "\n%44434241010000000000000000000000\n", "\n%0200000041000000000000000000000000000000\n", "\n%4G\n", @@ -3080,6 +3921,18 @@ fn arb_ai_token() -> impl Strategy { "9 () XW", "\n%AI9_BeginArtStyles\n", "\n%AI9_EndArtStyles\n", + "\n%AI9_BeginArtStyleList\n", + "\n%AI9_EndArtStyleList\n", + "(style)\n", + "(st\\)y(le)\n", + "(\\303\\251", + "(style) XW", + "1 () XW", + "6 (style) XW", + "7 (style) XW", + "-1 (style) XW", + "1e308 (style) XW", + "1 (Caf\\351) XW", "/KnownStyle :", "(style) /Name ,", "/ActiveStyle :", @@ -3089,6 +3942,67 @@ fn arb_ai_token() -> impl Strategy { "(Fill Style Filter) 0 0 /Filter ,", "(Adobe Outer Glow) 1 0 /Filter ,", "(Adobe Drop Shadow) 1 0 /Filter ,", + "(Adobe Pathfinder) 1 0 /Filter ,", + "0 /Int (Command) ,", + "0.5 /Real (Mix) ,", + "(Op) /UnicodeString (DisplayString) ,", + "(Adobe Transform) 1 0 /Filter ,", + "(Adobe Free Distort) 1 0 /Filter ,", + "(Adobe Punk and Bloat) 1 0 /Filter ,", + "(Adobe Roughen) 1 0 /Filter ,", + "(Adobe Scribble and Tweak) 1 0 /Filter ,", + "(Adobe Twirl) 1 0 /Filter ,", + "(Adobe Zigzag) 1 0 /Filter ,", + "1e308 /Real (scaleH_Factor) ,", + "-1e308 /Real (rotate_Radians) ,", + "1000 /Int (numCopies) ,", + "8 /Int (pinPoint) ,", + "0 /Bool (scaleLines) ,", + "0 /Bool (transformPatterns) ,", + "0 /Real (src0h) ,", + "1e-300 /Real (src1h) ,", + "-1 /Real (src2v) ,", + "1e308 /Real (dst3v) ,", + "-200 /Real (d_factor) ,", + "3600 /Real (angle) ,", + "1 /Real (absoluteness) ,", + "100 /Real (ridges) ,", + "1e4 /Real (amount) ,", + "0 /Bool (anch) ,", + "(Adobe Inner Glow) 1 0 /Filter ,", + "(Adobe Fuzzy Mask) 1 0 /Filter ,", + "(Adobe Round Corners) 1 0 /Filter ,", + "(Adobe Scribble Fill) 1 0 /Filter ,", + "(Chain Style Filter) 0 0 /Filter ,", + "/Dictionary : /NotRecorded ,", + "; /Dict ;", + "2.5 /Int (blnd) ,", + "99 /Int (csrc) ,", + "1e308 /Int (gtyp) ,", + "1 /Int (gtyp) ,", + "-13 /Real (vert) ,", + "14 /Real (Radius) ,", + "-1e308 /Real (radius) ,", + "67 /Real (Angle) ,", + "0.17 /Real (Scribbliness) ,", + "1e308 /Real (Spacing) ,", + "(x) /String (note) ,", + "1 /Bool (usePSLBlur) ,", + "16 /Int (Adobe Effect Expand Before Version) ,", + "/FillStyle : 0 O\n0 0 0 0 1 0 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,", + "/FillStyle : 0 O\nBb\n2 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n0 BB\n/Def ;\n (gclr) ,", + "(sclr) ,", + "(Adobe Offset Path) 1 0 /Filter ,", + "(Adobe Outline Type) 1 0 /Filter ,", + "(Adobe Outline Stroke) 1 0 /Filter ,", + "(Chain Style Filter) 0 0 /Filter ,", + "-1e308 /Real (ofst) ,", + "1e999 /Real (ofst) ,", + "2 /Int (jntp) ,", + "7 /Int (jntp) ,", + "NaN /Real (mlim) ,", + "(x) /UnicodeString (mlim) ,", + "0 /Visible ,", "1 /Visible ,", "2 /FillOrStroke ,", "1 /Bool (BSDashAdj) ,", @@ -3104,6 +4018,18 @@ fn arb_ai_token() -> impl Strategy { "/Def ;", "/Execution ;", "[5 5] 1e308 d", + "(Adobe Shape Effects) 1 0 /Filter ,", + "0 /Visible ,", + "(Rectangle) /UnicodeString (DisplayString) ,", + "2 /Int (Shape) ,", + "1e999 /Int (Shape) ,", + "1 /Real (Absolute) ,", + "-1e308 /Real (RelWidth) ,", + "1e5 /Real (RelHeight) ,", + "NaN /Real (AbsWidth) ,", + "0 /Real (AbsHeight) ,", + "-9 /Real (CornerRadius) ,", + "1 /Bool (Absolute) ,", "-0.5 /Real ,", "1.5 /Real ,", "-2 /Real ,", @@ -3119,6 +4045,37 @@ fn arb_ai_token() -> impl Strategy { ] } +/// Text wrap in editing data: an object's dictionary with the wrap options in any order, missing, +/// repeated or of the wrong type, with numbers that aren't finite, nested dictionaries and the kept +/// outline's art; and wraparound groups (`*w` … `*W`), unmatched or nested. +fn arb_ai_wrap_entry() -> impl Strategy { + let value = prop_oneof![ + arb_num(), + Just("nan".to_string()), + Just("inf".to_string()), + Just("-1e999".to_string()), + Just("1e-999".to_string()), + Just("(far)".to_string()), + Just("/Name".to_string()), + Just("[ 1 2 ]".to_string()), + ]; + let ty = prop::sample::select(vec!["/Real", "/Bool", "/Int", "/String", ""]); + let key = prop::sample::select(vec!["AI11TextWrapOffset", "AI11TextWrapInvert", "AI11TextWrapPath", "AI11TextWrapBeingComputed"]); + let entry = (value, ty, key).prop_map(|(v, t, k)| format!("%_{v} {t} ({k}) ,")); + let art = prop::sample::select(vec![ + "%_X=\n%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,", + "%_X=\n%_X=\n%_0 0 m\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,", + "%_X+\n%_ (AI11TextWrapPath) ,", + "%_/Dictionary :\n%_6 /Real (AI11TextWrapOffset) ,\n%_; (Nested) ,", + "%_/Dictionary :\n%_1 /Bool (AI11TextWrapInvert) ,", + ]) + .prop_map(str::to_string); + let dictionary = prop::collection::vec(prop_oneof![3 => entry, 1 => art], 0..6) + .prop_map(|entries| format!("\n%_/ArtDictionary :\n{}\n%_;\n%_\n", entries.join("\n"))); + let group = prop::sample::select(vec!["*w", "*W", "*w *w", "*W *W", "*w u", "U *W", "*w 1 To", "TO *W"]).prop_map(str::to_string); + prop_oneof![2 => dictionary, 1 => group] +} + /// The testkit's sample editing data, compressed as older `.ai` files compress it. fn ai_sample_compressed() -> Vec { [&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat() @@ -3135,10 +4092,54 @@ fn deflated(data: &[u8]) -> Vec { proptest! { #![proptest_config(config())] + /// Art styles with Convert to Shape whose dictionary holds any of its entries, and unknown ones, + /// as any kind, with any value (huge, tiny, not a number, a string), repeated or left out, shown + /// or hidden, on a path, a group and type, on the whole object or on its fill. + #[test] + fn ai_convert_to_shape_never_panics( + entries in prop::collection::vec(( + prop::sample::select(vec![ + "Shape", "Absolute", "AbsWidth", "AbsHeight", "RelWidth", "RelHeight", "CornerRadius", "DisplayString", "futureKey", + ]), + prop::sample::select(vec!["Int", "Real", "Bool", "UnicodeString", "String"]), + prop_oneof![ + (-1e4f64..1e4).prop_map(|v| v.to_string()), + prop::sample::select(vec!["0", "1", "2", "3", "-1", "0.5", "1e-300", "1e308", "-1e308", "1e999", "NaN", "(x)", ""]).prop_map(str::to_string), + ], + ), 0..16), + visible in any::(), + on_fill in any::(), + ) { + use vectorcraft_testkit::ai; + let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); + let basic = |name: &str, body: &str| format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n"); + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let effect = basic("Adobe Shape Effects", &keys).replacen("1 /Visible", if visible { "1 /Visible" } else { "0 /Visible" }, 1); + let bare = compound("Chain Style Filter", &[effect.clone(), basic("Conduit Filter", "")]); + let root = if on_fill { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[fill, effect]), basic("Conduit Filter", "")])]) + } else { + compound("Chain Style Filter", &[effect, compound("Stack Style Filter", &[fill, basic("Conduit Filter", "")])]) + }; + let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}/KnownStyle :\n(Bare) /Name ,\n/ActiveStyle :\n{bare}%AI9_EndArtStyles\n"); + let hidden = ai::rect(20.0, 20.0, 80.0, 60.0).replace('\n', "\n%_"); + let art = format!( + "0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\n0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_0 Ae\n%_u\n%_{hidden}f\n%_U\n1 (Bare) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n", + ai::rect(20.0, 20.0, 80.0, 60.0), + ai::rect(20.0, 20.0, 80.0, 60.0), + ai::rect(100.0, 20.0, 180.0, 60.0) + ); + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art))); + survive("Convert to Shape art style", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("Convert to Shape art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + /// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any /// operands, sections and dictionaries left open, images of any size. #[test] - fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) { + fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(prop_oneof![8 => arb_ai_token(), 1 => arb_ai_wrap_entry()], 0..80)) { use vectorcraft_testkit::ai; let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" "))); survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; @@ -3155,6 +4156,242 @@ proptest! { survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?; } + /// A group with an Effect › Pathfinder style whose dictionary is hostile: any `Command`, keys of + /// any type and value (missing, repeated, unknown, not finite), shown or hidden, the style cut + /// short anywhere; in editing data, an EPS and a `.ai`. A `Command` other than 0 never becomes + /// a live effect. + #[test] + fn ai_pathfinder_styles_never_panic( + command in prop_oneof![(-3i64..16).prop_map(|c| c.to_string()), arb_num()], + keys in prop::collection::vec( + ( + prop::sample::select(vec!["Mix", "Precision", "RemovePoints", "ExtractUnpainted", "ConvertCustom", "TrapTint", "TrapThickness", "Command", "Extra"]), + prop::sample::select(vec!["Int", "Real", "Bool", "String"]), + arb_num(), + ), + 0..16, + ), + hidden in any::(), + cut in 0usize..4_000, + ) { + use vectorcraft_testkit::ai; + let dict: String = keys.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); + let styles = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(PF) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Pathfinder) 1 0 /Filter ,\n{} /Visible ,\n/Dictionary : /NotRecorded ,\n{command} /Int (Command) ,\n{dict}; /Dict ;\n /Part ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n", + u8::from(!hidden) + ); + let styles: String = styles.chars().take(cut.max(1)).collect(); + let group = "0 Ae\nu\n1 0 0 1 0 0 Xa\n10 10 m\n10 60 L\n80 60 L\n80 10 L\nf\nU\n\ + %_0 Ae\n%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 50 L\n%_50 50 L\n%_50 10 L\n%_f\n%_0 0 1 0 0 0 1 Xa\n%_40 20 m\n%_40 60 L\n%_80 60 L\n%_80 20 L\n%_f\n%_U\n1 (PF) XW\n"; + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", group))); + let live = command.parse::().is_ok_and(|c| c == 0.0); + let check = |d: Option| { + if let Some(d) = &d + && !live + { + d.walk(|n| assert!(!n.appearance.effects.iter().any(|e| e.id.starts_with("pathfinder.")), "Command {command}: {:?}", n.appearance.effects)); + } + d + }; + survive("hostile Pathfinder style", || check(Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0)))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("hostile Pathfinder style in an EPS", || check(vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc)))?; + let file = ai::ai(data.as_bytes(), ai::page_pdf()); + survive("hostile Pathfinder style in a .ai", || check(vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc)))?; + } + + /// Art styles with Offset Path, Outline Object and Outline Stroke, in any order, shown or hidden, + /// whose dictionaries hold any of Offset Path's entries and unknown ones, as any kind, with any + /// value (huge, tiny, not a number, a string), repeated or left out, on the whole object, a fill + /// or a stroke, of a path, a group and type. + #[test] + fn ai_path_effects_never_panic( + effects in prop::collection::vec(( + prop::sample::select(vec!["Adobe Offset Path", "Adobe Outline Type", "Adobe Outline Stroke"]), + prop::collection::vec(( + prop::sample::select(vec!["ofst", "jntp", "mlim", "width", "DisplayString"]), + prop::sample::select(vec!["Int", "Real", "Bool", "UnicodeString"]), + prop_oneof![ + (-2e5f64..2e5).prop_map(|v| v.to_string()), + prop::sample::select(vec!["0", "1", "2", "3", "-1", "0.5", "1e-300", "1e308", "-1e308", "1e999", "NaN", "(x)", ""]).prop_map(str::to_string), + ], + ), 0..6), + any::(), + ), 0..5), + target in 0..3usize, + ) { + use vectorcraft_testkit::ai; + let basic = |name: &str, body: &str| format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n"); + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let filters: Vec = effects + .iter() + .map(|(name, entries, visible)| { + let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); + basic(name, &keys).replacen("1 /Visible", if *visible { "1 /Visible" } else { "0 /Visible" }, 1) + }) + .collect(); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let stroke = basic("Stroke Style Filter", "/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 4 w 10 M []0 d0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n"); + let conduit = basic("Conduit Filter", ""); + let chain = |item: String| compound("Chain Style Filter", &[filters.concat(), item, filters.concat()]); + let root = match target { + 0 => compound("Chain Style Filter", &[filters.concat(), compound("Stack Style Filter", &[stroke, fill, conduit])]), + 1 => compound("Chain Style Filter", &[compound("Stack Style Filter", &[stroke, chain(fill), conduit])]), + _ => compound("Chain Style Filter", &[compound("Stack Style Filter", &[chain(stroke), fill, conduit])]), + }; + let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n"); + let hidden = ai::rect(20.0, 20.0, 80.0, 60.0).replace('\n', "\n%_"); + let art = format!( + "0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\n0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_0 Ae\n%_u\n%_{hidden}f\n%_U\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n", + ai::rect(20.0, 20.0, 80.0, 60.0), + ai::rect(20.0, 20.0, 80.0, 60.0), + ai::rect(100.0, 20.0, 180.0, 60.0) + ); + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art))); + survive("Path effect art style", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("Path effect art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + + /// Art styles with a Distort & Transform effect (Transform, Free Distort, Pucker & Bloat, Roughen, + /// Tweak, Twist, Zig Zag) whose dictionary holds any of its entries, and unknown ones, as any kind, + /// with any value (huge, tiny, not a number, a string), repeated or left out, on a path and on type, + /// on the whole object or on its fill. + #[test] + fn ai_distort_effects_never_panic( + name in prop::sample::select(vec![ + "Adobe Transform", + "Adobe Free Distort", + "Adobe Punk and Bloat", + "Adobe Roughen", + "Adobe Scribble and Tweak", + "Adobe Twirl", + "Adobe Zigzag", + ]), + entries in prop::collection::vec(( + prop::sample::select(vec![ + "scaleH_Factor", "scaleV_Factor", "scaleH_Percent", "rotate_Radians", "rotate_Degrees", "moveH_Pts", "moveV_Pts", + "numCopies", "pinPoint", "reflectX", "reflectY", "randomize", "transformObjects", "transformPatterns", "scaleLines", + "src0h", "src0v", "src1h", "src1v", "src2h", "src2v", "src3h", "src3v", "dst0h", "dst0v", "dst1h", "dst1v", "dst2h", + "dst2v", "dst3h", "dst3v", "d_factor", "absoluteness", "size", "asiz", "dtal", "roundness", "horz", "vert", "ahor", + "aver", "anch", "in", "out", "angle", "amount", "relAmount", "ridges", "futureKey", + ]), + prop::sample::select(vec!["Int", "Real", "Bool"]), + prop_oneof![ + (-1e4f64..1e4).prop_map(|v| v.to_string()), + prop::sample::select(vec!["0", "1", "-1", "0.5", "1e-300", "1e308", "-1e308", "1e999", "NaN", "(x)", ""]).prop_map(str::to_string), + ], + ), 0..24), + on_fill in any::(), + ) { + use vectorcraft_testkit::ai; + let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); + let basic = |name: &str, body: &str| format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n"); + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let effect = basic(name, &keys); + let root = if on_fill { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[fill, effect]), basic("Conduit Filter", "")])]) + } else { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill, basic("Conduit Filter", "")]), effect]) + }; + let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n"); + let hidden = ai::rect(20.0, 20.0, 80.0, 60.0).replace('\n', "\n%_"); + let art = format!( + "0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n", + ai::rect(20.0, 20.0, 80.0, 60.0), + ai::rect(100.0, 20.0, 180.0, 60.0) + ); + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art))); + survive("distort art style", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("distort art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + + /// Layer options (`Lb`) of any number of operands with any values, and guide paths (`(N) *`, + /// `(S) *`) of any points, among them ones across the whole canvas or from edge to edge of + /// artboards that overlap or are the same, on layers and sublayers. + #[test] + fn ai_layer_options_and_ruler_guides_never_panic( + options in prop::collection::vec(arb_layer_operand(), 0..17), + guides in prop::collection::vec((prop::collection::vec((arb_guide_coord(), arb_guide_coord()), 0..5), any::(), any::()), 0..12), + boards in prop::collection::vec(prop::sample::select(vec![[0.0, 100.0, 200.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 100.0, 200.0, 0.0]]), 0..4), + ) { + use vectorcraft_testkit::ai; + let board = |b: &[f64; 4]| { + format!( + "%_/Dictionary :\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + b[0], b[1], b[2], b[3] + ) + }; + let array: String = boards.iter().map(board).collect(); + let document = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{array}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"); + let paths: Vec = guides + .iter() + .map(|(points, ruler, sub)| { + let ops: String = points.iter().enumerate().map(|(i, (x, y))| format!("{x} {y} {}\n", if i == 0 { "m" } else { "L" })).collect(); + let path = format!("{ops}({}) *\n", if *ruler { "N" } else { "S" }); + if *sub { format!("%AI5_BeginLayer\n{} Lb\n(Sub) Ln\n{path}LB\n%AI5_EndLayer--\n", options.join(" ")) } else { path } + }) + .collect(); + let body = format!("{document}%AI5_BeginLayer\n{} Lb\n(L) Ln\n{}\nLB\n%AI5_EndLayer--\n", options.join(" "), paths.concat()); + let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100 50 100 50\n%%EndComments", 1); + survive("layer options and ruler guides", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("layer options and ruler guides in units of 10 points", || { + Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(2000.0, 1000.0), vec![], false, false, 10.0).0) + })?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("layer options and ruler guides in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + + /// Artboard display options and Document Raster Effects Settings of any type and value, and + /// header comments (`%%For`, custom colors and their `%%+` lines, `%%TemplateBox`) with any + /// operands. + #[test] + fn ai_document_settings_never_panic( + boards in prop::collection::vec(prop::collection::vec((prop::sample::select(vec!["DisplayMark", "PAR"]), arb_ai_setting()), 0..4), 0..4), + raster in prop::collection::vec((prop::sample::select(vec!["dpi.", "mask", "alis", "spot", "padd", "colr"]), arb_ai_setting()), 0..8), + header in prop::collection::vec( + ( + prop::sample::select(vec!["%%For:", "%%CMYKCustomColor:", "%%RGBCustomColor:", "%%+", "%%TemplateBox:", "%%DocumentCustomColors:"]), + prop::collection::vec(prop_oneof![arb_num(), "\\(?[ -~]{0,12}\\)?".prop_map(|s| s)], 0..6), + ), + 0..10, + ), + ) { + use vectorcraft_testkit::ai; + let entries = |e: &[(&str, String)]| e.iter().map(|(k, v)| format!("%_{v} ({k}) ,\n")).collect::(); + let array: String = boards + .iter() + .enumerate() + .map(|(i, e)| { + format!( + "%_/Dictionary :\n{}%_{} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + entries(e), + i * 100, + i * 100 + 100 + ) + }) + .collect(); + let document = format!( + "%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n{}%_; (AI Auto Rasterize) ,\n%_/Array :\n{array}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", + entries(&raster) + ); + let comments: String = header.iter().map(|(c, ops)| format!("{c} {}\n", ops.join(" "))).collect(); + let body = format!("{document}{}", ai::layer("L", &format!("{}f\n", ai::rect(0.0, 0.0, 10.0, 10.0)))); + let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", &format!("{comments}%%EndComments"), 1); + survive("document settings", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("document settings in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + // The same comments in the EPS's own header. + let at = eps.windows(13).position(|w| w == b"%%EndComments").unwrap_or(0); + let outer = [&eps[..at], comments.as_bytes(), &eps[at..]].concat(); + survive("document settings in an EPS's header", || vectorcraft_engine::cmd::fileio::load("x.eps", &outer).ok().map(|l| l.doc))?; + } + /// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files. #[test] fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::()), 0..12)) { @@ -4170,7 +5407,7 @@ fn brushed_document(kind: usize, art: usize) -> Document { Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0)) }; let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc)); - doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) }); + doc.symbols.push(Symbol { dynamic: false, name: "s0".into(), art: std::sync::Arc::new(in_symbol) }); doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)])); let art = match art { 0 => instance(&mut doc), @@ -4360,7 +5597,7 @@ fn symbols_document(count: usize, levels: usize, last: &str) -> Document { for _ in 0..levels { art = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(art)]); } - doc.symbols.push(Symbol { name: format!("s{i}"), art: std::sync::Arc::new(art) }); + doc.symbols.push(Symbol { dynamic: false, name: format!("s{i}"), art: std::sync::Arc::new(art) }); } let first = instance(&mut doc, "s0".into()); let layer = doc.layers[0].id; @@ -4552,3 +5789,42 @@ proptest! { survive("mutated .ai blend", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; } } + +/// A graph in `.ai` editing data, written by hand from the version 7 specification's chapter 17: +/// its data and settings on `%_` lines (cells, strings, an array, a name), then its drawn art, a bar +/// with its `Go` tag and an empty group, in a group. +fn graph_text() -> String { + "%_Gs\n%_10 10 110 90 Gb\n%_3 0 0 12 1 1 0 0 2 30 0 Gy\n%_6 1 1 0 80 70 0 50 60 80 70 Gd\n%_0 1 Gj\n\ + %_1 () Ga 0 12 0 0 0 0 0 () 0 0 GA\n%_40 Gw\n%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 7 Gc GC\n\ + %_Gt\n%_(Bars) GD [1 2 3] 0 Ge\n%_/Name 1 Gi\n%_0.5 G\n%_0 g\n%_GT\n\ + u\n0 0 0 1 k\n10 10 m\n10 50 L\n30 50 L\n30 10 L\nf\n%_2 0 0 0 Go\nu\nU\nU\n%_GS\n" + .to_string() +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` graph whose records are damaged, cut or hostile never panics. With only the numbers + /// in its records changed, the records keep their shape, and the bar still comes in. + #[test] + fn ai_graph_never_panics(cut in 0usize..700, edits in prop::collection::vec((0usize..700, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '[', ']', '/', '%', '_', 'G', 's', 'S', 't', 'T', 'u', 'U'])), 0..12), digits in prop::collection::vec((0usize..600, 0u8..10), 0..12)) { + let text = mutate_text(&graph_text(), cut, &edits); + let private = ai_private(&text); + survive("mutated .ai graph", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; + let src = graph_text(); + let records = src.find("u\n0 0 0 1 k").unwrap_or(0).max(1); + let mut kept: Vec = src.chars().collect(); + for (at, d) in digits { + if let Some(c) = kept.get_mut(at % records).filter(|c| c.is_ascii_digit()) { + *c = char::from(b'0' + d); + } + } + let (doc, notes, read) = vectorcraft_eps::layered_ai(&ai_private(&kept.into_iter().collect::()), Document::new(4000.0, 200.0), vec![], false, true, 1.0); + prop_assert!(read, "{:?}", notes); + let mut paths = 0; + doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + prop_assert_eq!(paths, 1, "{:?}", notes); + prop_assert!(notes.iter().any(|n| n.contains("graphs come in as plain art")), "{:?}", notes); + prop_assert!(!notes.iter().any(|n| n.contains("art this can't read")), "the records aren't art: {:?}", notes); + } +} diff --git a/crates/engine/tests/text_import_fuzz.rs b/crates/engine/tests/text_import_fuzz.rs index 867b016a9..1ea656a15 100644 --- a/crates/engine/tests/text_import_fuzz.rs +++ b/crates/engine/tests/text_import_fuzz.rs @@ -1,6 +1,6 @@ //! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import -//! Options, place as area type or fail with an error, and so do hostile RTF files and Word -//! documents. +//! Options, place as area type or fail with an error, and so do hostile RTF files, Word documents +//! and Word 97-2003 documents, including ones whose compound file container is damaged. //! //! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). @@ -38,8 +38,8 @@ proptest! { "replaceSpaces": spaces, }); let p = json!({"name": "fuzz.txt", "dataBase64": vectorcraft_format::base64_encode(&data), "text": text, "thumbnail": 8}); - let _ = s.execute("file.place.info", &p); - let _ = s.execute("file.place", &p); + let _ = no_panic(s.execute("file.place.info", &p)); + let _ = no_panic(s.execute("file.place", &p)); }); prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); } @@ -55,8 +55,8 @@ proptest! { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8}); - let _ = s.execute("file.place.info", &p); - let _ = s.execute("file.place", &p); + let _ = no_panic(s.execute("file.place.info", &p)); + let _ = no_panic(s.execute("file.place", &p)); }); prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); } @@ -86,11 +86,372 @@ proptest! { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8}); - let _ = s.execute("file.place.info", &p); - let _ = s.execute("file.place", &p); + let _ = no_panic(s.execute("file.place.info", &p)); + let _ = no_panic(s.execute("file.place", &p)); }); prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); } + + #[test] + fn any_doc_places_without_panics( + text in prop::collection::vec(prop::sample::select(DOC_CHARS.chars().collect::>()), 1..60), + compressed in any::(), + chpx in arb_grpprl(), + papx in arb_grpprl(), + istd in 0u16..4, + stsh in prop_oneof![1 => prop::collection::vec(any::(), 0..120), 3 => arb_stsh()], + ffn in prop::collection::vec(any::(), 0..80), + changes in prop_oneof![ + 3 => Just(vec![]), + 1 => prop::collection::vec((any::(), any::(), any::()), 1..4), + ], + name in prop::sample::select(vec!["fuzz.doc", "fuzz.bin"]), + ) { + vectorcraft_text::FontDb::global().set_system_fallback(false); + let text: String = text.into_iter().collect(); + let (mut wd, mut table) = word97(&text, compressed, &chpx, istd, &papx, &stsh, &ffn); + // A changed byte anywhere in either stream: in the FIB, the text, a property page or a + // table. + for (in_table, at, byte) in changes { + let stream = if in_table { &mut table } else { &mut wd }; + let i = at.index(stream.len()); + stream[i] = byte; + } + let data = compound_file(&wd, &table, cfb::Version::V4); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8}); + let _ = no_panic(s.execute("file.place.info", &p)); + let _ = no_panic(s.execute("file.place", &p)); + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } + + #[test] + fn any_damaged_doc_container_places_without_panics( + text in prop::collection::vec(prop::sample::select(DOC_CHARS.chars().collect::>()), 1..60), + compressed in any::(), + v3 in any::(), + large in any::(), + damage in prop::collection::vec(arb_cfb_damage(), 1..4), + name in prop::sample::select(vec!["fuzz.doc", "fuzz.bin"]), + ) { + vectorcraft_text::FontDb::global().set_system_fallback(false); + // A letter first, so that the undamaged document has text to place. + let text: String = std::iter::once('a').chain(text).collect(); + let (mut wd, table) = word97(&text, compressed, &[], 0, &[], &[], &[]); + // Past the mini stream cutoff (4,096 bytes) the WordDocument stream is kept in sectors the + // FAT chains; below it, both streams are kept in the mini stream the mini FAT chains. + if large { + wd.resize(9000, 0); + } + let mut data = compound_file(&wd, &table, if v3 { cfb::Version::V3 } else { cfb::Version::V4 }); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": "fuzz.doc", "dataBase64": vectorcraft_format::base64_encode(&data)}); + let placed = s.execute("file.place", &p); + prop_assert!(placed.is_ok(), "the undamaged document doesn't place: {:?}", placed.err()); + let layout = CfbLayout::of(&data); + for d in &damage { + d.apply(&mut data, &layout); + } + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8}); + let _ = no_panic(s.execute("file.place.info", &p)); + let _ = no_panic(s.execute("file.place", &p)); + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } +} + +/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as +/// `EngineError::Internal`. +fn no_panic(r: Result) -> Result { + if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r { + panic!("{cmd} panicked: {msg}"); + } + r +} + +/// A compound file of `version` holding the WordDocument stream `wd` and the table stream `table`. +fn compound_file(wd: &[u8], table: &[u8], version: cfb::Version) -> Vec { + let mut cf = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).unwrap(); + std::io::Write::write_all(&mut cf.create_stream("WordDocument").unwrap(), wd).unwrap(); + std::io::Write::write_all(&mut cf.create_stream("1Table").unwrap(), table).unwrap(); + cf.flush().unwrap(); + cf.into_inner().into_inner() +} + +/// The compound file header's fields (\[MS-CFB\] §2.2) a damage changes: their offset and width. +const CFB_HEADER: [(usize, usize); 17] = [ + (0, 4), // signature + (24, 2), // minor version + (26, 2), // major version + (28, 2), // byte order + (30, 2), // sector shift + (32, 2), // mini sector shift + (40, 4), // directory sectors + (44, 4), // FAT sectors + (48, 4), // first directory sector + (52, 4), // transaction signature + (56, 4), // mini stream cutoff + (60, 4), // first mini FAT sector + (64, 4), // mini FAT sectors + (68, 4), // first DIFAT sector + (72, 4), // DIFAT sectors + (76, 4), // DIFAT[0] + (80, 4), // DIFAT[1] +]; + +/// A directory entry's fields (\[MS-CFB\] §2.6.1) a damage changes: their offset and width. +const CFB_ENTRY: [(usize, usize); 10] = [ + (0, 2), // first character of the name + (64, 2), // name length + (66, 1), // object type + (67, 1), // color + (68, 4), // left sibling + (72, 4), // right sibling + (76, 4), // child + (116, 4), // starting sector + (120, 4), // stream size, low + (124, 4), // stream size, high +]; + +/// Where an undamaged compound file keeps its directory, FAT and mini FAT. +struct CfbLayout { + /// The bytes of one sector. + sector: usize, + /// The offsets of the first directory sector, the first FAT sector and the first mini FAT + /// sector. + directory: usize, + fat: usize, + mini_fat: usize, +} + +impl CfbLayout { + fn of(data: &[u8]) -> Self { + let u32_at = |at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as usize; + let sector = 1 << u16::from_le_bytes(data[30..32].try_into().unwrap()); + let at = |n: usize| (n + 1) * sector; + let layout = Self { sector, directory: at(u32_at(48)), fat: at(u32_at(76)), mini_fat: at(u32_at(60)) }; + // The root entry's name opens the directory, the FAT marks its own sector, and the mini FAT + // has a sector. + assert_eq!(&data[layout.directory..layout.directory + 4], b"R\0o\0"); + assert_eq!(u32_at(layout.fat + 4 * u32_at(76)), 0xFFFF_FFFD); + assert!(u32_at(64) >= 1); + layout + } +} + +/// A change to the container of a compound file rather than to the bytes of its streams. +#[derive(Clone, Debug)] +enum CfbDamage { + /// A header field set to a value. + Header(prop::sample::Index, u32), + /// A field of one of the first directory sector's entries set to a value. + Entry(prop::sample::Index, prop::sample::Index, u32), + /// An entry of the first FAT sector (the next sector of a chain) set to a value. + Fat(prop::sample::Index, u32), + /// An entry of the first mini FAT sector set to a value. + MiniFat(prop::sample::Index, u32), + /// A byte anywhere set to a value. + Byte(prop::sample::Index, u8), + /// The file cut short. + Truncate(prop::sample::Index), +} + +impl CfbDamage { + fn apply(&self, data: &mut Vec, layout: &CfbLayout) { + let mut put = |at: usize, width: usize, value: u32| { + if let Some(b) = data.get_mut(at..at + width) { + b.copy_from_slice(&value.to_le_bytes()[..width]); + } + }; + match self { + Self::Header(field, value) => { + let (at, width) = CFB_HEADER[field.index(CFB_HEADER.len())]; + put(at, width, *value); + } + Self::Entry(entry, field, value) => { + let (at, width) = CFB_ENTRY[field.index(CFB_ENTRY.len())]; + put(layout.directory + 128 * entry.index(layout.sector / 128) + at, width, *value); + } + Self::Fat(entry, value) => put(layout.fat + 4 * entry.index(layout.sector / 4), 4, *value), + Self::MiniFat(entry, value) => put(layout.mini_fat + 4 * entry.index(layout.sector / 4), 4, *value), + // A file cut to nothing has no byte to change and nothing to cut. + Self::Byte(..) | Self::Truncate(_) if data.is_empty() => {} + Self::Byte(at, value) => { + let i = at.index(data.len()); + data[i] = *value; + } + Self::Truncate(at) => data.truncate(at.index(data.len())), + } + } +} + +/// A sector number, size, count or shift: the special sector numbers (\[MS-CFB\] §2.1), small and +/// huge values, or any. +fn arb_cfb_value() -> impl Strategy { + prop_oneof![ + 3 => prop::sample::select(vec![ + 0, + 1, + 2, + 3, + 6, + 7, + 9, + 12, + 13, + 16, + 31, + 64, + 0x1000, + 0x1001, + 0x7FFF_FFFF, + 0xFFFF_FFFA, + 0xFFFF_FFFB, + 0xFFFF_FFFC, + 0xFFFF_FFFD, + 0xFFFF_FFFE, + 0xFFFF_FFFF, + ]), + 1 => any::(), + ] +} + +/// A [`CfbDamage`]. +fn arb_cfb_damage() -> impl Strategy { + let index = any::; + prop_oneof![ + 3 => (index(), arb_cfb_value()).prop_map(|(f, v)| CfbDamage::Header(f, v)), + 3 => (index(), index(), arb_cfb_value()).prop_map(|(e, f, v)| CfbDamage::Entry(e, f, v)), + 2 => (index(), arb_cfb_value()).prop_map(|(e, v)| CfbDamage::Fat(e, v)), + 2 => (index(), arb_cfb_value()).prop_map(|(e, v)| CfbDamage::MiniFat(e, v)), + 1 => (index(), any::()).prop_map(|(at, v)| CfbDamage::Byte(at, v)), + 1 => index().prop_map(CfbDamage::Truncate), + ] +} + +/// Characters of a Word document's text: letters, marks of paragraphs, cells and breaks, fields, +/// pictures, hyphens and surrogate-free Unicode. +const DOC_CHARS: &str = "ab \r\u{7}\u{B}\u{C}\u{13}\u{14}\u{15}\u{1}\u{8}\t\u{1E}\u{1F}(é“✓"; + +/// Character and paragraph properties: Prls the Word 97-2003 reader knows, with operands in and out +/// of range, and raw bytes. +fn arb_grpprl() -> impl Strategy> { + let prl = prop_oneof![ + prop::sample::select(vec![ + &[0x35, 0x08, 0x81][..], + &[0x35, 0x08, 0x80], + &[0x3C, 0x08, 0x01], + &[0x00, 0x08, 0x01], + &[0x01, 0x08, 0x01], + &[0x55, 0x08, 0x01], + &[0x30, 0x4A, 0x0A, 0x00], + &[0x30, 0x4A, 0xFF, 0xFF], + &[0x43, 0x4A, 0xFF, 0xFF], + &[0x4F, 0x4A, 0x07, 0x00], + &[0x42, 0x2A, 0x63], + &[0x70, 0x68, 1, 2, 3, 0xFF], + &[0x45, 0x48, 0x00, 0x80], + &[0x03, 0x24, 0x09], + &[0x61, 0x24, 0x08], + &[0x17, 0x24, 0x01], + &[0x4B, 0x24, 0x01], + &[0x41, 0x24, 0x01], + &[0x0F, 0x84, 0x00, 0x80], + &[0x13, 0xA4, 0xFF, 0xFF], + &[0x08, 0xD6, 0xFF, 0xFF], + &[0x15, 0xC6, 0xFF, 0x7F], + &[0x2A, 0xC6, 0xFF], + ]) + .prop_map(<[u8]>::to_vec), + prop::collection::vec(any::(), 0..4), + ]; + prop::collection::vec(prl, 0..6).prop_map(|p| p.concat()) +} + +/// A style sheet of up to four styles (paragraph or character, based on another or none) with +/// properties of [`arb_grpprl`], or empty slots. +fn arb_stsh() -> impl Strategy> { + let style = prop::option::of((1u16..3, prop_oneof![Just(0x0FFFu16), 0u16..5], arb_grpprl(), arb_grpprl())); + prop::collection::vec(style, 0..5).prop_map(|styles| { + let words = |v: &[u16]| v.iter().flat_map(|w| w.to_le_bytes()).collect::>(); + let mut stsh = words(&[18, styles.len() as u16, 10, 1, 0, 15, 0, 0, 0, 0]); + for s in styles { + let Some((stk, base, papx, chpx)) = s else { + stsh.extend([0, 0]); + continue; + }; + let upx = |b: Vec| [words(&[b.len() as u16]), b.clone(), vec![0; b.len() % 2]].concat(); + let (cupx, grlpupx) = if stk == 1 { (2, [upx([vec![0, 0], papx].concat()), upx(chpx)].concat()) } else { (1, upx(chpx)) }; + let mut std = words(&[0x0FFE, stk | (base << 4), cupx | (0x0FFF << 4), 0, 0, 1, u16::from(b'S'), 0]); + std.extend(grlpupx); + stsh.extend((std.len() as u16).to_le_bytes()); + stsh.extend(std); + } + stsh + }) +} + +/// The WordDocument and table streams of a Word 97-2003 document whose main text is `text` in one +/// piece, with one page of character properties `chpx` and one of paragraph properties `papx` of +/// style `istd` covering it, and the style sheet `stsh` and font table `ffn`. +fn word97(text: &str, compressed: bool, chpx: &[u8], istd: u16, papx: &[u8], stsh: &[u8], ffn: &[u8]) -> (Vec, Vec) { + let mut wd = vec![0u8; 1024]; + let at = wd.len() as u32; + if compressed { + wd.extend(text.chars().map(|c| if c == '“' { 0x93 } else { u32::from(c).min(0xFF) as u8 })); + } else { + wd.extend(text.encode_utf16().flat_map(u16::to_le_bytes)); + } + let ccp = text.encode_utf16().count() as u32; + let end = wd.len() as u32; + // One page each: a run of every character, its properties near the page's end. + let page = |props: Vec, papx: bool| { + let mut p = vec![0u8; 512]; + p[0..4].copy_from_slice(&at.to_le_bytes()); + p[4..8].copy_from_slice(&end.to_le_bytes()); + let blob = if papx { [vec![0, (props.len() / 2) as u8], props].concat() } else { [vec![props.len() as u8], props].concat() }; + let top = (511 - blob.len().min(400)) & !1; + let n = blob.len().min(511 - top); + p[top..top + n].copy_from_slice(&blob[..n]); + p[8] = (top / 2) as u8; + p[511] = 1; + p + }; + let papx_props = [istd.to_le_bytes().to_vec(), papx.to_vec(), vec![0; papx.len() % 2]].concat(); + wd.resize(wd.len().div_ceil(512) * 512, 0); + let chpx_page = (wd.len() / 512) as u32; + wd.extend(page(chpx.iter().copied().take(200).collect(), false)); + wd.extend(page(papx_props.into_iter().take(400).collect(), true)); + let bte = |pn: u32| [at, end, pn].into_iter().flat_map(u32::to_le_bytes).collect::>(); + let fc = if compressed { (at * 2) | 0x4000_0000 } else { at }; + let plc = [&0u32.to_le_bytes()[..], &ccp.to_le_bytes(), &[0, 0], &fc.to_le_bytes(), &[0, 0]].concat(); + let clx = [&[0x02][..], &(plc.len() as u32).to_le_bytes(), &plc].concat(); + let mut table = vec![]; + let mut pairs = vec![(0u32, 0u32); 0x5D]; + for (index, part) in [(1, stsh.to_vec()), (12, bte(chpx_page)), (13, bte(chpx_page + 1)), (15, ffn.to_vec()), (33, clx)] { + pairs[index] = (table.len() as u32, part.len() as u32); + table.extend(part); + } + let words = |v: &[u16]| v.iter().flat_map(|w| w.to_le_bytes()).collect::>(); + let mut put = |at: usize, bytes: &[u8]| wd[at..at + bytes.len()].copy_from_slice(bytes); + put(0, &words(&[0xA5EC, 0x00C1])); + put(10, &words(&[0x0200, 0x00BF])); + put(32, &words(&[14])); + put(62, &words(&[22])); + put(76, &ccp.to_le_bytes()); + put(80, &1u32.to_le_bytes()); + put(152, &words(&[0x5D])); + for (i, (fc, lcb)) in pairs.iter().enumerate() { + put(154 + 8 * i, &[fc.to_le_bytes(), lcb.to_le_bytes()].concat()); + } + (wd, table) } /// A well-formed paragraph, or a table row of two such paragraphs. diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 5a7a9b491..9c84001f7 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -18,7 +18,8 @@ //! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0 //! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that //! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads -//! an Illustrator EPS's editing copy (#1027). One VectorCraft wrote (its prolog the procedure set +//! an Illustrator EPS's editing copy (#1027), and one without layers (Illustrator 3) comes in +//! through its art put on one layer, as its page when that can't be read. One VectorCraft wrote (its prolog the procedure set //! [`crate::legacy_ai::PROCSET`], which prints its gradients flat and leaves its type out) comes in //! from those layers alone, without its page. //! @@ -69,7 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub use native::{ai_alone, is_loss, layered_ai}; +pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai}; pub use text::family_style; /// The page of a PostScript file without a bounding box (US Letter). @@ -266,7 +267,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 291171f78..98f408578 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -18,6 +18,7 @@ //! did before (the printed page, or the PDF), with a warning saying why. mod blend; +mod envelope; mod frames; mod lex; mod mesh; @@ -32,9 +33,10 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use frames::text_frames; -pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables}; -pub(crate) use read::{slot_frame, slot_of}; +pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, read_beside, variables}; +pub(crate) use read::{join_and, slot_frame, slot_of}; #[cfg(test)] mod tests; @@ -158,11 +160,11 @@ impl Codec { flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).map_err(|_| damaged("its zlib compression"))?; } Codec::Zstd => { - // The data may be several frames one after the other, padded with zeros. - let end = data.iter().rposition(|b| *b != 0).map_or(0, |i| i + 1); - let mut src = data.get(..end).unwrap_or_default(); + // The data may be several frames one after the other, padded with zeros. A frame + // can itself end in a zero byte, so the padding is what is left after the last frame. + let mut src = data; let mut frames = 0usize; - while !src.is_empty() { + while src.iter().any(|b| *b != 0) { frames += 1; if frames > MAX_FRAMES { return Err(damaged("its Zstandard compression")); diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs new file mode 100644 index 000000000..33fae9eab --- /dev/null +++ b/crates/eps/src/import/ai/envelope.rs @@ -0,0 +1,361 @@ +//! Envelopes from the editing data (`(Adobe Deform Plugin) 1 0 0 XP`), read as live envelopes. +//! +//! What an envelope is and does is Illustrator's public documentation: Make with Warp, Make with +//! Mesh (rows and columns) and Make with Top Object +//! (), +//! Edit Contents +//! (), +//! Envelope Options +//! () +//! and the warp styles with their bend and distortions +//! (). +//! +//! An envelope is written in three parts. On `%_` lines, a group holding the mesh that shapes it, +//! then its `/ArtDictionary`: Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity +//! (`AIDeformFidelityKey`), Edit Contents (`AIDeformEditModeKey`), a warp's style (`DeformStyle`, +//! numbered as Effect › Warp lists them), bend and distortions (`DeformValue`, `DeformHoriz`, +//! `DeformVert`, fractions) and orientation (`Rotate`), the contents (`X=` … `X+`, then +//! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the +//! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group +//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its +//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its +//! drawn art. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use kurbo::{BezPath, Point, Rect, Vec2}; +use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity}; +use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; +use vectorcraft_geom::PathData; + +use super::obj::Obj; +use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour}; + +/// The plugin that writes envelopes. +pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use +/// The note for an envelope that keeps its drawn art. +pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; +/// The most envelopes waiting for their drawn art at once. +pub(super) const MAX_PENDING: usize = 64; +/// The most envelopes one import draws to check; more keep their drawn art. +pub(super) const MAX_CHECKED: usize = 256; +/// The most rows or columns of a mesh read (Make with Mesh's own limit). +const MAX_GRID: u32 = 50; +/// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort +/// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask). +const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; +/// More of the inked pixels than this differing, each with all its neighbours, and the envelope +/// doesn't come out as drawn. +const MAX_SOLID: f64 = 0.03; +/// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn. +const MAX_ANY: f64 = 0.1; + +/// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one. +fn whole(o: &Obj, key: &str) -> Option> { + o.get(key)?; + let n = o.nums(key); + Some(match n.as_slice() { + [v] if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e12 => Some(*v as i64), + _ => None, + }) +} + +/// A switch (`0` or `1`), off when missing. +fn switch(o: &Obj, key: &str) -> Option { + match whole(o, key) { + None | Some(Some(0)) => Some(false), + Some(Some(1)) => Some(true), + _ => None, + } +} + +/// Envelope Options and Fidelity. +pub(super) fn options(o: &Obj) -> Option<(EnvelopeOptions, f64)> { + let word = whole(o, "AIDeformOptionsKey")??; + if word < 0 || word & !KNOWN_BITS != 0 { + return None; + } + let fidelity = match whole(o, "AIDeformFidelityKey") { + None => default_fidelity(), + Some(Some(f)) if (0..=100).contains(&f) => f as f64, + Some(_) => return None, + }; + let bit = |n: u32| word & (1 << n) != 0; + let options = EnvelopeOptions { + anti_alias: !bit(16), + preserve_shape: if bit(17) { PreserveShape::ClippingMask } else { PreserveShape::Transparency }, + distort_appearance: bit(0), + distort_pattern_fills: bit(1), + distort_linear_gradients: bit(2), + }; + Some((options, fidelity)) +} + +/// A warp: its style, bend, distortions and orientation. +fn warp(o: &Obj) -> Option { + let style = whole(o, "DeformStyle")??; + let id = super::style::warp_style(style)?; + let percent = |key: &str| -> Option { + if o.get(key).is_none() { + return Some(0.0); + } + match o.nums(key).as_slice() { + [v] if v.is_finite() && (-1.0..=1.0).contains(v) => Some(v * 100.0), + _ => None, + } + }; + o.get("DeformValue")?; + let vertical = switch(o, "Rotate")?; + Some(EnvelopeKind::Warp { + style: id.into(), + bend: percent("DeformValue")?, + h: percent("DeformHoriz")?, + v: percent("DeformVert")?, + horizontal: !vertical, + }) +} + +/// The gradient mesh `n` is, or the one its plain groups hold alone. +fn only_mesh(n: &Node) -> Option<&GradientMesh> { + match &n.kind { + NodeKind::Mesh(m) => Some(m), + NodeKind::Group { children, clip: false } => match children.as_slice() { + [c] => only_mesh(c), + _ => None, + }, + _ => None, + } +} + +/// A grid point and its handles. +type GridPoint = (Point, [Vec2; 4]); + +/// The points of mesh `m` as a `rows`×`cols` grid with its first row at the top and its first +/// column at the left (y down), as an envelope's mesh lays them out; `None` for a grid out of range +/// or with points that aren't finite. +fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { + let (mut rows, mut cols) = (m.rows, m.cols); + if rows == 0 || cols == 0 || rows > MAX_GRID || cols > MAX_GRID { + return None; + } + let len = (rows as usize + 1) * (cols as usize + 1); + if m.points.len() != len { + return None; + } + let finite = |v: Vec2| v.x.is_finite() && v.y.is_finite(); + if !m.points.iter().all(|q| finite(q.p.to_vec2()) && q.handles.iter().all(|h| finite(*h))) { + return None; + } + let mut pts: Vec = m.points.iter().map(|q| (q.p, q.handles)).collect(); + let at = |pts: &[GridPoint], cols: u32, r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).map_or(Point::ZERO, |q| q.0); + let remap = |h: [Vec2; 4], slots: [usize; 4]| slots.map(|s| h.get(s).copied().unwrap_or(Vec2::ZERO)); + // Columns that run down the page: the grid goes the other way round. + let along = at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0); + if along.y.abs() > along.x.abs() { + let mut t = Vec::with_capacity(len); + for r in 0..=cols { + for c in 0..=rows { + let (p, h) = pts.get((c * (cols + 1) + r) as usize).copied()?; + // The new next column is the old next row. + let mut slots = [0; 4]; + (slots[H_RIGHT], slots[H_LEFT], slots[H_DOWN], slots[H_UP]) = (H_DOWN, H_UP, H_RIGHT, H_LEFT); + t.push((p, remap(h, slots))); + } + } + (rows, cols) = (cols, rows); + pts = t; + } + if (at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0)).x < 0.0 { + for row in pts.chunks_mut(cols as usize + 1) { + row.reverse(); + for (_, h) in row.iter_mut() { + h.swap(H_RIGHT, H_LEFT); + } + } + } + if (at(&pts, cols, rows, 0) - at(&pts, cols, 0, 0)).y < 0.0 { + let mut flipped: Vec = pts.chunks(cols as usize + 1).rev().flatten().copied().collect(); + for (_, h) in &mut flipped { + h.swap(H_DOWN, H_UP); + } + pts = flipped; + } + Some((rows, cols, pts)) +} + +/// The outline of an upright grid: its edge, clockwise from the top left corner. +fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { + let at = |r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).copied(); + // Each edge: from a point by one of its handles to the next point by one of its handles. + type Edge = ((u32, u32), usize, (u32, u32), usize); + let mut ring: Vec = vec![]; + ring.extend((0..cols).map(|c| ((0, c), H_RIGHT, (0, c + 1), H_LEFT))); + ring.extend((0..rows).map(|r| ((r, cols), H_DOWN, (r + 1, cols), H_UP))); + ring.extend((1..=cols).rev().map(|c| ((rows, c), H_LEFT, (rows, c - 1), H_RIGHT))); + ring.extend((1..=rows).rev().map(|r| ((r, 0), H_UP, (r - 1, 0), H_DOWN))); + let mut bp = BezPath::new(); + bp.move_to(at(0, 0)?.0); + for ((ar, ac), out, (br, bc), into) in ring { + let ((a, ha), (b, hb)) = (at(ar, ac)?, at(br, bc)?); + bp.curve_to(a + *ha.get(out)?, b + *hb.get(into)?, b); + } + bp.close_path(); + Some(PathData::from_bezpath(&bp)) +} + +/// Has group `n` nothing of its own: no name, lock, transparency, appearance or other properties, +/// shown? +fn plain_group(n: &Node) -> bool { + let plain = Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && (&n.name, n.visible, n.locked, n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) + == (&plain.name, plain.visible, plain.locked, plain.opacity, plain.blend, plain.isolate, plain.knockout, plain.knockout_shape) + && n.appearance == plain.appearance + && n.extras() == plain.extras() +} + +/// `nodes` without the plain group the editing data puts round an envelope's contents. +fn unwrapped(mut nodes: Vec) -> Vec> { + if nodes.len() == 1 + && let Some(n) = nodes.first() + && plain_group(n) + && let Some(n) = nodes.pop() + && let NodeKind::Group { children, .. } = n.kind + { + return children; + } + nodes.into_iter().map(Arc::new).collect() +} + +/// The live envelope of dictionary `o`, the group on `%_` lines before it (`before`) and the art +/// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one. +pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option { + let editing = switch(o, "AIDeformEditModeKey")?; + let (options, fidelity) = options(o)?; + let before = before?; + // While the contents are edited, they are the group before the dictionary and the mesh is in it. + let (content, shape): (Vec>, Option<&GradientMesh>) = if editing { + let NodeKind::Group { children, clip: false } = &before.kind else { return None }; + let shape = match inner.as_slice() { + [n] => only_mesh(n), + _ => None, + }; + (children.clone(), shape) + } else { + let shape = only_mesh(&before); + (unwrapped(inner), shape) + }; + if content.is_empty() { + return None; + } + let kind = match whole(o, "AIDeformStyle")?? { + 1 => warp(o)?, + 2 => { + let (rows, cols, pts) = upright(shape?)?; + let (points, handles) = pts.into_iter().unzip(); + EnvelopeKind::Mesh { rows, cols, points, handles } + } + 3 => { + let (rows, cols, pts) = upright(shape?)?; + EnvelopeKind::TopObject { path: outline(rows, cols, &pts)? } + } + _ => return None, + }; + Some(NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing, options, frame: Box::default() }) +} + +/// What the import's documents hold that art refers to: the patterns and image blobs. +pub(super) struct Shared<'a> { + pub(super) patterns: &'a [PatternDef], + pub(super) images: &'a BTreeMap, +} + +/// A document of `node` alone, on a layer with id `layer`, with the patterns and images it shows. +fn alone(node: &Node, layer: NodeId, shared: &Shared<'_>) -> Document { + let mut doc = Document::new(1.0, 1.0); + let mut l = Node::layer(layer, "", LayerColor::Preset(0)); + if let Some(c) = l.children_mut() { + c.push(Arc::new(node.clone())); + } + doc.layers = vec![Arc::new(l)]; + doc.patterns = shared.patterns.to_vec(); + let mut keys = vec![]; + doc.visit_images(|_, im| keys.push(im.key.clone())); + for k in keys { + if let Some(blob) = shared.images.get(&k) { + doc.images.insert(k, blob.clone()); + } + } + doc +} + +/// How `live` and `drawn`, drawn over `region` at `scale`, differ: the pixels that differ in +/// lightness or colour, those whose neighbours all differ too (where one draws something the other +/// doesn't, not an edge drawn a little apart), and the pixels either inks. `None` when they can't +/// be drawn. +fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Option<(usize, usize, usize)> { + let x = vectorcraft_render::Renderer::new().render_region(live, region, scale, false); + let y = vectorcraft_render::Renderer::new().render_region(drawn, region, scale, false); + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + return None; + } + let (w, h) = (x.width as usize, x.height as usize); + let (xs, ys) = (x.pixels.as_chunks::<4>().0, y.pixels.as_chunks::<4>().0); + let differs: Vec = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q)).collect(); + let inked = xs.iter().zip(ys).filter(|(p, q)| p[3] > 8 || q[3] > 8).count(); + let at = |i: usize, j: usize| differs.get(j * w + i).copied().unwrap_or(false); + let any = differs.iter().filter(|d| **d).count(); + let solid = (1..h.saturating_sub(1)) + .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) + .filter(|&(i, j)| (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) + .count(); + Some((any, solid, inked)) +} + +/// The differences from their drawn art that the live envelopes of one import may add up to, as +/// shares of the page drawn as the page check draws it: half of what that check allows +/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so live envelopes never make the file open from +/// its PDF part instead. +#[derive(Debug, Default)] +pub(super) struct Spent { + any: f64, + solid: f64, +} + +/// Do `live` and `drawn` draw alike: few of the pixels either inks differ, and fewer still where a +/// whole patch differs; and, on `page` (when the file has one), do they differ little enough that +/// with the differences in `spent` the page check still takes the layers' art (it is added to +/// `spent` when they do)? Both are drawn on `layer`, a layer id no other node has. +pub(super) fn drawn_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { + let (Some(a), Some(b)) = (live.visual_bounds(), drawn.visual_bounds()) else { return false }; + let region = a.union(b).inflate(2.0, 2.0); + if !(region.is_finite() && region.width() > 0.0 && region.height() > 0.0) || region.width().max(region.height()) > 1e6 { + return false; + } + let (x, y) = (alone(live, layer, shared), alone(drawn, layer, shared)); + let Some((any, solid, inked)) = differing(&x, &y, region, compare_scale(region)) else { return false }; + if inked == 0 { + return false; + } + let share = |n: usize| n as f64 / inked as f64; + if share(solid) > MAX_SOLID || share(any) > MAX_ANY { + return false; + } + let Some(page) = page.filter(|p| p.is_finite() && p.width() > 0.0 && p.height() > 0.0) else { return true }; + // Only what is on the page counts, drawn as the page check draws it (about 400 pixels along the + // page's longer side). + let on_page = region.intersect(page); + let scale = compare_scale(page); + if on_page.width() * scale < 1.0 || on_page.height() * scale < 1.0 { + return true; + } + let Some((any, solid, _)) = differing(&x, &y, on_page, scale) else { return false }; + let pixels = (page.width() * scale * page.height() * scale).max(1.0); + let (any, solid) = (spent.any + any as f64 / pixels, spent.solid + solid as f64 / pixels); + if any > MAX_DIFFERENCE / 2.0 || solid > MAX_SOLID_DIFFERENCE / 2.0 { + return false; + } + *spent = Spent { any, solid }; + true +} diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 126619c13..17590b320 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -1,6 +1,7 @@ //! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their //! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the -//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`), the paint style, and +//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's +//! letter and word spacing `TC TW`, hanging punctuation `Tq` and hyphenation `Th`), the paint style, and //! the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the //! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the //! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its @@ -9,9 +10,10 @@ use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TextKind, TextObject, TextRun}; -use vectorcraft_geom::PathData; +use vectorcraft_geom::AnchorKind; use super::super::obj::V; +use super::art::path_data; use super::{FLIP, GState, Reader}; use crate::latin; @@ -31,8 +33,8 @@ pub(super) struct LegacyText { start: f64, /// Between `Tp` and `TP`: the path read is the text path. in_path: bool, - /// The frame or path, in art space. - path: Option, + /// The frame or path, in art space, and the kind of the anchor each of its segments ends on. + path: Option<(BezPath, Vec)>, runs: Vec, /// Each paragraph's attributes, as they were at its first character. paras: Vec, @@ -78,8 +80,23 @@ impl TypeState { } } -fn num(vals: &[V], i: usize) -> Option { - vals.iter().filter_map(V::num).nth(i).filter(|v| v.is_finite()) +/// An operator's `N` operands, the last `N` values before it, when each is a finite number. +fn nums(vals: &[V]) -> Option<[f64; N]> { + let operands = vals.get(vals.len().checked_sub(N)?..)?; + let mut out = [0.0; N]; + for (o, v) in out.iter_mut().zip(operands) { + *o = v.num().filter(|n| n.is_finite())?; + } + Some(out) +} + +/// A flag operand: 0 or 1. +fn flag(v: f64) -> Option { + match v { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + } } impl Reader<'_> { @@ -110,35 +127,44 @@ impl Reader<'_> { /// `type To`: a text object begins. pub(super) fn begin_text(&mut self, vals: &[V], hidden: bool) { self.end_path_quietly(); - let kind = num(vals, 0).map_or(0, |k| k as i64); + if self.pattern.is_some() { + self.unreadable("type in a pattern", false); + } + let kind = nums::<1>(vals).map_or(0, |[k]| k as i64); self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() }); } /// `a b c d tx ty startPt Tp`: the text path begins. pub(super) fn begin_text_path(&mut self, vals: &[V]) { self.end_path_quietly(); - let n: Vec = vals.iter().filter_map(V::num).collect(); let Some(t) = self.text.as_mut() else { return }; - if let Some(m) = n.get(..6).and_then(|m| <[f64; 6]>::try_from(m).ok()) { + // Seven operands, or the matrix alone. + let (m, start) = match vals.len() { + 7 => (vals.get(..6).and_then(nums::<6>), vals.get(6).and_then(V::num)), + 6 => (nums::<6>(vals), None), + _ => (None, None), + }; + if let Some(m) = m { let m = Affine::new(m); let det = m.determinant(); - if m.as_coeffs().iter().all(|v| v.is_finite()) && det.is_finite() && det.abs() > 1e-12 { + if det.is_finite() && det.abs() > 1e-12 { t.matrix = Some(m); } } - t.start = n.get(6).copied().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); + t.start = start.filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); t.in_path = true; } /// The path painted (or `TP`, ending the text path): the text object's frame or path. pub(super) fn text_path(&mut self) { let path = std::mem::take(&mut self.path); + let kinds = std::mem::take(&mut self.path_kinds); self.end_path_quietly(); if let Some(t) = self.text.as_mut() && t.path.is_none() && !path.elements().is_empty() { - t.path = Some(path); + t.path = Some((path, kinds)); } } @@ -150,56 +176,122 @@ impl Reader<'_> { } } - /// A character or paragraph attribute. + /// A character or paragraph attribute. An attribute whose operands are missing, are not + /// numbers, or are flags other than 0 and 1 stays as it was. pub(super) fn text_attr(&mut self, w: &str, vals: &[V]) { let st = &mut self.type_state; - let n0 = num(vals, 0); + let n0 = nums::<1>(vals).map(|[v]| v); match w { - "Tr" => st.render = n0.map_or(0, |v| v as i64), + "Tr" => { + if let Some(v) = n0 { + st.render = v as i64; + } + } "Tf" => { - if let Some(name) = vals.iter().find_map(|v| if let V::Name(n) = v { Some(n.clone()) } else { None }) { - let name = st - .fonts - .iter() - .find(|(new, _)| *new == name) - .map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); + // `/name size Tf`, or `/name size ascent descent Tf`. + let Some(at) = vals.iter().position(|v| matches!(v, V::Name(_))) else { return }; + if let Some(V::Name(name)) = vals.get(at) { + let name = + st.fonts.iter().find(|(new, _)| new == name).map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); let (family, style) = super::super::super::text::family_style(&name); st.style.font_family = family; st.style.font_style = style; st.style.font_version = None; } - if let Some(size) = num(vals, 0).filter(|s| *s > 0.0) { + if let Some(size) = vals.get(at + 1).and_then(V::num).filter(|s| s.is_finite() && *s > 0.0) { st.style.size = size.min(MAX_SIZE); } } - "Ts" => st.style.baseline_shift = n0.unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE), + "Ts" => { + if let Some(v) = n0 { + st.style.baseline_shift = v.clamp(-MAX_SIZE, MAX_SIZE); + } + } "Tz" => { + // `parallelScale perpendicularScale Tz`; before version 7, `scale Tz`. let scale = |v: f64| v.clamp(1.0, 10_000.0); - st.style.h_scale = n0.map_or(100.0, scale); - st.style.v_scale = num(vals, 1).map_or(100.0, scale); + let scales = match vals.len() { + 2 => nums::<2>(vals), + 1 => n0.map(|h| [h, 100.0]), + _ => None, + }; + if let Some([h, v]) = scales { + (st.style.h_scale, st.style.v_scale) = (scale(h), scale(v)); + } + } + "Tt" => { + if let Some(v) = n0 { + st.style.tracking = v.clamp(-10_000.0, 10_000.0); + } + } + "TA" => { + if let Some(auto) = n0.and_then(flag) { + st.style.kerning = if auto { None } else { Some(0.0) }; + } } - "Tt" => st.style.tracking = n0.unwrap_or(0.0).clamp(-10_000.0, 10_000.0), - "TA" => st.style.kerning = if n0 == Some(0.0) { Some(0.0) } else { None }, "Tl" => { - if let Some(l) = n0 { + // `leading paragraphLeading Tl`; before version 7, `leading Tl`. + let leading = match vals.len() { + 2 => nums::<2>(vals), + 1 => n0.map(|l| [l, 0.0]), + _ => None, + }; + if let Some([l, before]) = leading { let l = l.clamp(0.0, MAX_SIZE); // The automatic leading, 120% of the size, stays automatic. st.style.leading = if (l - st.style.size * 1.2).abs() < 1e-3 { None } else { Some(l) }; + st.para.space_before = before.clamp(-MAX_SIZE, MAX_SIZE); } - st.para.space_before = num(vals, 1).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE); } "Ta" => { - st.para.justify = match n0.map(|v| v as i64) { - Some(1) => Justify::Center, - Some(2) => Justify::Right, - Some(3) => Justify::JustifyLeft, - Some(4) => Justify::JustifyAll, - _ => Justify::Left, + let justify = match n0 { + Some(0.0) => Some(Justify::Left), + Some(1.0) => Some(Justify::Center), + Some(2.0) => Some(Justify::Right), + Some(3.0) => Some(Justify::JustifyLeft), + Some(4.0) => Some(Justify::JustifyAll), + _ => None, + }; + if let Some(j) = justify { + st.para.justify = j; } } "Ti" => { - let indent = |i: usize| num(vals, i).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE); - (st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (indent(0), indent(1), indent(2)); + if let Some(i) = nums::<3>(vals) { + let [first, left, right] = i.map(|v| v.clamp(-MAX_SIZE, MAX_SIZE)); + (st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (first, left, right); + } + } + // `min opt max TC` (letter spacing) and `min opt max TW` (word spacing), in percent of + // a space's width, as the paragraph's Justification options take them. + "TC" | "TW" => { + if let Some(v) = nums::<3>(vals) { + let j = &mut st.para.justification; + if w == "TC" { + j.letter_spacing = v; + } else { + j.word_spacing = v; + } + j.keep_in_range(); + } + } + // `hangingQuotes Tq`. + "Tq" => { + if let Some(on) = n0.and_then(flag) { + st.para.roman_hanging = on; + } + } + // `hyphenate limitHyphLines minLeadHyphen minTailHyphen maxHyphLines Th`, kept to the + // ranges `text.setFormat` takes. + "Th" => { + let Some([on, limit, lead, tail, max]) = nums::<5>(vals) else { return }; + let (Some(on), Some(limit)) = (flag(on), flag(limit)) else { return }; + let letters = |v: f64, lo: f64, hi: f64| v.round().clamp(lo, hi) as u32; + st.para.hyphenate = on; + let h = &mut st.para.hyphenation; + h.after_first = letters(lead, 1.0, 15.0); + h.before_last = letters(tail, 1.0, 15.0); + h.hyphen_limit = if limit { letters(max, 0.0, 25.0) } else { 0 }; } _ => {} } @@ -222,9 +314,12 @@ impl Reader<'_> { t.pending_break = true; return; } - if st.chars >= MAX_CHARS { + // As many characters as the file's allowance has left. + let room = MAX_CHARS.saturating_sub(st.chars); + if room == 0 { return; } + let decoded: String = decoded.chars().take(room).collect(); st.chars += decoded.chars().count(); let gs = &self.gs; let mut style = st.style.clone(); @@ -236,7 +331,7 @@ impl Reader<'_> { }; style.fill = if fill { gs.fill.clone() } else { Paint::None }; style.stroke = if stroke { gs.stroke.clone() } else { Paint::None }; - style.stroke_width = if stroke { gs.width } else { 0.0 }; + style.stroke_width = if stroke { gs.line_width() } else { 0.0 }; (style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone()); (style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke); let mut text = String::new(); @@ -274,8 +369,10 @@ impl Reader<'_> { // Art space → text space (y down). let to_text = FLIP * m.inverse(); let kind = match (t.kind, &t.path) { - (1, Some(p)) => TextKind::Area { frame: PathData::from_bezpath(&(to_text * p.clone())) }, - (2, Some(p)) => TextKind::OnPath { path: PathData::from_bezpath(&(to_text * p.clone())), start: start_fraction(p, t.start), end: None }, + (1, Some((p, kinds))) => TextKind::Area { frame: path_data(&(to_text * p.clone()), kinds) }, + (2, Some((p, kinds))) => { + TextKind::OnPath { path: path_data(&(to_text * p.clone()), kinds), start: start_fraction(p, t.start), end: None } + } _ => TextKind::Point, }; let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default()); diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 9b81e29a8..eb8841e0d 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -8,6 +8,9 @@ //! line starts a comment that is skipped. //! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed //! file is one [`Tok::Preview`]. +//! - A document included whole (`%%BeginDocument:` … `%%EndDocument` lines, DSC 3.0 p. 68, nested +//! ones counted, the counted payloads of `%%BeginData:` and `%%BeginBinary:` inside skipped) is +//! one [`Tok::Document`]. use super::super::lex::{find, hex_decode}; @@ -29,6 +32,9 @@ pub(super) enum Tok<'a> { Data(&'a [u8]), /// A placed file's preview: what is between `%AI26_BeginPlacedObjectPreview` and its end. Preview(&'a [u8]), + /// A document included whole: what is between `%%BeginDocument:`'s line and the line of the + /// `%%EndDocument` that ends it, or `None` when none does (the rest of the data is skipped). + Document(Option<&'a [u8]>), } #[derive(Clone, Debug, PartialEq)] @@ -42,7 +48,11 @@ pub(super) struct Token<'a> { const PREVIEW: (&[u8], &[u8]) = (b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview"); /// A placed file's markers, which a hidden one has on `%_` lines. const PLACE: [&[u8]; 2] = [b"%AI5_BeginPlace", b"%AI5_EndPlace"]; -/// A binary section skipped whole: (begin, end) markers. +/// A path's note comments, also read on `%_` lines. +const NOTES: [&[u8]; 3] = [b"%AI3_Note:", b"%%Note:", b"%AI5_NoteMore:"]; +/// The markers around a placed file's relative folder, which a hidden one has on `%_` lines too. +const RELATIVE: [&[u8]; 2] = [b"%AI28_BeginPlacedRelativePath", b"%AI28_EndPlacedRelativePath"]; +/// A document included whole: (begin, end) markers. const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument"); fn is_space(b: u8) -> bool { @@ -89,6 +99,18 @@ impl<'a> Lexer<'a> { rest.get(..n).unwrap_or_default() } + /// [`Self::line`], the position moved past its end too (`\r`, `\n` or `\r\n`). + fn whole_line(&mut self) -> &'a [u8] { + let line = self.line(); + if self.peek() == Some(b'\r') { + self.pos += 1; + } + if self.peek() == Some(b'\n') { + self.pos += 1; + } + line + } + /// Where the next token is looked for. pub fn offset(&self) -> usize { self.pos @@ -144,8 +166,9 @@ impl<'a> Lexer<'a> { if start { return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false }); } - // So does a hidden placed file: its `~` ends what `%_%AI5_BeginPlace` begins. - if prefixed && PLACE.iter().any(|m| line.starts_with(m)) { + // So does a hidden placed file: its `~` ends what `%_%AI5_BeginPlace` begins. And a + // hidden path's note. + if prefixed && PLACE.iter().chain(&NOTES).chain(&RELATIVE).any(|m| line.starts_with(m)) { return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: true }); } // A comment later in a line. @@ -180,13 +203,15 @@ impl<'a> Lexer<'a> { /// The token a comment line stands for when it starts a section read differently: image data /// (`%%BeginData:` followed by `XI`), a placed file's preview and skipped binary sections. fn special(&mut self, line: &'a [u8]) -> Option> { - if let Some((_, end)) = [PREVIEW, DOCUMENT].iter().find(|(b, _)| line.starts_with(b)) { + if line.starts_with(PREVIEW.0) { let from = self.pos; - let after = find(self.rest(), end).map_or(self.src.len(), |i| self.pos + i); + let after = find(self.rest(), PREVIEW.1).map_or(self.src.len(), |i| self.pos + i); self.pos = after; self.line(); - let tok = if *end == PREVIEW.1 { Tok::Preview(self.slice(from, after)) } else { Tok::Comment(line.get(1..).unwrap_or_default()) }; - return Some(Token { tok, hidden: false }); + return Some(Token { tok: Tok::Preview(self.slice(from, after)), hidden: false }); + } + if line.starts_with(DOCUMENT.0) { + return Some(Token { tok: Tok::Document(self.document()), hidden: false }); } let n = line.strip_prefix(b"%%BeginData:")?; let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?; @@ -221,6 +246,55 @@ impl<'a> Lexer<'a> { Some(Token { tok: Tok::Data(samples), hidden: false }) } + /// After a `%%BeginDocument:` line: the document up to the start of the `%%EndDocument` line + /// that ends it, the position moved past that line. A `%%BeginDocument:` line inside starts a + /// nested document, and the bytes a `%%BeginData:` or `%%BeginBinary:` line inside counts are + /// skipped (DSC 3.0 pp. 68 and 72): a marker in them ends nothing. `None` when no line ends it: + /// the position moves to the end of the data. + fn document(&mut self) -> Option<&'a [u8]> { + // The rest of the `%%BeginDocument:` line is its end; the line after the document starts + // tokens afresh (not hidden unless it starts with `%_`). + self.whole_line(); + self.hidden = false; + let from = self.pos; + let mut depth = 0usize; + while self.pos < self.src.len() { + let start = self.pos; + // A hidden object's document is on `%_` lines. + let line = self.whole_line(); + let line = line.strip_prefix(b"%_").unwrap_or(line); + if line.starts_with(DOCUMENT.1) { + if depth == 0 { + return Some(self.slice(from, start)); + } + depth -= 1; + } else if line.starts_with(DOCUMENT.0) { + depth = depth.saturating_add(1); + } else if let Some(n) = line.strip_prefix(b"%%BeginData:").or_else(|| line.strip_prefix(b"%%BeginBinary:")) { + self.skip_counted(n); + } + } + None + } + + /// Skip what a `%%BeginData:` or `%%BeginBinary:` comment counts (`n`, its text after the + /// colon): `count [type [Bytes|Lines]]`, bytes unless it says `Lines`. + fn skip_counted(&mut self, n: &[u8]) { + let mut words = std::str::from_utf8(n).unwrap_or_default().split_whitespace(); + let Some(count) = words.next().and_then(|c| c.parse::().ok()) else { return }; + if words.nth(1).is_some_and(|unit| unit.eq_ignore_ascii_case("lines")) { + for _ in 0..count { + if self.pos >= self.src.len() { + break; + } + self.whole_line(); + } + } else { + // The count starts after the comment's line end, which `document` has passed. + self.pos = self.pos.saturating_add(count).min(self.src.len()); + } + } + /// A regular token's characters. fn word(&mut self) -> &'a [u8] { let start = self.pos; @@ -367,11 +441,38 @@ mod tests { let toks = all(b"%AI26_BeginPlacedObjectPreview\r\n2 1 (\x00\xff\r\n%AI26_EndPlacedObjectPreview\r\nN\r\n"); assert_eq!(toks, vec![t(Tok::Preview(b"\r\n2 1 (\x00\xff\r\n"), false), t(Tok::Word(b"N"), false)]); let toks = all(b"%%BeginDocument: a.eps\n(\x00 frobnicate\n%%EndDocument\nN\n"); - assert_eq!(toks, vec![t(Tok::Comment(b"%BeginDocument: a.eps"), false), t(Tok::Word(b"N"), false)]); + assert_eq!(toks, vec![t(Tok::Document(Some(b"(\x00 frobnicate\n")), false), t(Tok::Word(b"N"), false)]); // One that doesn't end takes the rest. assert_eq!(all(b"%AI26_BeginPlacedObjectPreview\r1 (").len(), 1); } + #[test] + fn an_included_document_ends_at_its_own_end_line() { + // A nested document, `%%EndDocument` later in a line and in counted data end nothing; `\r\n`, + // `\r` and `\n` lines alike. + let body = b"1 0 0 setrgbcolor\r\n%%BeginDocument: inner.eps\r(%%EndDocument) pop\r%%EndDocument\r\n\ + %%BeginData: 14 Binary Bytes\n%%EndDocument\n%%BeginBinary: 14\n%%EndDocument\n\ + %%BeginData: 1 Hex Lines\n%%EndDocument\n"; + let mut src = b"%%BeginDocument: outer.eps\r\n".to_vec(); + src.extend_from_slice(body); + src.extend_from_slice(b"%%EndDocument\r\n~\r\n"); + assert_eq!(all(&src), vec![t(Tok::Document(Some(body)), false), t(Tok::Word(b"~"), false)]); + // On a hidden object's `%_` lines. + let toks = all(b"%_%%BeginDocument: a.eps\r%_1 0 0 setrgbcolor\r%_%%EndDocument\r%_~\r"); + assert_eq!(toks, vec![t(Tok::Document(Some(b"%_1 0 0 setrgbcolor\r")), true), t(Tok::Word(b"~"), true)]); + // The line after it is hidden only when it starts with `%_`. + let toks = all(b"%_%%BeginDocument: a.eps\r%_%%EndDocument\rN\r"); + assert_eq!(toks, vec![t(Tok::Document(Some(b"")), true), t(Tok::Word(b"N"), false)]); + } + + #[test] + fn an_included_document_that_doesnt_end_takes_the_rest() { + for src in [&b"%%BeginDocument: a.eps\n%%BeginDocument: b.eps\n%%EndDocument\n~\n"[..], b"%%BeginDocument: a.eps\n%%BeginData: 99999999999\n"] + { + assert_eq!(all(src), vec![t(Tok::Document(None), false)]); + } + } + #[test] fn skip_past_data() { let mut l = Lexer::new(b"/Binary : /ASCII85Decode ,\r\n%_(abc[%\r\n%x~>\r\n%_; 1"); diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 0b2438c7e..3937045db 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -186,7 +186,9 @@ impl MeshReader { "N" => { if let Some(V::Arr(a)) = value { if self.corners.len() < 4 { - let n: Vec = a.iter().filter_map(V::num).take(64).collect(); + // A corner holding anything but numbers is unread: skipping it would shift + // the numbers after it into the colour or the opacity. + let n: Vec = a.iter().take(64).map(V::num).collect::>().unwrap_or_default(); let comps = self.space.components(); let color = comps.and_then(|k| self.space.color(n.get(..k)?)); let rest = comps.and_then(|k| n.get(k..)).map(<[f64]>::to_vec).unwrap_or_default(); @@ -243,8 +245,9 @@ impl MeshReader { _ => return None, }; let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None }; - // Non-unit rational weights need a different surface representation. - if nw != 1.0 || pw != 1.0 { + // Weights are 1, or 0 on some corners, which draw as the same cubic edges (checked + // against the files' pages). Others would need a rational surface. + if ![nw, pw].iter().all(|w| *w == 1.0 || *w == 0.0) { return None; } if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) { @@ -629,6 +632,25 @@ mod tests { assert!((first.handles[H_DOWN] - Vec2::new(0.0, 1.0)).hypot() < 1e-9, "{:?}", first.handles); } + /// A corner's handle weights are 1, or 0 on some corners, which draw as the same patch; any + /// other weight leaves the patch unread. + #[test] + fn corners_with_handle_weights_of_zero_are_read() { + let read = |nw: f64, pw: f64| { + let mut reader = MeshReader::default(); + patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.3, nw, 0.3, 0.0, pw]); + reader + }; + for (nw, pw) in [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0)] { + let reader = read(nw, pw); + assert!(!reader.unread, "{nw} {pw}"); + let p = &reader.patches[0].points[0]; + assert_eq!((p.handles[H_DOWN], p.handles[H_RIGHT]), (Vec2::new(0.0, 0.3), Vec2::new(0.3, 0.0))); + } + let reader = read(0.5, 1.0); + assert!(reader.unread && reader.patches.is_empty()); + } + #[test] fn hostile_meshes_are_capped_and_checked() { let mut reader = MeshReader::default(); @@ -901,6 +923,29 @@ mod tests { } } + /// A corner holding anything but numbers is unread: skipping it would shift the numbers after + /// it into its colour and opacity (two inks' tints `0.25 /bad 0.5`, read as `0.25 0.5` and no + /// opacity). + #[test] + fn a_corner_with_something_other_than_numbers_is_unread() { + let ink = |c: [f64; 4], name: &str| { + let mut v: Vec = c.iter().map(|x| V::Num(*x)).collect(); + v.extend([V::Name("DeviceCMYK".into()), V::Str(name.as_bytes().to_vec()), V::Name("Separation".into())]); + V::Arr(v) + }; + let channels = + V::Arr(vec![ink([0.0, 0.0, 1.0, 0.0], "Yellow"), ink([0.0, 1.0, 0.0, 0.0], "Magenta"), V::Num(2.0), V::Name("NChannel".into())]); + let mut reader = MeshReader::default(); + reader.entry(&[channels, V::Name("CS".into())], Affine::IDENTITY); + for (x, y) in [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)] { + let mut corner = vec![V::Num(0.25), V::Name("bad".into()), V::Num(0.5)]; + corner.extend([x, y, x, y, 1.0, x, y, 1.0].map(V::Num)); + reader.entry(&[V::Arr(corner), V::Name("N".into())], Affine::IDENTITY); + } + reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY); + assert!(reader.unread && reader.patches.is_empty(), "{:?}", reader.patches); + } + /// Meshes as large as files users sent (97 × 72 patches) are read, as one mesh. #[test] fn a_large_grid_is_read_as_one_mesh() { diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index d91f153a8..10027f010 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -68,6 +68,8 @@ pub(super) struct Obj { pub content: Vec, /// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary. pub art: Option>, + /// What that art has that isn't read, reported once the mask is known to be enabled. + pub unread: Vec, } impl Obj { diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index 43955d5fc..9a4ba72eb 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -2,10 +2,13 @@ //! `%AI5_EndPalette`, as written in files users sent. //! //! - `0 0 Pb` starts the swatches and `PB` ends them. -//! - Each swatch is its paint, then `(name) Pc`. The paint is a colour operator (`g`, `k`, `Xa`: a -//! process colour; `x`, `Xx`: a spot colour; `Xk`: a global process colour), a gradient -//! (`Bb` … `flag (gradient) … Bg` … `BB`) or a pattern (`(pattern) … p`). `Xs` and `Xz` paint -//! with Registration, which every document has. +//! - Each swatch is its paint, then an optional `(name)`, then `Pc`. The paint is a colour operator +//! (`g`, `k`, `Xa`: a process colour; `x`, `Xx`: a spot colour; `Xk`: a global process colour), a +//! gradient (`Bb` … `flag (gradient) … Bg` … `BB`) or a pattern (`(pattern) … p`). `Xs` and `Xz` +//! paint with Registration, which every document has. +//! - A swatch without a name (version 7 files write none) is named by its spot colour, gradient or +//! pattern, or by its values as [`color_name`] gives them. `Pn` (the None swatch) and a `Pc` with +//! no paint before it add nothing: the document has `[None]` already. //! - `flag (name) count Pg` starts a colour group: the swatches up to the next `Pg` or `PB` are //! its own. //! - Newer files write the swatches between `%AI17_Begin_Content_if_version_gt` and @@ -13,9 +16,14 @@ //! the first are read, the others skipped. //! //! Anything else in the section (an operator it doesn't know) is left alone: the swatches around -//! it still come in. +//! it still come in, and a paint before it makes no swatch. +//! +//! "AI7 spec": Adobe Illustrator File Format Specification, version 7.0 (1998), listed by PRONOM at +//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §11 documents the palette operators, and its +//! example writes the swatches without names. use vectorcraft_color::Color; +use vectorcraft_doc::swatches::color_name; use super::lex::{Lexer, Tok}; use super::obj::V; @@ -55,9 +63,6 @@ impl Palette { pub fn is_empty(&self) -> bool { self.swatches.is_empty() && self.groups.iter().all(|(_, s)| s.is_empty()) } - fn len(&self) -> usize { - self.swatches.len() + self.groups.iter().map(|(_, s)| s.len()).sum::() - } } /// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end. @@ -65,8 +70,10 @@ pub(super) fn palette(data: &[u8]) -> Palette { let mut lex = Lexer::new(data); let mut out = Palette::default(); let mut vals: Vec = Vec::new(); - // The paint of the swatch being read. - let mut cell: Option = None; + // The paint of the swatch being read, with the name it gets when `Pc` gives none. + let mut cell: Option = None; + // How many swatches have been read. + let mut count = 0usize; // Inside a gradient's `Bb` … `BB`. let mut in_gradient = false; // How deep in versioned content, and the depth of the block whose later alternatives (which @@ -111,19 +118,20 @@ pub(super) fn palette(data: &[u8]) -> Palette { } "Bg" if in_gradient => { if let Some(name) = last_text(&vals) { - cell = Some(Cell::Gradient(name)); + cell = Some(Entry { cell: Cell::Gradient(name.clone()), name }); } } "BB" => in_gradient = false, - "p" => cell = first_text(&vals).map(Cell::Pattern), + "p" => cell = first_text(&vals).map(|name| Entry { cell: Cell::Pattern(name.clone()), name }), "Xs" | "Xz" | "XS" | "XZ" => cell = None, "g" | "k" | "x" | "Xa" | "Xx" | "Xk" => { cell = paint::color_op(w, &vals).and_then(|(p, named)| match named { - Some(Named { color, spot, .. }) => Some(Cell::Color { color, global: true, spot }), - None => p.color().map(|color| Cell::Color { color, global: false, spot: false }), + Some(Named { name, color, spot, .. }) => Some(Entry { name, cell: Cell::Color { color, global: true, spot } }), + None => p.color().map(|color| Entry { name: color_name(color), cell: Cell::Color { color, global: false, spot: false } }), }); } "Pg" => { + cell = None; // Capped as swatches are: a file of many `Pg` would make as many empty groups. if let Some(name) = first_text(&vals) && out.groups.len() < MAX_SWATCHES @@ -132,19 +140,27 @@ pub(super) fn palette(data: &[u8]) -> Palette { } } "Pc" => { - if let (Some(name), Some(c)) = (last_text(&vals), cell.take()) - && !name.starts_with('[') - && out.len() < MAX_SWATCHES + // The name before `Pc`, or the one the paint gives; `[Registration]` and the other + // built-in swatches every document has are left out. + let name = last_text(&vals); + if let Some(mut e) = cell.take() + && !name.as_ref().is_some_and(|n| n.starts_with('[')) + && count < MAX_SWATCHES { - let e = Entry { name, cell: c }; + if let Some(name) = name { + e.name = name; + } + count += 1; match out.groups.last_mut() { Some((_, g)) => g.push(e), None => out.swatches.push(e), } } - cell = None; } - _ => {} + // A gradient's other operators (its focal point, its matrices) are part of its paint. + _ if in_gradient => {} + // Anything else ends the paint before it, which no `Pc` then makes a swatch of. + _ => cell = None, } vals.clear(); } @@ -235,4 +251,71 @@ mod tests { let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n"); assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["Black"]); } + + /// An operator it doesn't know after a paint ends that paint: the `Pc` after it makes no swatch. + #[test] + fn a_paint_before_an_unknown_operator_makes_no_swatch() { + let p = palette(b"0 0 Pb\n0 1 1 0 k\nZz\n(Odd)\nPc\n0.5 g\n1 (Group) 1 Pg\nPc\n0 0 0 1 k\n(Black)\nPc\nPB\n"); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), Vec::<&str>::new()); + let groups: Vec<(&str, Vec<&str>)> = p.groups.iter().map(|(n, s)| (n.as_str(), s.iter().map(|e| e.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Group", vec!["Black"])]); + } + + /// The palette of the AI7 spec's example (§11): `Pn`, then each swatch's paint and `Pc` with no + /// name, then a last `Pc`. Each swatch is named by its spot colour or gradient, or by its values; + /// `1 g` is white (§5.4.1). + #[test] + fn swatches_without_names_are_named_by_their_paint() { + let p = palette( + b"0 0 Pb\nPn\nPc\n1 g\nPc\n0 g\nPc\n0 0 0 0 k\nPc\n0.75 g\nPc\n\ + Bb\n2 (B&W) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\nPc\n0.25 0 0 0 k\nPc\n\ + 0 1 1 0 (Spot A) 0 x\nPc\n0.75 g\nPc\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nPc\nPc\nPB\n", + ); + assert!(p.groups.is_empty()); + assert_eq!( + p.swatches, + vec![ + color("Gray K=0", Color::gray(0.0), false, false), + color("Gray K=100", Color::gray(1.0), false, false), + color("C=0 M=0 Y=0 K=0", Color::cmyk(0.0, 0.0, 0.0, 0.0), false, false), + color("Gray K=25", Color::gray(0.25), false, false), + Entry { name: "B&W".into(), cell: Cell::Gradient("B&W".into()) }, + color("C=25 M=0 Y=0 K=0", Color::cmyk(0.25, 0.0, 0.0, 0.0), false, false), + color("Spot A", Color::cmyk(0.0, 1.0, 1.0, 0.0), true, true), + color("Gray K=25", Color::gray(0.25), false, false), + Entry { name: "Dots".into(), cell: Cell::Pattern("Dots".into()) }, + ] + ); + // RGB and global process colours too. + let p = palette(b"0 0 Pb\n0 0 0 0 1 0.5 0 Xa\nPc\n0 0.5 1 0 1 0.5 0 (Brand) 0 1 Xk\nPc\nPB\n"); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["R=255 G=128 B=0", "Brand"]); + } + + /// Named and unnamed swatches in one palette: the named ones keep their names, and built-in + /// swatches are still left out. + #[test] + fn named_swatches_keep_their_names_beside_unnamed_ones() { + let p = palette( + b"0 0 Pb\n1 1 1 1 ([Registration]) 0 Xs\n([Registration])\nPc\n0 0 0 1 k\n(Black)\nPc\n0 0 0 0.5 k\nPc\n\ + 1 (Greys) 1 Pg\n0.4 g\n(Mid)\nPc\n0.4 g\nPc\nPB\n", + ); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["Black", "C=0 M=0 Y=0 K=50"]); + let groups: Vec<(&str, Vec<&str>)> = p.groups.iter().map(|(n, s)| (n.as_str(), s.iter().map(|e| e.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Greys", vec!["Mid", "Gray K=60"])]); + } + + /// No more than `MAX_SWATCHES` swatches, counted across the colour groups. + #[test] + fn swatches_are_capped_across_groups() { + let mut data = b"0 0 Pb\n".to_vec(); + for i in 0..MAX_SWATCHES / 1000 + 1 { + data.extend(format!("1 (G{i}) 1 Pg\n").as_bytes()); + data.extend("0.5 g\nPc\n".repeat(1000).as_bytes()); + } + data.extend(b"PB\n"); + let p = palette(&data); + assert_eq!(p.groups.len(), MAX_SWATCHES / 1000 + 1); + assert_eq!(p.swatches.len() + p.groups.iter().map(|(_, s)| s.len()).sum::(), MAX_SWATCHES); + assert!(p.groups.last().is_some_and(|(_, s)| s.is_empty())); + } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cc060f3f1..93cc0af6b 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1,20 +1,36 @@ //! The reader: runs the editing data's operators into a document. //! //! - Art space has y up; the first artboard's top left corner is the document's origin. -//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b … -//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour -//! index), `(name) Ln`, its art, `LB`. +//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b +//! template dimPercent … Lb` (ten operands in the legacy format, without the one before the +//! colour index; see `Reader::layer_attrs`), `(name) Ln`, its art, `LB`. +//! - Multi-layer masks `Mb` `W n` `Md` … `MB`, whose objects can go on past `LB` into the +//! next layers: a clipping group in each layer, read by [`masks`]. +//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole +//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard +//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths. //! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted -//! with `W` clips); a group's transparency and name follow its end. +//! with `W` clips); a group's transparency and name follow its end. A wraparound group `*w` … +//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap +//! area type, with no offset. //! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case -//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`. +//! closes), `(op) *` for a guide. A segment's operator in lower case makes the anchor it ends on +//! smooth, in upper case a corner; a closed path's first anchor takes the kind of the segment +//! back to it. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`. //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. -//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else -//! the art size around the template box's centre, else (a file in the legacy format another -//! app wrote) the bounding box. -//! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note -//! gives how many variables and data sets it has ([`VariableLibrary`]). +//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, with each one's display +//! options, else `%AI3_Cropmarks`, else the art size around the template box's centre +//! (`%AI3_TemplateBox`, `%%TemplateBox` in older files), else (a file in the legacy format +//! another app wrote) the bounding box. +//! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's +//! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of +//! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of. +//! - Variables: the variable library, XML nodes of the `/Document` dictionary: its variables, data +//! sets, active data set and lock, without bindings; a note gives how many entries were left out +//! ([`VariableRead`]). +//! - Objects on Path: a group whose objects' dictionaries carry ids, and a record of the +//! `/Document` dictionary naming them, read by [`on_path`]. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and //! an alpha channel that hides part of such an image becomes its opacity mask; other samples @@ -25,25 +41,45 @@ //! box is as large as the file has pixels; the matrix takes them into art space from the canvas //! (y down, its centre the template box's), where they start at the point of art space's origin, //! the first row the top one. Each is a linked image showing the preview until its file is found. +//! The version 7 form (§16) has no `%AI5_BeginPlace`: `[matrix] llx lly urx ury (path)` and +//! `` ` ``, then `%%IncludeFile:` or the file between `%%BeginDocument:` and `%%EndDocument`, +//! then `~`; its box is the file's `%%BoundingBox`. Newer files have three more numbers before +//! the path. Operands of any other shape, a second `` ` `` before `~` or an included document +//! that doesn't end leave the placed file unread. //! - Art with an appearance the format can't write as plain art (several fills or strokes, //! effects, brushes) comes first as that look, drawn (a group, or an image where an effect //! rasterizes it), then the object itself on `%_` //! lines and `1 (style) XW`: the drawn look is kept, named after the object. //! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's //! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is -//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one: +//! read by a reader of its own and becomes a pattern swatch. In the version 7 form (§3.4) a list +//! of layers follows the tile, `[ (colors) @ (tile) & … ] E`, each layer's tile read from its +//! string with the colors' state (`_ &`: the tile's box filled). A fill or stroke with one: //! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix -//! taking the pattern's space to art space (the other operands are what it is made of). +//! taking the pattern's space to art space (the other operands are what it is made of). With a +//! pattern defined in the version 7 form, the other operands are applied in order (§5.4.1) before +//! the initial matrix. //! //! - Type in the legacy format (version 7 specification §15): `type To` … `TO`, read by //! [`legacy_text`]. //! +//! - Graphs (version 7 specification §17): `%_Gs`, the graph's data and settings on `%_` lines, +//! its drawn art as ordinary groups, paths and type (each object followed by `%_… Go`), `%_GS`. +//! The drawn art is read; the data and settings are left out, with a note. +//! //! - Swatches: the palette (`%AI5_BeginPalette`), read by [`super::palette`], replaces the //! default swatches. //! +//! - Graphic styles: the style list (`%AI9_BeginArtStyleList`), one `(name)` per style in the +//! panel's order, each the name of an art style. The listed styles whose appearance reads +//! ([`appearance_from_art_style`](ops::appearance_from_art_style)) replace the default graphic +//! styles, and an object read as itself with a listed style (`1 (name) XW`) that has the +//! style's appearance and transparency is linked to it. A `1` naming a style the list doesn't +//! have is the object's own appearance, not a link. +//! //! Sections the reader doesn't need (brushes, symbols, styles, filters) are skipped; //! type, symbols, fills with a pattern it can't read, placed files without a preview and operators -//! it doesn't know are errors. +//! it doesn't know are errors. So is data that ends inside a layer, a group or a compound path. use std::collections::{BTreeSet, HashMap, HashSet}; use std::rc::Rc; @@ -58,9 +94,10 @@ use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, LinkInfo, Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer, Unit, }; -use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_geom::{AnchorKind, FillRule, PathData}; use super::super::ate::CANVAS_CENTRE; +use super::super::graphics::HAIRLINE; use super::lex::{Lexer, Tok}; use super::obj::{self, Obj, V}; use super::paint::{self, GradientDef, Instance, Named}; @@ -70,16 +107,19 @@ mod art; mod containers; mod directives; mod document; +mod masks; +mod on_path; mod ops; mod types; +pub(crate) use art::join_and; use art::preview_png; -pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables}; +pub use document::{PaletteSwatches, VariableRead, artboards, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; -use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; +use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster}; #[cfg(test)] -pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; +pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] @@ -101,6 +141,10 @@ pub struct Structure { pub template: Option<[f64; 4]>, /// What layers that don't show have that isn't read (operators, objects): left out of them. pub hidden_unread: BTreeSet, + /// Top-level object ids and the unread kinds within them. + pub unread: Vec<(NodeId, Vec)>, + /// Other bounds for previewless placed files whose page art uses the file box's corner. + pub unread_elsewhere: Vec<(NodeId, Rect)>, /// The names of text objects, by their slot's id. pub slot_names: HashMap, /// Where the drawn looks lay that objects read with their art style took the place of (the @@ -110,6 +154,11 @@ pub struct Structure { /// move, in its pixels, that places them there (the corner of the box they are written with, /// in place of the canvas point of art space's origin). pub placed_elsewhere: Vec<(NodeId, Affine)>, + /// The live envelopes read from the editing data, by id, with the drawn art each took the place + /// of: the importer puts that art back when the layers with the envelopes don't draw as the page. + pub envelopes_drawn: Vec<(NodeId, Node)>, + /// The document's graphic styles are those of the file's style list, in place of the defaults. + pub styles_read: bool, pub warnings: Vec, } @@ -123,6 +172,17 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result { r.finish() } +/// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private +/// data): the program's header gives the author and the custom colors the editing data's header +/// doesn't. +pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result { + let mut r = Reader::new(data); + r.large_canvas = large_canvas.then_some(1.0); + r.run()?; + r.program_header(ps); + r.finish() +} + struct Reader<'a> { lex: Lexer<'a>, stack: Vec, @@ -131,15 +191,28 @@ struct Reader<'a> { gs: GState, frames: Vec, path: BezPath, + /// The kind of the anchor each segment of `path` ends on, in order: smooth for a lower-case + /// segment operator, corner for an upper-case one (version 7 specification §5.3.2). + path_kinds: Vec, points: usize, /// The path is a clipping path (`W`). clip_next: bool, doc: Document, to_doc: Option, cropmarks: Option, - artboards: Vec<(String, Rect)>, - /// The variables and data sets of the `/Document` dictionary's variable library. - variables: VariableLibrary, + artboards: Vec, + /// The user the header's `%%For` names. + author: Option, + /// The custom colors the header's `%%CMYKCustomColor` and `%%RGBCustomColor` define. + custom_colors: Vec, + /// The last header comment defined custom colors (CMYK when true), which a `%%+` line continues. + custom_more: Option, + /// The header ended (`%%EndComments`). + header_done: bool, + /// The `/Document` dictionary's variable library. + variables: VariableRead, + /// The Objects on Path records of the `/Document` dictionary and the ids of the objects. + on_path: on_path::OnPath, gradients: HashMap, gradient_def: Option, instance: Option, @@ -162,6 +235,12 @@ struct Reader<'a> { unknown: BTreeSet, /// What layers that don't show have that this reader doesn't read. hidden_unread: BTreeSet, + unread: Vec<(NodeId, Vec)>, + symbol_bounds: HashMap, + /// Depth shared by pattern and symbol readers. + subreader_depth: usize, + pending_unread_fill: bool, + pending_unread_stroke: bool, /// The header's bounding boxes and template box. bbox: Option<[f64; 4]>, hires: Option<[f64; 4]>, @@ -181,14 +260,34 @@ struct Reader<'a> { blend_record: Option<(usize, Vec)>, /// How many more objects blends may lay out in this import ([`super::blend::live_blend`]). blend_budget: usize, + /// The group on `%_` lines an envelope's dictionary just took (see [`Reader::drop_envelope_art`]). + envelope_art: Option, + /// Envelopes waiting for their drawn art, innermost last. + envelopes: Vec, + /// How many more envelopes this import may draw to check ([`super::envelope::drawn_alike`]). + envelope_checks: usize, + /// How far the live envelopes read so far draw from their drawn art ([`super::envelope::Spent`]). + envelope_spent: super::envelope::Spent, + /// The live envelopes made so far and the drawn art each took the place of + /// ([`Structure::envelopes_drawn`]). + envelopes_drawn: Vec<(NodeId, Node)>, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, + /// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read: also after its + /// dictionary (its name, its opacity mask and the mask's art). + mesh_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. layer_state: Option<(f32, BlendMode, bool, Knockout)>, + /// The state a mesh or blend had before an `Xy` right after its end, which is the layer's + /// instead when the layer's `LB` follows rather than their own `6 () XW`. + mesh_undo: Option<(f32, BlendMode, bool, Knockout)>, /// Painted copies of clipping paths that have a fill (drawn under what they clip). clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). looks: HashSet, + /// Straight `(N) *` guide paths painted directly on a layer, by id: their two points in art + /// space. [`Reader::finish`] makes the ruler guides among them guides of their layer. + ruler_guides: HashMap, /// Legacy compound member groups, even when followed by the generic `9 () XW` marker. compound_groups: HashSet, /// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look. @@ -197,12 +296,18 @@ struct Reader<'a> { mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. styles: HashMap, + /// The style list (`%AI9_BeginArtStyleList`): the Graphic Styles panel's names, in its order. + style_list: Vec>, + /// The objects read as themselves with the style their `1 (style) XW` names: their id and the + /// style's name, as written. + style_links: Vec<(NodeId, String)>, /// The swatches (`%AI5_BeginPalette`). palette: super::palette::Palette, /// See [`Structure::restyled`]. restyled: Vec, /// See [`Structure::placed_elsewhere`]. placed_elsewhere: Vec<(NodeId, Affine)>, + unread_elsewhere: Vec<(NodeId, Rect)>, /// CMYK samples converted, by value. cmyk_cache: HashMap<[u8; 4], [u8; 3]>, /// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes. @@ -226,9 +331,16 @@ struct Reader<'a> { pattern: Option, /// The tile (y down) of each pattern read, by name. pattern_tiles: HashMap, + /// The patterns read from a definition in the version 7 form, whose fills apply their other + /// operands before the matrix (see [`Reader::pattern_paint`]). + v7_patterns: HashSet, /// The legacy text object being read (`To` … `TO`), and the modal type attributes. text: Option, type_state: legacy_text::TypeState, + /// The graph being read (see [`Reader::graph_record`]). + graph: Option, + /// The multi-layer mask being read (`Mb` … `MB`), which goes on past a layer's end. + multi_mask: Option, } /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → @@ -249,13 +361,19 @@ impl<'a> Reader<'a> { gs: GState::default(), frames: Vec::new(), path: BezPath::new(), + path_kinds: Vec::new(), points: 0, clip_next: false, doc, to_doc: None, cropmarks: None, artboards: Vec::new(), - variables: VariableLibrary::default(), + author: None, + custom_colors: Vec::new(), + custom_more: None, + header_done: false, + variables: VariableRead::default(), + on_path: on_path::OnPath::default(), gradients: HashMap::new(), gradient_def: None, instance: None, @@ -271,6 +389,11 @@ impl<'a> Reader<'a> { unsupported: BTreeSet::new(), unknown: BTreeSet::new(), hidden_unread: BTreeSet::new(), + unread: Vec::new(), + symbol_bounds: HashMap::new(), + subreader_depth: 0, + pending_unread_fill: false, + pending_unread_stroke: false, bbox: None, hires: None, template: None, @@ -283,16 +406,27 @@ impl<'a> Reader<'a> { blend_roles: HashMap::new(), blend_record: None, blend_budget: BLEND_BUDGET, + envelope_art: None, + envelopes: Vec::new(), + envelope_checks: super::envelope::MAX_CHECKED, + envelope_spent: super::envelope::Spent::default(), + envelopes_drawn: Vec::new(), clip_fills: HashSet::new(), looks: HashSet::new(), + ruler_guides: HashMap::new(), compound_groups: HashSet::new(), text_looks: HashSet::new(), group_ended: false, + mesh_ended: false, layer_state: None, + mesh_undo: None, styles: HashMap::new(), + style_list: vec![], + style_links: vec![], palette: super::palette::Palette::default(), restyled: vec![], placed_elsewhere: vec![], + unread_elsewhere: vec![], mesh: None, cmyk_cache: HashMap::new(), done: false, @@ -304,8 +438,11 @@ impl<'a> Reader<'a> { ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), + v7_patterns: HashSet::new(), text: None, type_state: legacy_text::TypeState::default(), + graph: None, + multi_mask: None, } } @@ -324,18 +461,38 @@ impl<'a> Reader<'a> { let w = std::str::from_utf8(w).unwrap_or("?"); self.op(w, hidden)?; } - Tok::Comment(c) => self.comment(c)?, + Tok::Comment(c) => self.comment(c, hidden)?, Tok::Data(d) => self.image(d)?, Tok::Preview(d) => { if let Some(p) = self.place.as_mut() { p.preview = preview_png(d); } } + // A placed file's document, included whole: its art isn't read here. When no + // `%%EndDocument` ends it, the placed file isn't read. + Tok::Document(d) => { + if let Some(p) = self.place.as_mut() { + p.broken |= d.is_none(); + } + } } } + // A pattern's or symbol's reader opens a group of its own and checks it is the only frame + // left. In the document, a container still open after the last layer holds art no layer has. + let in_layer = self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))); + if self.subreader_depth == 0 && !in_layer && self.frames.iter().any(|f| !matches!(f.kind, Kind::Obj(_) | Kind::Mask)) { + return Err("it ends inside a group or compound path".into()); + } if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))) { return Err("it ends inside a layer".into()); } + if self.multi_mask.is_some() { + return Err("it ends inside a multi-layer mask".into()); + } + // A placed file that no `~` ends isn't read. + if let Some(open) = self.place.take() { + self.unreadable("placed files", open.hidden); + } Ok(()) } } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 6bf699ce9..806cd270f 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -1,18 +1,19 @@ use super::containers::close; use super::document::TEXT_SLOT; use super::types::{ - FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place, + FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, + NON_NATIVE_ART_UNREAD, Place, }; use super::{ - Affine, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, NodeId, - NodeKind, Obj, OpacityMask, Paint, PathData, Point, Reader, Rect, StrokeLayer, V, ps, + Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, + NodeId, NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps, }; impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. pub(super) fn text_slot(&mut self, o: &Obj) -> Result<(), String> { if self.pattern.is_some() { - self.unreadable("type in a pattern"); + self.unreadable("type in a pattern", false); } // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its @@ -110,32 +111,45 @@ impl<'a> Reader<'a> { return Err("a path has too many points".into()); } let current = self.path.elements().last().and_then(|e| e.end_point()); - match w { + let kind = if w.starts_with(|c: char| c.is_ascii_lowercase()) { AnchorKind::Smooth } else { AnchorKind::Corner }; + let added = match w { "m" => { let Some(a) = p(2) else { return Ok(()) }; self.path.move_to(a); + return Ok(()); } - _ if current.is_none() => {} - "l" | "L" => { - if let Some(a) = p(2) { + _ if current.is_none() => false, + "l" | "L" => match p(2) { + Some(a) => { self.path.line_to(a); + true } - } - "c" | "C" => { - if let (Some(a), Some(b), Some(c)) = (p(6), p(4), p(2)) { + None => false, + }, + "c" | "C" => match (p(6), p(4), p(2)) { + (Some(a), Some(b), Some(c)) => { self.path.curve_to(a, b, c); + true } - } - "v" | "V" => { - if let (Some(cur), Some(b), Some(c)) = (current, p(4), p(2)) { + _ => false, + }, + "v" | "V" => match (current, p(4), p(2)) { + (Some(cur), Some(b), Some(c)) => { self.path.curve_to(cur, b, c); + true } - } - _ => { - if let (Some(a), Some(c)) = (p(4), p(2)) { + _ => false, + }, + _ => match (p(4), p(2)) { + (Some(a), Some(c)) => { self.path.curve_to(a, c, c); + true } - } + _ => false, + }, + }; + if added { + self.path_kinds.push(kind); } Ok(()) } @@ -143,6 +157,7 @@ impl<'a> Reader<'a> { /// A path left unpainted before a container opens. pub(crate) fn end_path_quietly(&mut self) { self.path = BezPath::new(); + self.path_kinds.clear(); self.points = 0; self.clip_next = false; } @@ -150,6 +165,7 @@ impl<'a> Reader<'a> { /// Paint the current path (`op`), as a guide when `guide`. pub(super) fn paint(&mut self, op: &str, hidden: bool, guide: bool) -> Result<(), String> { let mut bp = std::mem::take(&mut self.path); + let kinds = std::mem::take(&mut self.path_kinds); self.points = 0; let clip = std::mem::take(&mut self.clip_next); if let Some(i) = self.instance.take() { @@ -162,8 +178,12 @@ impl<'a> Reader<'a> { if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { close(&mut bp); } + let line = match bp.elements() { + [PathEl::MoveTo(a), PathEl::LineTo(b)] => Some((*a, *b)), + _ => None, + }; let to_doc = self.space(); - let path = PathData::from_bezpath(&(to_doc * bp)); + let path = path_data(&(to_doc * bp), &kinds); let gs = self.gs.clone(); let (fill, stroke) = match op.to_ascii_lowercase().as_str() { "f" => (true, false), @@ -171,6 +191,8 @@ impl<'a> Reader<'a> { "b" => (true, true), _ => (false, false), }; + // A gradient instance's paint replaces the missing pattern. + let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none()); let mut items = Vec::new(); if fill && !clip { items.push(AppearanceItem::Fill(FillLayer { @@ -178,8 +200,9 @@ impl<'a> Reader<'a> { ..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone())) })); } - if stroke && !clip { - let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width); + // A clipping path keeps its stroke, which paints over the art it clips. + if stroke { + let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.line_width()); st.cap = gs.cap; st.join = gs.join; st.miter_limit = gs.miter; @@ -189,6 +212,19 @@ impl<'a> Reader<'a> { } let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide }; let mut node = self.new_node(kind, &gs); + // A ruler guide is written as a straight `(N) *` guide path on its layer. + if guide + && op == "N" + && !hidden + && !gs.hidden + && !clip + && self.pattern.is_none() + && matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })) + && let Some((a, b)) = line + && [a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) + { + self.ruler_guides.insert(node.id, (a, b)); + } node.appearance = Appearance { items, ..Appearance::default() }.into(); node.opacity = gs.opacity; node.blend = gs.blend; @@ -212,7 +248,33 @@ impl<'a> Reader<'a> { .into(); copy }); + // The note and Show Center are the path's, not its painted copy's. + node.edit_attrs(|a| { + a.note = gs.note.clone().unwrap_or_default(); + a.show_center = gs.show_center; + }); + let unread_bounds = unread.then(|| node.visual_bounds()).flatten(); + let unread_id = node.id; self.add(node, hidden)?; + if unread && self.pattern.is_some() { + // Art in a pattern's definition painted with a pattern it doesn't have: the tile can't be read. + self.unsupported.insert("pattern fills".into()); + } else if unread && ((hidden && !self.in_mask()) || gs.hidden || !self.shown()) { + self.hidden_unread.insert("pattern fills".into()); + } else if unread && self.in_mask() { + self.unreadable("pattern fills", false); + } else if unread { + match unread_bounds { + Some(b) if sane(b) && b.area() > 0.0 => { + if !self.mark_unread("pattern fills".into()) { + self.unread.push((unread_id, vec!["pattern fills".into()])); + } + } + _ => { + self.unsupported.insert("pattern fills".into()); + } + } + } if let Some(copy) = copy { self.clip_fills.insert(copy.id); self.add(copy, hidden)?; @@ -224,7 +286,7 @@ impl<'a> Reader<'a> { pub(super) fn end_mesh(&mut self) -> Result<(), String> { let Some((m, hidden)) = self.mesh.take() else { return Ok(()) }; if m.unread || m.patches.is_empty() { - self.unreadable("gradient meshes it can't read"); + self.unreadable("gradient meshes it can't read", hidden && self.pattern.is_none()); return Ok(()); } let gs = self.gs.clone(); @@ -235,7 +297,9 @@ impl<'a> Reader<'a> { let mut node = self.new_node(NodeKind::Mesh(one), &gs); node.opacity = gs.opacity; node.blend = gs.blend; - return self.add(node, hidden); + self.add(node, hidden)?; + self.mesh_ended = true; + return Ok(()); } Err(crate::import::ai::mesh::NotOne::Unjoined) => { self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches") @@ -249,17 +313,28 @@ impl<'a> Reader<'a> { }; node.opacity = gs.opacity; node.blend = gs.blend; - self.add(node, hidden) + self.add(node, hidden)?; + self.mesh_ended = true; + Ok(()) } - /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. + /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. A clipping path + /// takes the stroke itself, not the painted copy of its fill. pub(super) fn stroke_last(&mut self) { let gs = self.gs.clone(); - let Some(n) = self.last_object() else { return }; - if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { + if !self.after_object { return; } - let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width); + let Some(kids) = self.frames.last_mut().map(|f| &mut f.children) else { return }; + let Some(mut at) = kids.len().checked_sub(1) else { return }; + if kids.get(at).is_some_and(|n| self.clip_fills.contains(&n.id)) && at > 0 { + at -= 1; + } + let Some(n) = kids.get_mut(at) else { return }; + if !matches!(n.kind, NodeKind::Path { .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { + return; + } + let mut st = StrokeLayer::new(gs.stroke.clone(), gs.line_width()); st.cap = gs.cap; st.join = gs.join; st.miter_limit = gs.miter; @@ -457,10 +532,45 @@ impl<'a> Reader<'a> { /// shows the preview the data keeps of it, as a linked image whose file is missing does. One /// without a preview, a path or a matrix isn't read. pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> { - let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty()) - else { - self.unreadable("placed files"); - return Ok(()); + let (m, b, path, png, w, h, folder) = match self.place.take() { + Some(Place { broken: true, .. }) => { + self.unreadable("placed files", hidden); + return Ok(()); + } + Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)), folder, .. }) if !path.is_empty() => { + (m, b, path, png, w, h, folder) + } + other => { + let boxed = other.as_ref().and_then(|p| Some((p.matrix?, p.bounds?))).filter(|(_, b)| sane(*b) && b.area() > 0.0); + let mut have_bounds = false; + if self.shown() + && !self.gs.hidden + && !hidden + && let Some((m, b)) = boxed + { + let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y)); + let box_at_canvas = Rect::new(origin.x, origin.y, origin.x + b.width(), origin.y + b.height()); + let bounds = (self.space() * m).transform_rect_bbox(box_at_canvas); + have_bounds = sane(bounds) && bounds.area() > 0.0; + if have_bounds { + let placeholder_id = self.unread_box("placed files", bounds, hidden)?; + if let Some(placeholder_id) = placeholder_id.filter(|_| self.unread_elsewhere.len() < MAX_PLACED_ELSEWHERE) { + let corner = (self.space() * m).transform_rect_bbox(b); + if sane(corner) && corner.area() > 0.0 && corner != bounds { + self.unread_elsewhere.push((placeholder_id, corner)); + } + } + return Ok(()); + } + } + if self.shown() && !self.gs.hidden && !hidden && !have_bounds { + self.unsupported.insert("placed files".into()); + } else { + self.unreadable("placed files", hidden); + } + return Ok(()); + } }; // The file's pixels (y down) start at the canvas point of art space's origin (the box's own // corner when the file doesn't say where the canvas is); the preview's are spread over them. @@ -477,7 +587,7 @@ impl<'a> Reader<'a> { .filter(|a| a.as_coeffs().iter().all(|v| v.is_finite())); let xf = self.space() * m * over_file; if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.unreadable("placed files"); + self.unreadable("placed files", hidden); return Ok(()); } // The preview is all the document has of the file: what a missing link shows. @@ -491,10 +601,9 @@ impl<'a> Reader<'a> { { self.placed_elsewhere.push((NodeId(self.doc.peek_next_id()), shift)); } - self.add_image( - ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, - hidden, - ) + let mut link = LinkInfo::new(path); + link.relative = folder.and_then(|f| relative_path(&f, link.name())); + self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(link), placement: Default::default(), pixels: None }, hidden) } pub(super) fn warn(&mut self, w: &str) { @@ -504,17 +613,66 @@ impl<'a> Reader<'a> { } } +/// `bp` as a path whose anchors take the kinds in `kinds`, one for each segment of `bp` in order, +/// for the anchor the segment ends on. A closed subpath's last segment ends on its first anchor +/// when it returns to the start point, and that anchor takes its kind. Anchors no segment ends on +/// (a subpath's start) keep the kind [`PathData::from_bezpath`] gives them, and so does a subpath +/// whose anchors don't match its segments. +pub(super) fn path_data(bp: &BezPath, kinds: &[AnchorKind]) -> PathData { + let mut path = PathData::from_bezpath(bp); + // Each subpath's anchors as `from_bezpath` starts them: one for its start, one for each segment. + let mut subpaths: Vec>> = Vec::new(); + let mut kinds = kinds.iter().copied(); + let mut open = false; + for el in bp.elements() { + match el { + PathEl::MoveTo(_) => { + subpaths.push(vec![None]); + open = true; + } + PathEl::ClosePath => open = false, + _ => { + let kind = kinds.next(); + match subpaths.last_mut() { + Some(s) if open => s.push(kind), + _ => { + subpaths.push(vec![None, kind]); + open = true; + } + } + } + } + } + if subpaths.len() != path.subpaths.len() { + return path; + } + for (sp, mut set) in path.subpaths.iter_mut().zip(subpaths) { + // The closing anchor merged into the first: its kind goes to the first. + if sp.closed + && set.len() == sp.anchors.len() + 1 + && let Some(last) = set.pop() + && let Some(first) = set.first_mut() + { + *first = last; + } + if set.len() != sp.anchors.len() { + continue; + } + for (a, kind) in sp.anchors.iter_mut().zip(set) { + if let Some(kind) = kind { + a.kind = kind; + } + } + } + path +} + /// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`). pub(super) fn element_name(o: &Obj) -> Option { let name = o.text("xmlnode-nodename")?; Some(name.rsplit(':').next().unwrap_or(&name).to_string()) } -/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. -pub(super) fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator { - o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name)) -} - /// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by /// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is /// larger than [`MAX_PIXELS`] or is cut short. @@ -549,6 +707,18 @@ pub(super) fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> { Some((png, width, height)) } +/// A placed file's path relative to the document's folder (`/` separators): its relative `folder` +/// (`src/`, `..\art`) followed by its file `name`. `None` when the folder is empty, absolute, or +/// holds control characters, or when there is no name. +pub(super) fn relative_path(folder: &str, name: &str) -> Option { + let folder = folder.replace('\\', "/"); + let absolute = folder.starts_with('/') || folder.as_bytes().get(1) == Some(&b':') || folder.contains("://"); + if folder.is_empty() || absolute || name.is_empty() || folder.chars().chain(name.chars()).any(char::is_control) { + return None; + } + Some(format!("{}/{name}", folder.trim_end_matches('/'))) +} + pub(super) fn sane(r: Rect) -> bool { [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) } @@ -577,7 +747,7 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { } /// `a`, `a and b`, `a, b and c`. -pub(super) fn join_and(items: &[&str]) -> String { +pub(crate) fn join_and(items: &[&str]) -> String { match items { [] => String::new(), [one] => (*one).to_string(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 09ccd14aa..173e22ed0 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -1,7 +1,8 @@ use super::document::slot_of; use super::ops::{paints_apart, path_rule}; -use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES}; +use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope}; use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj}; +use vectorcraft_doc::TextWrap; impl<'a> Reader<'a> { // ---- frames ---------------------------------------------------------------------------- @@ -19,10 +20,15 @@ impl<'a> Reader<'a> { children: Vec::new(), hidden, after: self.after_object, + mesh_ended: self.mesh_ended, gs: self.gs.clone(), + pending_unread_fill: self.pending_unread_fill, + pending_unread_stroke: self.pending_unread_stroke, base, clips: Vec::new(), compound_members: false, + unread: Vec::new(), + state: None, }); self.after_object = false; Ok(()) @@ -69,15 +75,43 @@ impl<'a> Reader<'a> { } pub(super) fn end_container(&mut self, what: &str, hidden: bool) -> Result<(), String> { + self.multi_mask_container_ends()?; let f = match what { "U" => self.close(what, |k| matches!(k, Kind::Group))?, "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, + "*W" => self.close(what, |k| matches!(k, Kind::Wrap))?, _ => self.close(what, |k| matches!(k, Kind::Clip))?, }; - let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; + let Frame { + kind, + children: mut kids, + gs, + clips, + hidden: opened_hidden, + compound_members, + unread, + pending_unread_fill, + pending_unread_stroke, + .. + } = f; + if matches!(kind, Kind::Clip) { + self.gs = gs.clone(); + self.pending_unread_fill = pending_unread_fill; + self.pending_unread_stroke = pending_unread_stroke; + } + // Wraparound text: every object in it other than type is a wrap object, before the text or + // after it, with no offset (the format gives none) unless its dictionary gives its options. + // One before the text is below it, so the text flows around it only once it is moved above. + if matches!(kind, Kind::Wrap) { + let graphics = kids.iter_mut().filter(|k| !matches!(k.kind, NodeKind::Text(_)) && slot_of(k.name.as_deref()).is_none()); + for k in graphics.filter(|k| k.extras().wrap.is_none()) { + k.extras_mut().wrap = Some(TextWrap { offset: 0.0, invert: false }); + } + } // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. if matches!(kind, Kind::Group) + && unread.is_empty() && !clips.is_empty() && clips.len() == kids.len() && matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. })) @@ -107,7 +141,14 @@ impl<'a> Reader<'a> { // painted copies make a compound of their own, under what it clips. let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); painted = self.compound(fills, &gs); - let Some(n) = self.compound(kids, &gs) else { return Ok(()) }; + let Some(n) = self.compound(kids, &gs) else { + if gs.hidden || !self.shown() { + self.hidden_unread.extend(unread); + } else { + self.unsupported.extend(unread); + } + return Ok(()); + }; n } // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. @@ -122,6 +163,13 @@ impl<'a> Reader<'a> { if compound_members { self.compound_groups.insert(node.id); } + if !unread.is_empty() { + if !node.visible || !self.shown() { + self.hidden_unread.extend(unread); + } else { + self.unread.push((node.id, unread)); + } + } self.add(node, opened_hidden || hidden)?; if let Some(p) = painted { self.clip_fills.insert(p.id); @@ -159,8 +207,19 @@ impl<'a> Reader<'a> { 1 => clip.pop().map(|(_, n)| n), _ => { let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n)); + // The compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same + // ones: it has one appearance for all its paths. + let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend); + let shared = + clip.split_first().and_then(|((_, first), others)| others.iter().all(|(_, n)| look(n) == look(first)).then(|| look(first))); let children = clip.into_iter().map(|(_, n)| Arc::new(n)).collect(); - Some(Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })) + let mut n = Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule }); + if let Some((appearance, opacity, blend)) = shared { + n.appearance = appearance; + n.opacity = opacity; + n.blend = blend; + } + Some(n) } }; let clips = clip_node.is_some(); @@ -171,19 +230,28 @@ impl<'a> Reader<'a> { } pub(super) fn end_layer(&mut self) -> Result<(), String> { - let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + self.pending_unread_fill = false; + self.pending_unread_stroke = false; + let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let state = self.layer_state.take(); + // An `Xy` right after the layer's last mesh or blend, then `LB`: the layer's, not theirs. + if let (Some(_), Some(before)) = (state, self.mesh_undo.take()) + && let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_) | NodeKind::Blend { .. })) + { + (mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout) = before; + } let Kind::Layer(a) = f.kind else { return Ok(()) }; let (children, clip) = self.clip_first(f.children, &f.clips); let color = a.color.unwrap_or(LayerColor::Preset(0)); let mut layer = Node::new( self.doc.alloc_id(), - NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, + NodeKind::Layer { color, template: a.template, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, ); layer.name = Some(a.name.clone()); layer.visible = a.visible; layer.locked = a.locked; // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { + if let Some((opacity, blend, isolate, knockout)) = state { (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); } match self.frames.last_mut() { @@ -199,15 +267,16 @@ impl<'a> Reader<'a> { Ok(()) } - /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file - /// is read from its PDF part. - pub(super) fn note_unknown(&mut self, w: &str) { + /// An operator the reader doesn't know is scoped to its top-level object when possible. + pub(super) fn note_unknown(&mut self, w: &str, hidden: bool) { let op: String = w.chars().take(24).collect(); - if !self.shown() { + // A pattern definition or an owning mask can be on `%_` lines while still + // determining visible art. Unknown operators there must remain unread. + if (self.gs.hidden && self.pattern.is_none()) || (hidden && self.pattern.is_none() && !self.in_mask()) || !self.shown() { if self.hidden_unread.len() < 16 { self.hidden_unread.insert(format!("`{op}`")); } - } else if self.unknown.len() < 16 { + } else if !self.defer_to_mask(&format!("`{op}`")) && !self.mark_unread(format!("`{op}`")) && self.unknown.len() < 16 { self.unknown.insert(op); } } @@ -225,6 +294,8 @@ impl<'a> Reader<'a> { self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?; // A dictionary's state starts afresh (styles set theirs inside). self.gs = GState::default(); + self.pending_unread_fill = false; + self.pending_unread_stroke = false; Ok(()) } @@ -235,13 +306,17 @@ impl<'a> Reader<'a> { let (children, clip) = self.clip_first(f.children, &f.clips); let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); self.gs = f.gs; + self.pending_unread_fill = f.pending_unread_fill; + self.pending_unread_stroke = f.pending_unread_stroke; self.after_object = f.after; + self.mesh_ended = f.mesh_ended; let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { return Err("mask art ends outside a mask dictionary".into()); }; if o.art.replace(Arc::new(art)).is_some() { return Err("a mask has more than one art block".into()); } + o.unread = f.unread; Ok(()) } @@ -251,13 +326,20 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, .. } = f; + let Frame { kind, children, base, hidden, gs, pending_unread_fill, pending_unread_stroke, after, mesh_ended, state, .. } = f; let Kind::Obj(mut o) = kind else { return Ok(()) }; obj::close(&mut o, &mut self.stack, base); self.obj_hidden = hidden; - // Art made inside a dictionary (type's paths) isn't kept. + // Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what + // follows the object the dictionary is about: a mesh's state comes after its mask. self.gs = gs; + self.pending_unread_fill = pending_unread_fill; + self.pending_unread_stroke = pending_unread_stroke; + if let Some(state) = state { + self.keep_state(state); + } self.after_object = after; + self.mesh_ended = mesh_ended; if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { return self.push(V::Obj(Rc::new(*o))); } @@ -265,22 +347,35 @@ impl<'a> Reader<'a> { "ArtDictionary" => { let envelope = o.get("AIDeformOptionsKey").is_some(); self.art_dictionary(&o); + let before = self.envelope_art.take(); if envelope { let mut slots = vec![]; for child in &children { envelope_text_slots(child, &mut slots); } + let typed = !slots.is_empty() || before.as_ref().is_some_and(has_text_slot); for mut slot in slots { // The editing data keeps the source text inside the envelope's options; // its visible result is the outlined art the PDF page carries. slot.visible = false; self.add(slot, false)?; } + // An envelope of type keeps its drawn art and its type, hidden, as above: the + // type is filled in after reading, which the envelope can't wait for. + if typed { + return Ok(()); + } + if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING { + self.warn(crate::import::ai::envelope::DRAWN); + } else { + let siblings = self.frames.last().map_or(0, |f| f.children.len()); + self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth: self.frames.len(), siblings }); + } } } "Document" => self.document(&o), "AI11Text" => self.text_slot(&o)?, - "SymbolInstance" => self.unreadable("symbols"), + "SymbolInstance" => self.symbol_instance(&o)?, "ForeignObject" => self.foreign_art(&o)?, _ => {} } @@ -315,31 +410,115 @@ impl<'a> Reader<'a> { /// Note something the data has that isn't read: an error on a layer that shows, left out of a /// layer that doesn't. - pub(super) fn unreadable(&mut self, what: &str) { - if self.shown() { - self.unsupported.insert(what.into()); + pub(super) fn unreadable(&mut self, what: &str, hidden: bool) { + if (!hidden || self.in_mask()) && !self.gs.hidden && self.shown() { + if !self.defer_to_mask(what) && !self.mark_unread(what.to_string()) { + self.unsupported.insert(what.into()); + } } else { self.hidden_unread.insert(what.into()); } } - /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding - /// only gradient meshes. Whether it was dropped. - pub(super) fn drop_envelope_mesh(&mut self) -> bool { - fn only_meshes(n: &Node) -> bool { - match &n.kind { - NodeKind::Mesh(_) => true, - NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), - _ => false, + /// Keep what an opacity mask's art doesn't read with the mask, until its dictionary says + /// whether the mask is enabled. Whether art of a mask is being read. Operators it doesn't know + /// are kept up to 16, as [`Reader::note_unknown`] keeps them. + fn defer_to_mask(&mut self, what: &str) -> bool { + let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Mask)) else { return false }; + let full = what.starts_with('`') && f.unread.iter().filter(|w| w.starts_with('`')).count() >= 16; + if !full && !f.unread.iter().any(|w| w == what) { + f.unread.push(what.into()); + } + true + } + + /// Mark the first object container below the innermost layer. + pub(super) fn mark_unread(&mut self, what: String) -> bool { + let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false }; + let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip)) else { + return false; + }; + if !frame.unread.contains(&what) { + frame.unread.push(what); + } + true + } + + /// Stand in for one unread object with a box that the page can match. + pub(super) fn unread_box(&mut self, what: &str, bounds: super::Rect, hidden: bool) -> Result, String> { + if (hidden && !self.in_mask()) || self.gs.hidden || !self.shown() { + self.hidden_unread.insert(what.into()); + return Ok(None); + } + // A mask's art isn't an object of the layers: its object, or the file, is what isn't read. + if self.in_mask() { + self.unreadable(what, false); + return Ok(None); + } + let in_container = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))).is_some_and(|layer| { + self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip)) + }); + if !super::art::sane(bounds) || bounds.area() <= 0.0 { + self.unsupported.insert(what.into()); + return Ok(None); + } + let path = vectorcraft_geom::shapes::rectangle(bounds); + let gs = self.gs.clone(); + let node = self.new_node(NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }, &gs); + let id = node.id; + if in_container { + self.mark_unread(what.into()); + } + self.add(node, hidden)?; + if !in_container { + self.unread.push((id, vec![what.into()])); + } + Ok(Some(id)) + } + + fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> { + let bounds = o.get("SymbolRef").and_then(|v| v.iter().find_map(V::text)).and_then(|name| self.symbol_bounds.get(&name).copied()); + let matrix = o + .get("Matrix") + .or_else(|| o.get("Transform")) + .map(|v| v.iter().find_map(|item| if let V::Arr(a) = item { Some(Self::nums(a)) } else { None }).unwrap_or_else(|| Self::nums(v))); + let box_at_canvas = match (bounds, matrix.as_deref()) { + (Some(b), Some([a, c, d, e, x, y])) if [*a, *c, *d, *e, *x, *y].iter().all(|n| n.is_finite()) => { + Some(self.space().transform_rect_bbox(super::Affine::new([*a, *c, *d, *e, *x, *y]).transform_rect_bbox(b))) + } + _ => None, + }; + match box_at_canvas { + Some(b) => self.unread_box("symbols", b, self.obj_hidden).map(|_| ()), + None => { + self.unreadable("symbols", self.obj_hidden); + Ok(()) } } + } + + /// Drop the object just read when it is an envelope's own: a group on `%_` lines, before the + /// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's + /// contents are being edited, those contents (the mesh then is in the dictionary); either way the + /// distorted art follows on plain lines. The source text in the contents stays, hidden, as the + /// dictionary's does. The group is kept in [`Reader::envelope_art`] for the envelope to be made + /// of. Whether it was dropped. + pub(super) fn drop_envelope_art(&mut self) -> bool { let Some(n) = self.last_object() else { return false }; let id = n.id; - if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { + if !matches!(n.kind, NodeKind::Group { .. }) || !self.hidden_ids.contains(&id) { return false; } - if let Some(f) = self.frames.last_mut() { - f.children.pop(); + let mut slots = vec![]; + if let Some(f) = self.frames.last_mut() + && let Some(group) = f.children.pop() + { + envelope_text_slots(&group, &mut slots); + for mut slot in slots { + slot.visible = false; + f.children.push(slot); + } + self.envelope_art = Some(group); } self.hidden_ids.remove(&id); self.after_object = false; @@ -347,10 +526,15 @@ impl<'a> Reader<'a> { } /// Do the layers being read show? - fn shown(&self) -> bool { + pub(super) fn shown(&self) -> bool { self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) } + /// Is the art being read an opacity mask's? It is on `%_` lines and still shapes what shows. + pub(super) fn in_mask(&self) -> bool { + self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) + } + /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). @@ -392,7 +576,90 @@ impl<'a> Reader<'a> { self.warn(crate::import::ai::blend::DRAWN); } } + // The blend's own state block follows its record, as a group's follows its end. + self.after_object = true; } + + /// An envelope's plugin `XP`: the envelope whose dictionary was read last in this container + /// and its drawn art, the one object read since, become one live envelope, or, where they + /// can't, the drawn art stays (see [`crate::import::ai::envelope`]). Envelopes left waiting + /// in containers that closed keep their drawn art. + pub(super) fn end_envelope(&mut self) { + let depth = self.frames.len(); + while self.envelopes.last().is_some_and(|p| p.depth > depth) { + self.envelopes.pop(); + self.warn(crate::import::ai::envelope::DRAWN); + } + if self.envelopes.last().is_none_or(|p| p.depth != depth) { + return; + } + let Some(p) = self.envelopes.pop() else { return }; + let drawn = self.frames.last().filter(|f| f.children.len() == p.siblings + 1).and_then(|f| f.children.last()); + let Some(drawn) = drawn else { + self.warn(crate::import::ai::envelope::DRAWN); + return; + }; + let live = (self.envelope_checks > 0).then(|| crate::import::ai::envelope::live(&p.obj, p.before, p.inner)).flatten(); + let Some(kind) = live else { + self.warn(crate::import::ai::envelope::DRAWN); + return; + }; + // Objects the reader couldn't read come in as the page's art in their place: the envelope's + // drawn art stays round them. + let mut ids = HashSet::new(); + fn collect(n: &Node, ids: &mut HashSet) { + ids.insert(n.id); + for c in n.children().into_iter().flatten() { + collect(c, ids); + } + } + collect(drawn, &mut ids); + if let NodeKind::Envelope { content, .. } = &kind { + content.iter().for_each(|c| collect(c, &mut ids)); + } + if self.unread.iter().any(|(id, _)| ids.contains(id)) { + self.warn(crate::import::ai::envelope::DRAWN); + return; + } + self.envelope_checks -= 1; + // The drawn art's own properties (name, visibility, lock, transparency, appearance, mask, + // attributes) become the envelope's. + let mut envelope = drawn.clone(); + (envelope.id, envelope.kind) = (self.doc.alloc_id(), kind); + let layer = self.doc.alloc_id(); + let shared = crate::import::ai::envelope::Shared { patterns: &self.doc.patterns, images: &self.doc.images }; + let mut shown = envelope.clone(); + (shown.visible, shown.opacity) = (true, 1.0); + let mut plain = drawn.clone(); + (plain.visible, plain.opacity) = (true, 1.0); + let page = self.page_in_doc(); + if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) { + self.warn(crate::import::ai::envelope::DRAWN); + return; + } + // The contents are the envelope's own now, not a copy on `%_` lines. + fn forget(n: &Node, hidden: &mut HashSet) { + hidden.remove(&n.id); + for c in n.children().into_iter().flatten() { + forget(c, hidden); + } + } + forget(&envelope, &mut self.hidden_ids); + if let Some(f) = self.frames.last_mut() + && let Some(slot) = f.children.last_mut() + { + let id = envelope.id; + let drawn = std::mem::replace(slot, envelope); + self.envelopes_drawn.push((id, drawn)); + } + } +} + +/// Does `node` hold a text slot? +fn has_text_slot(node: &Node) -> bool { + let mut slots = vec![]; + envelope_text_slots(node, &mut slots); + !slots.is_empty() } /// The source text inside an envelope options dictionary, carried through its containing metadata. diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index d2a4b6cc5..ed838b51b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,5 +1,8 @@ use super::art::{four, sane, two}; -use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED}; +use super::types::{ + FLIP, GState, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, + VERSIONED_UNENDED, +}; use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker}; impl<'a> Reader<'a> { @@ -23,7 +26,7 @@ impl<'a> Reader<'a> { // ---- comments -------------------------------------------------------------------------- - pub(super) fn comment(&mut self, c: &[u8]) -> Result<(), String> { + pub(super) fn comment(&mut self, c: &[u8], hidden: bool) -> Result<(), String> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); if let Some((len, hex)) = self.blend_record.as_mut() { @@ -49,11 +52,50 @@ impl<'a> Reader<'a> { } return Ok(()); } + // The header's comments (AI7 specification §2.1.1; DSC 3.0). + let more = self.custom_more.take(); + if !self.header_done && self.frames.is_empty() { + let bytes = c.trim_ascii_end(); + if let (Some(cmyk), Some(v)) = (more, bytes.strip_prefix(b"%+")) { + self.custom_color(v, cmyk); + return Ok(()); + } + if let Some(v) = bytes.strip_prefix(b"%CMYKCustomColor:") { + self.custom_color(v, true); + return Ok(()); + } + if let Some(v) = bytes.strip_prefix(b"%RGBCustomColor:") { + self.custom_color(v, false); + return Ok(()); + } + if let Some(v) = bytes.strip_prefix(b"%For:") { + // `(username) (organization)`: the user who created the file. + if self.author.is_none() { + self.author = ps_string(v).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + } + return Ok(()); + } + if bytes == b"%EndComments" { + self.header_done = true; + } + } + if self.note_comment(c) { + return Ok(()); + } let m = marker(line); - // A pattern's definition (one inside another's isn't read). - if m == "BeginPattern" && self.pattern.is_none() { + // A pattern's definition. One inside another's isn't read, and the outer tile is then incomplete. + if m == "BeginPattern" { let section = self.section("EndPattern")?; - self.define_pattern(section); + if self.pattern.is_some() { + self.unsupported.insert("patterns inside patterns".into()); + } else { + self.define_pattern(section); + } + return Ok(()); + } + if m == "BeginSymbol" { + let section = self.section("EndSymbol")?; + self.symbol_bounds(section); return Ok(()); } if m == "BeginArtStyles" { @@ -61,6 +103,11 @@ impl<'a> Reader<'a> { self.styles = crate::import::ai::style::art_styles(section); return Ok(()); } + if m == "BeginArtStyleList" { + let section = self.section("EndArtStyleList")?; + self.style_list = crate::import::ai::style::art_style_list(section); + return Ok(()); + } if m == "BeginPalette" { let section = self.section("EndPalette")?; self.palette = crate::import::ai::palette::palette(section); @@ -95,7 +142,10 @@ impl<'a> Reader<'a> { _ => {} } if line.starts_with("AI5_BeginLayer") { - return self.open(Kind::Layer(Box::default()), false); + self.multi_mask_layer_starts()?; + self.open(Kind::Layer(Box::default()), false)?; + self.multi_mask_go_on(); + return Ok(()); } if line.starts_with("AI5_BeginRaster") { self.raster = Some(Raster::default()); @@ -104,11 +154,32 @@ impl<'a> Reader<'a> { return self.image_from(vals, &hex); } } else if line.starts_with("AI5_BeginPlace") { - self.place = Some(Place::default()); + // One that is still open (no `~` ended it) isn't read. + let unseen = hidden || self.gs.hidden || !self.shown(); + if let Some(open) = self.place.replace(Place { hidden: unseen, ..Place::default() }) { + self.unreadable("placed files", open.hidden); + } } else if line.starts_with("AI5_EndPlace") { // One that `~` didn't end isn't read. - if self.place.take().is_some() { - self.unreadable("placed files"); + if self.place.is_some() { + self.end_place(hidden)?; + } + } else if m == "BeginPlacedRelativePath" { + let at = self.stack.len(); + if let Some(p) = self.place.as_mut() { + p.folder_at = Some(at); + } + } else if m == "EndPlacedRelativePath" { + // The folder is the string between the markers, taken off the stack. + let base = self.frames.last().map_or(0, |f| f.base); + if let Some(at) = self.place.as_mut().and_then(|p| p.folder_at.take()).filter(|at| (base..=self.stack.len()).contains(at)) { + let vals = self.stack.split_off(at); + if let Some(p) = self.place.as_mut() { + p.folder = vals + .iter() + .find_map(|v| if let V::Str(s) = v { Some(super::obj::text(s)) } else { None }) + .filter(|f| f.len() <= MAX_PLACED_FOLDER); + } } } else if self.frames.is_empty() && let Some(v) = line.strip_prefix("%HiResBoundingBox:") @@ -133,7 +204,7 @@ impl<'a> Reader<'a> { self.ruler_units = Some(n); } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { self.art_size = two(v); - } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { + } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:").or_else(|| line.strip_prefix("%TemplateBox:")) { self.template_center = two(v); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { @@ -152,6 +223,70 @@ impl<'a> Reader<'a> { Ok(()) } + /// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it, + /// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`] + /// characters. + fn note_comment(&mut self, c: &[u8]) -> bool { + let (text, more) = if let Some(t) = c.strip_prefix(b"AI3_Note:").or_else(|| c.strip_prefix(b"%Note:")) { + (t, false) + } else if let Some(t) = c.strip_prefix(b"AI5_NoteMore:") { + (t, true) + } else { + return false; + }; + // Only as much of the line as the note can keep is decoded; a UTF-8 character the cut + // splits is left out. + let mut head = text.get(..text.len().min(4 * MAX_NOTE)).unwrap_or(text); + if head.len() < text.len() + && let Err(e) = std::str::from_utf8(head) + && e.error_len().is_none() + { + head = head.get(..e.valid_up_to()).unwrap_or(head); + } + let text = super::obj::text(head); + match (&mut self.gs.note, more) { + (Some(note), true) => { + note.push('\n'); + note.push_str(&text); + } + // A continuation with no note to continue. + (None, true) => return true, + (note, false) => *note = Some(text), + } + if let Some(note) = self.gs.note.as_mut() + && let Some((at, _)) = note.char_indices().nth(MAX_NOTE) + { + note.truncate(at); + } + true + } + + /// A custom color of the header, `cyan magenta yellow black (name)` (`cmyk`) or + /// `red green blue (name)`, each component from 0 to 1. One written otherwise isn't kept. + fn custom_color(&mut self, v: &[u8], cmyk: bool) { + self.custom_more = Some(cmyk); + if self.custom_colors.len() >= MAX_CUSTOM_COLORS { + return; + } + let Some(open) = v.iter().position(|&b| b == b'(') else { return }; + let (Some(head), Some(name)) = (v.get(..open), v.get(open..).and_then(ps_string)) else { return }; + // One more than a color has, so that too many are seen. + let parts: Vec> = head + .split(u8::is_ascii_whitespace) + .filter(|w| !w.is_empty()) + .take(5) + .map(|w| std::str::from_utf8(w).ok()?.parse::().ok().filter(|n| (0.0..=1.0).contains(n))) + .collect(); + let color = match parts.as_slice() { + [Some(c), Some(m), Some(y), Some(k)] if cmyk => vectorcraft_color::Color::cmyk(*c, *m, *y, *k), + [Some(r), Some(g), Some(b)] if !cmyk => vectorcraft_color::Color::rgb(*r, *g, *b), + _ => return, + }; + if !name.trim().is_empty() && !self.custom_colors.iter().any(|n| n.name == name) { + self.custom_colors.push(super::Named { name, color, tint: 1.0, spot: true }); + } + } + /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and /// versioned content in an alternate). fn skip_to(&mut self, end: &str) -> Result<(), String> { @@ -185,13 +320,20 @@ impl<'a> Reader<'a> { /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't - /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// read whole (an operator it doesn't know, type, a pattern it doesn't have, unbalanced groups, + /// no tile) isn't kept: art /// filled with it can't be read. fn define_pattern(&mut self, section: &[u8]) { - if self.pattern_tiles.len() >= MAX_PATTERNS { + if self.pattern_tiles.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS { return; } + match legacy_pattern(section) { + Legacy::No => {} + Legacy::Malformed => return, + Legacy::Yes(def) => return self.define_legacy_pattern(&def), + } let mut sub = Reader::new(section); + sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); // The gradients defined so far, lent (and those it defines kept). sub.gradients = std::mem::take(&mut self.gradients); @@ -200,7 +342,105 @@ impl<'a> Reader<'a> { let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); - let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + // Unread art in the definition, hidden or not, leaves the tile incomplete. + let whole = read.is_ok() + && sub.unsupported.is_empty() + && sub.unknown.is_empty() + && sub.unread.is_empty() + && sub.hidden_unread.is_empty() + && sub.frames.len() == 1; + self.keep_pattern(sub, whole, false); + } + + /// A definition in the version 7 form ([`legacy_pattern`]), read layer by layer into one group: + /// each layer's colors (`@`) set the state its tile string (`&`) is read with, or the fill of the + /// tile's box (`_ &`). Each layer starts from the state the definition starts with. + fn define_legacy_pattern(&mut self, def: &LegacyPattern) { + let mut sub = Reader::new(b""); + sub.subreader_depth = self.subreader_depth + 1; + sub.pattern = Some(PatternHead { taken: true, name: def.name.clone(), tile: Some(def.tile) }); + sub.gradients = std::mem::take(&mut self.gradients); + sub.nodes = self.nodes; + sub.template = self.template; + let mut read = sub.open(Kind::Group, false); + for layer in &def.layers { + if read.is_err() { + break; + } + read = sub.pattern_layer(layer, def.tile); + } + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() + && sub.unsupported.is_empty() + && sub.unknown.is_empty() + && sub.unread.is_empty() + && sub.hidden_unread.is_empty() + && sub.frames.len() == 1; + self.keep_pattern(sub, whole, true); + } + + /// One layer of a definition in the version 7 form, read into the definition's group. + fn pattern_layer(&mut self, layer: &'a LegacyLayer, tile: Rect) -> Result<(), String> { + self.gs = GState::default(); + self.pending_fill = None; + self.pending_stroke = None; + self.pending_unread_fill = false; + self.pending_unread_stroke = false; + self.instance = None; + self.end_path_quietly(); + // The color part has the overprint and the colors only. + let mut lex = super::Lexer::new(&layer.color); + while let Some(t) = lex.next_token() { + match t.tok { + Tok::Num(_) | Tok::Str(_) | Tok::Hex(_) => {} + Tok::Word(w) if LAYER_COLOR_OPS.contains(&w) => {} + _ => return Err("a pattern layer's colors hold something else".into()), + } + } + self.run_string(&layer.color)?; + match &layer.tile { + Some(art) => self.run_string(art)?, + None => { + let corner = |x: f64, y: f64| [V::Num(x), V::Num(y)]; + self.path_op("m", &corner(tile.x0, tile.y0))?; + for (x, y) in [(tile.x0, tile.y1), (tile.x1, tile.y1), (tile.x1, tile.y0)] { + self.path_op("L", &corner(x, y))?; + } + self.paint("f", false, false)?; + } + } + // A path the layer leaves unpainted paints nothing. + self.end_path_quietly(); + Ok(()) + } + + /// Run this reader on one of a pattern layer's strings, which must end everything it starts. + fn run_string(&mut self, bytes: &'a [u8]) -> Result<(), String> { + let depth = self.frames.len(); + self.lex = super::Lexer::new(bytes); + let read = self.run(); + let open = self.done + || self.frames.len() != depth + || self.stack.iter().any(|v| matches!(v, V::Mark)) + || !self.marks.is_empty() + || self.text.is_some() + || self.raster.is_some() + || self.place.is_some() + || self.mesh.is_some() + || self.gradient_def.is_some(); + self.stack.clear(); + self.marks.clear(); + read?; + if open { + return Err("a pattern layer doesn't end what it starts".into()); + } + Ok(()) + } + + /// Keep the pattern `sub` read, when it read it `whole`, as a pattern swatch; `v7` when it was + /// defined in the version 7 form. + fn keep_pattern(&mut self, mut sub: Reader<'_>, whole: bool, v7: bool) { let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { return; @@ -211,6 +451,11 @@ impl<'a> Reader<'a> { if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { self.doc.patterns.retain(|p| p.name != def.name); } + if v7 { + self.v7_patterns.insert(def.name.clone()); + } else { + self.v7_patterns.remove(&def.name); + } self.doc.patterns.push(def); for (key, blob) in sub.doc.images { self.doc.images.entry(key).or_insert(blob); @@ -224,6 +469,34 @@ impl<'a> Reader<'a> { } } + /// Read a definition's box for an instance whose art must come from the page. + fn symbol_bounds(&mut self, section: &[u8]) { + if self.symbol_bounds.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS { + return; + } + let mut lex = super::Lexer::new(section); + let name = match lex.next_token().map(|t| t.tok) { + Some(Tok::Str(bytes)) => super::obj::text(&bytes), + _ => return, + }; + let mut sub = Reader::new(section); + sub.subreader_depth = self.subreader_depth + 1; + sub.cropmarks = self.cropmarks; + sub.template = self.template; + sub.nodes = self.nodes; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + if read.is_err() || !sub.unsupported.is_empty() || !sub.unknown.is_empty() || sub.frames.len() != 1 { + return; + } + let Some(frame) = sub.frames.pop() else { return }; + let bounds = frame.children.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())); + let Some(bounds) = bounds.map(|b| sub.space().inverse().transform_rect_bbox(b)) else { return }; + if sane(bounds) && bounds.area() > 0.0 { + self.symbol_bounds.insert(name, bounds); + } + } + /// In a pattern's definition, the operands of its first operator start with its head, /// `(name) llx lly urx ury`: taken off them. pub(super) fn take_head(&mut self, mut vals: Vec) -> Vec { @@ -237,3 +510,203 @@ impl<'a> Reader<'a> { vals.split_off(at + 5) } } + +/// The operators a pattern layer's colors (`@`) may hold: overprint, gray, process and custom colors. +const LAYER_COLOR_OPS: &[&[u8]] = &[b"O", b"R", b"g", b"G", b"k", b"K", b"x", b"X"]; + +/// The longest string a pattern layer may have (the format's limit, 64K bytes). +const MAX_LAYER_STRING: usize = 65_535; + +/// Most layers in one pattern's definition. +const MAX_LAYERS: usize = 65_536; + +/// A pattern's definition in the version 7 form: its head, then a list of layers painted in order, +/// the first the lowest. +struct LegacyPattern { + name: String, + /// The tile in the pattern's space (y up). + tile: Rect, + layers: Vec, +} + +/// `(colors) @ (tile) &`, or `(colors) @ _ &` (no tile: the tile's box is filled with the colors' fill). +struct LegacyLayer { + color: Vec, + tile: Option>, +} + +enum Legacy { + /// Another form: the definition's art follows its head as operators. + No, + /// The version 7 form, which can't be read. + Malformed, + Yes(LegacyPattern), +} + +/// The next token that isn't a comment; `Err` at a pattern's definition inside this one. +fn next_operand<'s>(lex: &mut super::Lexer<'s>) -> Result>, ()> { + while let Some(t) = lex.next_token() { + match t.tok { + Tok::Comment(c) if marker(&String::from_utf8_lossy(c)) == "BeginPattern" => return Err(()), + Tok::Comment(_) => {} + _ => return Ok(Some(t)), + } + } + Ok(None) +} + +/// A pattern's definition in the version 7 form: `(name) llx lly urx ury [`, then for each layer +/// `(colors) @` and `(tile) &` or `_ &`, then `] E` (version 7 specification §3.4). It is that form +/// when a `[` that isn't on a `%_` line follows the head. +fn legacy_pattern(section: &[u8]) -> Legacy { + let mut lex = super::Lexer::new(section); + let mut next = || next_operand(&mut lex).ok().flatten().map(|t| (t.tok, t.hidden)); + let name = match next() { + Some((Tok::Str(s), _)) => super::obj::text(&s), + _ => return Legacy::No, + }; + let mut tile = [0.0; 4]; + for v in &mut tile { + match next() { + Some((Tok::Num(n), _)) => *v = n, + _ => return Legacy::No, + } + } + if !matches!(next(), Some((Tok::Word(b"["), false))) { + return Legacy::No; + } + let [x0, y0, x1, y1] = tile; + let mut layers = vec![]; + let string = |t: Option<(Tok, bool)>| match t { + Some((Tok::Str(s) | Tok::Hex(s), _)) if s.len() <= MAX_LAYER_STRING => Some(s), + _ => None, + }; + let word = |t: Option<(Tok, bool)>, w: &[u8]| matches!(t, Some((Tok::Word(x), _)) if x == w); + loop { + // Before the first layer, anything but a string or `] E` is the newer form's art (`[]0 d`). + let first = layers.is_empty(); + let color = match next() { + Some((Tok::Word(b"]"), _)) if !first => break, + Some((Tok::Word(b"]"), _)) => { + return match next() { + Some((Tok::Word(b"E"), _)) => Legacy::Yes(LegacyPattern { name, tile: Rect::new(x0, y0, x1, y1), layers }), + _ => Legacy::No, + }; + } + t @ Some((Tok::Str(_) | Tok::Hex(_), _)) => match string(t) { + Some(s) => s, + None => return Legacy::Malformed, + }, + _ if first => return Legacy::No, + _ => return Legacy::Malformed, + }; + if !word(next(), b"@") { + return Legacy::Malformed; + } + let tile = match next() { + Some((Tok::Word(b"_"), _)) => None, + t => match string(t) { + Some(s) => Some(s), + None => return Legacy::Malformed, + }, + }; + if !word(next(), b"&") || layers.len() >= MAX_LAYERS { + return Legacy::Malformed; + } + layers.push(LegacyLayer { color, tile }); + } + // `] E` ends the definition: nothing but comments may follow it. + if !word(next(), b"E") || !matches!(next_operand(&mut lex), Ok(None)) { + return Legacy::Malformed; + } + Legacy::Yes(LegacyPattern { name, tile: Rect::new(x0, y0, x1, y1), layers }) +} + +/// How many custom colors the header may define. +pub(super) const MAX_CUSTOM_COLORS: usize = 1000; + +/// The longest string the header's comments may give, in bytes. +const MAX_STRING: usize = 4096; + +/// The PostScript string `v` starts with (after spaces), its escapes read, when it ends on the line +/// and holds at most [`MAX_STRING`] bytes. +fn ps_string(v: &[u8]) -> Option { + let mut bytes = v.trim_ascii_start().iter().copied().peekable(); + if bytes.next()? != b'(' { + return None; + } + let (mut out, mut depth) = (Vec::new(), 0usize); + while let Some(b) = bytes.next() { + if out.len() > MAX_STRING { + return None; + } + match b { + b'\\' => match bytes.next()? { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + d @ b'0'..=b'7' => { + let mut n = u32::from(d - b'0'); + for _ in 0..2 { + match bytes.peek() { + Some(&o @ b'0'..=b'7') => { + n = n * 8 + u32::from(o - b'0'); + bytes.next(); + } + _ => break, + } + } + out.push((n & 0xff) as u8); + } + other => out.push(other), + }, + b'(' => { + depth += 1; + out.push(b); + } + b')' => { + let Some(d) = depth.checked_sub(1) else { return Some(super::obj::text(&out)) }; + depth = d; + out.push(b); + } + _ => out.push(b), + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn postscript_strings_read_their_escapes_and_end_on_the_line() { + assert_eq!(ps_string(b" (Ann \\(R\\) (x) \\101\\0618) (Org)").as_deref(), Some("Ann (R) (x) A18")); + assert_eq!(ps_string(b"(a\\nb\\\\)").as_deref(), Some("a\nb\\")); + assert_eq!(ps_string(b"(never ends \\)"), None); + assert_eq!(ps_string(b"(ends in a backslash\\"), None); + assert_eq!(ps_string(b"no string"), None); + let long = format!("({})", "x".repeat(MAX_STRING + 1)); + assert_eq!(ps_string(long.as_bytes()), None); + let longest = format!("({})", "x".repeat(MAX_STRING)); + assert_eq!(ps_string(longest.as_bytes()).map(|s| s.len()), Some(MAX_STRING)); + } + + #[test] + fn deeply_nested_symbol_definitions_stop_at_subreader_limit() { + let mut definition = String::from("(Outer) 0 Ae\n"); + for i in 0..300 { + definition.push_str(&format!("%AI14_BeginSymbol\n(S{i}) 0 Ae\n")); + } + definition.push_str("0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n"); + for _ in 0..300 { + definition.push_str("%AI10_EndSymbol\n0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n"); + } + let mut reader = Reader::new(b""); + reader.subreader_depth = MAX_SUBREADERS; + reader.symbol_bounds(definition.as_bytes()); + assert!(reader.symbol_bounds.is_empty()); + } +} diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index c1e98d0a2..f38ec7623 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1,8 +1,12 @@ -use super::art::{element_name, elements, join_and, sane}; +use super::art::{element_name, join_and, sane}; use super::containers::drop_commented_copies; +use super::ops::appearance_from_art_style; use super::types::{FLIP, Kind, MAX_DEPTH}; -use super::{Affine, Artboard, ColorMode, Document, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj}; -use vectorcraft_doc::{ImageBlob, PatternDef}; +use super::{ + Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, + Structure, Swatch, Unit, V, obj, +}; +use vectorcraft_doc::{DataSet, DataValue, GraphicStyle, Guide, ImageBlob, PatternDef, TextWrap, Variable, VariableKind, Variables}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). fn ruler_unit(v: u8) -> Option { @@ -17,6 +21,92 @@ fn ruler_unit(v: u8) -> Option { }) } +/// An artboard of the `/Document`'s `ArtboardArray`: its name, its rect in art space and its +/// display options. +#[derive(Clone, Debug)] +pub(super) struct AiArtboard { + pub name: String, + pub rect: Rect, + pub center_mark: bool, + pub cross_hairs: bool, + pub safe_areas: bool, + /// The video ruler's pixel aspect ratio. + pub par: f64, +} + +impl AiArtboard { + fn new(name: String, rect: Rect) -> Self { + Self { name, rect, center_mark: false, cross_hairs: false, safe_areas: false, par: 1.0 } + } +} + +/// The number of entry `key` in `o` when it is written as one finite number of the type `ty` +/// (`/Int`, `/Real`, `/Bool`; a whole number unless `/Real`), else `None`. +fn typed(o: &Obj, key: &str, ty: &str) -> Option { + match o.get(key)? { + [V::Num(v), V::Name(t)] if t == ty && v.is_finite() && (ty == "Real" || v.fract() == 0.0) => Some(*v), + _ => None, + } +} + +/// The `/Bool` entry `key` of `o`: 0 or 1. +fn boolean(o: &Obj, key: &str) -> Option { + match typed(o, key, "Bool")? { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + } +} + +/// An `ArtboardArray` entry's display options: `DisplayMark` (`/Int`, bits: 1 the center mark, +/// 2 cross hairs, 4 video safe areas) and `PAR` (`/Real`, the video ruler's pixel aspect ratio, +/// 0.1 to 10). A value of another type or out of range leaves the option off, or 1. +fn display_options(ab: &Obj, board: &mut AiArtboard) { + if let Some(mark) = typed(ab, "DisplayMark", "Int").filter(|m| (0.0..=f64::from(u32::MAX)).contains(m)) { + let mark = mark as u32; + (board.center_mark, board.cross_hairs, board.safe_areas) = (mark & 1 != 0, mark & 2 != 0, mark & 4 != 0); + } + if let Some(par) = typed(ab, "PAR", "Real").filter(|p| (0.1..=10.0).contains(p)) { + board.par = par; + } +} + +/// The `AI Auto Rasterize` dictionary of the `/Document` (Document Raster Effects Settings) into +/// `doc`: `dpi.` the resolution (`/Int`, 1 to 2400), `mask`, `alis` and `spot` the clipping mask, +/// anti-aliasing and Preserve Spot Colors (`/Bool`), `padd` the room added around the object +/// (`/Real`, points), and `colr` the color model and background (`/Int`: 0 the document's model on +/// white, 4 the document's model, transparent, 6 grayscale, transparent). A value of another type, +/// out of range or not one of these leaves the setting as it is. +fn raster_settings(r: &Obj, doc: &mut Document) { + use vectorcraft_doc::rastersettings::{MAX_ADD_AROUND, RasterColorModel}; + use vectorcraft_doc::setup::Background; + if let Some(ppi) = typed(r, "dpi.", "Int").filter(|p| (1.0..=2400.0).contains(p)) { + doc.raster_effects_ppi = ppi; + } + let s = &mut doc.raster_effects; + if let Some(on) = boolean(r, "mask") { + s.clipping_mask = on; + } + if let Some(on) = boolean(r, "alis") { + s.anti_alias = on; + } + if let Some(on) = boolean(r, "spot") { + s.preserve_spot_colors = on; + } + if let Some(pad) = typed(r, "padd", "Real").filter(|p| (0.0..=MAX_ADD_AROUND).contains(p)) { + s.add_around = pad; + } + let model = match typed(r, "colr", "Int") { + Some(0.0) => Some((RasterColorModel::Document, Background::White)), + Some(4.0) => Some((RasterColorModel::Document, Background::Transparent)), + Some(6.0) => Some((RasterColorModel::Grayscale, Background::Transparent)), + _ => None, + }; + if let Some((model, background)) = model { + (s.color_model, s.background) = (model, background); + } +} + /// The name of the empty group that stands for a text object, followed by the number of its /// story in the text document when the file says. pub(crate) const TEXT_SLOT: &str = "\u{0}text"; @@ -122,45 +212,49 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { r.document(o); } } - r.artboards + r.artboards.into_iter().map(|b| (b.name, b.rect)).collect() } -/// How many variables and data sets the variable library of the editing data has: XML nodes -/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each -/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements). -/// VectorCraft doesn't read them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct VariableLibrary { - pub variables: usize, - pub data_sets: usize, +/// The variable library of the editing data, as [`variable_library`] reads it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct VariableRead { + /// Its variables, data sets, active data set and lock, without bindings. + pub variables: Variables, + /// How many of its declarations, data sets, values and settings were left out. + pub skipped: usize, + /// Whether the editing data has a variable library (a `variableSets` element). + pub present: bool, } -impl VariableLibrary { - /// The import's note that the library wasn't read, when it has a variable or a data set. - pub fn note(self) -> Option { - let Self { variables, data_sets } = self; - // Counts after a colon need no plural, so every language can translate the note whole. - (variables + data_sets > 0).then(|| { - format!("the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them") - }) +impl VariableRead { + /// The import's note about what of the library wasn't read: nothing when all of it was. + pub fn note(&self) -> Option { + if !self.present || self.skipped == 0 { + return None; + } + if self.variables.variables.is_empty() && self.variables.datasets.is_empty() { + return Some("the file's variable library couldn't be read: the Variables panel doesn't list it".into()); + } + // A count after a colon needs no plural, so every language can translate the note whole. + Some(format!("the file's variable library was partly read (entries skipped: {}): the Variables panel lists the rest", self.skipped)) } } -/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers +/// The variable library of the editing data `data` ([`VariableRead`]), for a file whose layers /// [`read`] can't read. Data without a `variableSets` element isn't read. -pub fn variables(data: &[u8]) -> VariableLibrary { +pub fn variables(data: &[u8]) -> VariableRead { if crate::import::ai::find(data, b"variableSets").is_none() { - return VariableLibrary::default(); + return VariableRead::default(); } let mut r = Reader::new(data); let _ = r.run(); + if r.variables.present { + return r.variables; + } // A `/Document` still open when reading stopped. let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); - for o in open.iter().filter(|o| o.ty == "Document") { - variable_library(o, 0, &mut r.variables); - } - if r.variables != VariableLibrary::default() { - return r.variables; + if let Some(found) = open.iter().filter(|o| o.ty == "Document").map(variable_library).find(|v| v.present) { + return found; } // Reading can stop before the library: read its node on its own. variable_sets_node(data).map_or(r.variables, |node| { @@ -173,6 +267,34 @@ pub fn variables(data: &[u8]) -> VariableLibrary { }) } +impl<'a> Reader<'a> { + /// Take the author and custom colors that the header of the program `ps` gives (its comment + /// lines up to `%%EndComments`), where the editing data's own header gives none: the program of + /// an EPS whose editing data is kept apart from it. + pub(super) fn program_header(&mut self, ps: &[u8]) { + let mut end = 0; + for line in ps.split_inclusive(|&b| b == b'\n' || b == b'\r') { + if !line.starts_with(b"%") && !line.trim_ascii().is_empty() { + break; + } + end += line.len(); + if line.trim_ascii_end() == b"%%EndComments" { + break; + } + } + let mut outer = Reader::new(ps.get(..end).unwrap_or_default()); + let _ = outer.run(); + if self.author.is_none() { + self.author = outer.author; + } + for n in outer.custom_colors { + if self.custom_colors.len() < super::directives::MAX_CUSTOM_COLORS && !self.custom_colors.iter().any(|c| c.name == n.name) { + self.custom_colors.push(n); + } + } + } +} + /// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line /// that closes it: found from its name back, counting the dictionaries and arrays it holds. fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { @@ -217,10 +339,28 @@ fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { } } +/// The text wrap options an object's dictionary gives: its offset in points (`AI11TextWrapOffset`, +/// times `scale`, the factor that takes the art onto the document) and Invert Wrap +/// (`AI11TextWrapInvert`). The outline the file may keep with them (`AI11TextWrapPath`) is not +/// read: the object's own shape gives it. A value that isn't a number is left at its default, and +/// a dictionary without a readable entry gives none. +fn text_wrap(o: &Obj, scale: f64) -> Option { + let num = |key: &str| o.get(key).and_then(|v| v.iter().find_map(V::num)).filter(|v| v.is_finite()); + let offset = num("AI11TextWrapOffset"); + let invert = num("AI11TextWrapInvert").filter(|v| *v == 0.0 || *v == 1.0); + if offset.is_none() && invert.is_none() && o.get("AI11TextWrapPath").is_none() { + return None; + } + let default = TextWrap::default(); + let offset = offset.map(|v| v * scale).filter(|v| !v.is_nan()).map_or(default.offset, |v| v.clamp(-1000.0, 1000.0)); + Some(TextWrap { offset, invert: invert == Some(1.0) }) +} + impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. pub(super) fn art_dictionary(&mut self, o: &Obj) { + self.constraint_art(o); // A blend's key object or spine. if let Some(kind) = o.text("ShapeBlendPathType") && let Some(id) = self.last_object().map(|n| n.id) @@ -229,9 +369,10 @@ impl<'a> Reader<'a> { self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); } // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as - // a group holding the mesh that shapes it; the distorted art the file's PDF part draws - // follows on plain lines. That mesh isn't art, so it goes (#2405). - if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { + // a group holding the mesh that shapes it (or its contents, while they are being edited); + // the distorted art the file's PDF part draws follows on plain lines. That group isn't + // art, so it goes (#2405), kept aside for the live envelope ([`Reader::end_envelope`]). + if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_art() { return; } if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { @@ -242,12 +383,28 @@ impl<'a> Reader<'a> { if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { n.extras_mut().mask = Some(Box::new(value)); } else if !on("Disabled") { - self.unreadable("opacity masks without an owning object"); + self.unreadable("opacity masks without an owning object", false); + } + // What the art doesn't read matters only when the mask shapes what shows. + for what in &mask.unread { + if on("Disabled") { + self.hidden_unread.insert(what.clone()); + } else if let Some(op) = what.strip_prefix('`').and_then(|w| w.strip_suffix('`')) { + self.note_unknown(op, false); + } else { + self.unreadable(what, false); + } } } else if mask.nums("Disabled").first().copied() != Some(1.0) { - self.unreadable("opacity masks without readable art"); + self.unreadable("opacity masks without readable art", false); } } + // Text wrap: area type below the object in its layer flows around it. + if let Some(wrap) = text_wrap(o, self.large_canvas.unwrap_or(1.0)) + && let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) + { + n.extras_mut().wrap = Some(wrap); + } let name = match o.text("AIArtName") { Some(name) => name, None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { @@ -264,10 +421,16 @@ impl<'a> Reader<'a> { } } - /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable - /// library has. + /// The `/Document` dictionary: the artboards and their display options, Document Raster Effects + /// Settings, its variable library (the first one read), and its Objects on Path records. pub(super) fn document(&mut self, o: &Obj) { - variable_library(o, 0, &mut self.variables); + if !self.variables.present { + self.variables = variable_library(o); + } + self.constraint_data(o); + if let Some(r) = find_entry(o, "AI Auto Rasterize", 0) { + raster_settings(r, &mut self.doc); + } let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; for (i, ab) in list.items().enumerate().take(1000) { let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); @@ -277,7 +440,9 @@ impl<'a> Reader<'a> { continue; } let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); - self.artboards.push((name, r)); + let mut board = AiArtboard::new(name, r); + display_options(ab, &mut board); + self.artboards.push(board); } } @@ -298,7 +463,13 @@ impl<'a> Reader<'a> { /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. fn first_board(&self) -> Option { - self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + self.artboards.first().map(|b| b.rect).or(self.cropmarks).or_else(|| self.sized_board()) + } + + /// The page the art is compared with in the document's space, once the art has started. + pub(super) fn page_in_doc(&self) -> Option { + let board = self.first_board().or_else(|| self.bbox_board())?; + Some(self.to_doc?.transform_rect_bbox(board)) } /// The page of a file that says nothing of its artboard (the legacy format as other apps write @@ -363,7 +534,44 @@ impl<'a> Reader<'a> { Some((swatches, groups)) } + /// The graphic styles of the style list, in its order: each listed name the art styles define with + /// an appearance [`appearance_from_art_style`] reads, once (a name listed again, empty or not + /// defined is left out), with ids from 1. Also the id of each kept name. + fn graphic_styles(&self) -> (Vec, HashMap) { + let mut styles: Vec = vec![]; + let mut ids = HashMap::new(); + for raw in &self.style_list { + let name = obj::text(raw); + if name.is_empty() || ids.contains_key(&name) { + continue; + } + let Some(st) = self.styles.get(&name) else { continue }; + let Some(mut appearance) = appearance_from_art_style(st) else { continue }; + // A Large Canvas copy's styles are at its art's scale ([`Self::ensure_space`]). + if let Some(s) = self.large_canvas.filter(|s| *s != 1.0) { + appearance.scale_strokes(s); + appearance.scale_effects(s, vectorcraft_effects::scale_effect); + } + let id = u32::try_from(styles.len() + 1).unwrap_or(u32::MAX); + ids.insert(name.clone(), id); + let mut style = GraphicStyle { id, ..GraphicStyle::new(name, appearance) }; + if let Some((opacity, blend, isolate, knockout)) = st.transparency { + (style.opacity, style.blend, style.isolate, style.knockout) = (opacity, blend, isolate, knockout); + } + styles.push(style); + } + (styles, ids) + } + pub(super) fn finish(mut self) -> Result { + if self.graph.take().is_some() { + self.warn(super::types::GRAPH_UNENDED); + } + // Envelopes whose plugin never closed them keep their drawn art. + if !self.envelopes.is_empty() { + self.envelopes.clear(); + self.warn(crate::import::ai::envelope::DRAWN); + } if !self.unknown.is_empty() { let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); @@ -378,24 +586,26 @@ impl<'a> Reader<'a> { let to_doc = self.ensure_space(); let artboard = self.first_board(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); + super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); - let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + let boards: Vec = if self.artboards.is_empty() { + artboard.or_else(|| self.bbox_board()).map(|r| vec![AiArtboard::new("Artboard 1".to_string(), r)]).unwrap_or_default() } else { - self.artboards.clone() + std::mem::take(&mut self.artboards) }; + let board_rects: Vec = boards.iter().map(|b| b.rect).collect(); doc.artboards = boards .into_iter() .enumerate() - .map(|(i, (name, r))| Artboard { + .map(|(i, b)| Artboard { id: i as u32 + 1, - name, - rect: to_doc.transform_rect_bbox(r), + name: b.name, + rect: to_doc.transform_rect_bbox(b.rect), locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, + show_center_mark: b.center_mark, + show_cross_hairs: b.cross_hairs, + show_video_safe_areas: b.safe_areas, + video_ruler_pixel_aspect_ratio: b.par, color: None, }) .collect(); @@ -413,6 +623,22 @@ impl<'a> Reader<'a> { color: None, }]; } + if !self.ruler_guides.is_empty() { + let ids: Vec = doc.artboards.iter().map(|a| a.id).collect(); + let (candidates, centre) = (std::mem::take(&mut self.ruler_guides), self.template_center); + let pick = |id: NodeId| -> Option { + let (a, b) = candidates.get(&id)?; + let (vertical, board) = ruler_guide(*a, *b, centre, &board_rects)?; + let p = to_doc * *a; + let artboard = match board { + Some(i) => Some(*ids.get(i)?), + None => None, + }; + let pos = if vertical { p.x } else { p.y }; + pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None }) + }; + take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); + } if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { doc.color_mode = ColorMode::Cmyk; let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); @@ -422,21 +648,55 @@ impl<'a> Reader<'a> { doc.units = u; } // The file's own swatches in place of the defaults, in its order and colour groups. - if let Some((swatches, groups)) = self.palette_swatches(&doc.patterns) { - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - // The colour operators' definitions first: a gradient, defined before the art, names a - // global process colour as a stop and would otherwise make it an ink (#1771). - for n in self.spots.iter().chain(&self.stop_spots) { - if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + let own_palette = match self.palette_swatches(&doc.patterns) { + Some((swatches, groups)) => { + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + true } + None => false, + }; + // The colour operators' definitions first: a gradient, defined before the art, names a + // global process colour as a stop and would otherwise make it an ink (#1771). The header's + // custom colors follow. The first definition of a name makes its swatch, and takes the place + // of a default swatch of that name when the file has no palette of its own (a reserved name + // in brackets, such as `[None]`, keeps its swatch). + let mut defined = std::collections::HashSet::new(); + // The swatch names there are, looked up rather than scanned for each name a file defines + // (`Document::swatch` also knows the registration colour). + let mut have: std::collections::HashSet = doc.swatches_iter().map(|s| s.name.clone()).collect(); + have.insert(vectorcraft_color::swatch::REGISTRATION.to_string()); + let colors = self.spots.iter().chain(&self.stop_spots).map(|n| (n, n.spot)).chain(self.custom_colors.iter().map(|n| (n, true))); + for (n, spot) in colors { + let first = defined.insert(n.name.clone()); + let swatch = Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot }; + if have.insert(n.name.clone()) { + doc.swatches.push(swatch); + } else if first + && !own_palette + && !(n.name.starts_with('[') && n.name.ends_with(']')) + && let Some(default) = doc.swatch_mut(&n.name) + { + *default = swatch; + } + } + if let Some(author) = self.author.take() { + doc.metadata.author = author; } for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { - if doc.swatch(&name).is_none() { + if have.insert(name.clone()) { doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); } } + // The file's graphic styles in place of the defaults, and the objects linked to them. + let (styles, ids) = self.graphic_styles(); + let styles_read = !styles.is_empty(); + if styles_read { + let links: HashMap = self.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect(); + if !links.is_empty() { + link_styles(&mut doc.layers, &links, &styles, 0); + } + doc.graphic_styles = styles.into(); + } let mut warnings = self.warnings; if self.drawn_looks > 0 { warnings.push(format!( @@ -444,7 +704,11 @@ impl<'a> Reader<'a> { self.drawn_looks )); } - warnings.extend(self.variables.note()); + let variables = std::mem::take(&mut self.variables); + warnings.extend(variables.note()); + if variables.present { + doc.variables = variables.variables.into(); + } Ok(Structure { doc, to_doc, @@ -452,36 +716,472 @@ impl<'a> Reader<'a> { artboard, template: self.template, hidden_unread: self.hidden_unread, + unread: self.unread, + unread_elsewhere: self.unread_elsewhere, slot_names: self.slot_names, restyled: self.restyled, placed_elsewhere: self.placed_elsewhere, + envelopes_drawn: self.envelopes_drawn, + styles_read, warnings, }) } } +/// Link the objects of `links` among `nodes` to their graphic style's id, each only when it has its +/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order). A path, which +/// has no contents, takes the style's contents slot. +fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[GraphicStyle], depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in nodes { + if let Some(id) = links.get(&n.id) + && let Some(g) = id.checked_sub(1).and_then(|i| styles.get(usize::try_from(i).ok()?)).filter(|g| g.id == *id) + { + let mut appearance = (*n.appearance).clone(); + if !n.is_container() && !matches!(n.kind, NodeKind::Text(_) | NodeKind::Image(_)) { + appearance.contents_index = g.appearance.contents_index; + } + if g.transparency_matches(n) && g.appearance_on(n).approx_eq(&appearance) { + let n = Arc::make_mut(n); + n.appearance = appearance.into(); + n.extras_mut().graphic_style = Some(*id); + } + } + if n.children().is_some_and(|c| !c.is_empty()) + && let Some(c) = Arc::make_mut(n).children_mut() + { + link_styles(c, links, styles, depth + 1); + } + } +} + /// The dictionary of entry `key` in `o` or the dictionaries it holds. -fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { +/// How far (points) a ruler guide's ends may lie from the canvas's or an artboard's edges. +const GUIDE_TOLERANCE: f64 = 0.01; + +/// Whether the straight guide path from `a` to `b` (art space) is a ruler guide → whether it is +/// vertical, and the index in `boards` of the artboard it belongs to (`None`: a canvas guide). A +/// canvas guide runs across the whole canvas, 16,383 points centred on the template box's +/// centre `centre`; an artboard guide runs from edge to edge of one artboard of `boards`, inside it. +fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) -> Option<(bool, Option)> { + let near = |u: f64, v: f64| (u - v).abs() <= GUIDE_TOLERANCE; + let vertical = match (near(a.x, b.x), near(a.y, b.y)) { + (true, false) => true, + (false, true) => false, + _ => return None, + }; + let (along, across) = if vertical { ((a.y.min(b.y), a.y.max(b.y)), a.x) } else { ((a.x.min(b.x), a.x.max(b.x)), a.y) }; + if let Some((cx, cy)) = centre { + let c = if vertical { cy } else { cx }; + if near(along.0, c - CANVAS_CENTRE) && near(along.1, c + CANVAS_CENTRE) { + return Some((vertical, None)); + } + } + let mut on = boards.iter().enumerate().filter(|(_, r)| { + let (ends, sides) = + if vertical { ((r.min_y(), r.max_y()), (r.min_x(), r.max_x())) } else { ((r.min_x(), r.max_x()), (r.min_y(), r.max_y())) }; + near(along.0, ends.0) && near(along.1, ends.1) && (sides.0..=sides.1).contains(&across) + }); + match (on.next(), on.next()) { + (Some((i, _)), None) => Some((vertical, Some(i))), + _ => None, + } +} + +/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into +/// `guides`, each on its layer. +fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, guides: &mut Vec, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in nodes { + if !matches!(n.kind, NodeKind::Layer { .. }) { + continue; + } + let id = n.id; + let Some(kids) = Arc::make_mut(n).children_mut() else { continue }; + kids.retain(|k| match pick(k.id) { + Some(g) => { + guides.push(Guide { layer: Some(id), ..g }); + false + } + None => true, + }); + take_ruler_guides(kids, pick, guides, depth + 1); + } +} + +pub(super) fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { if depth > 8 { return None; } o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1))) } -/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds -/// (see [`VariableLibrary`]) to `found`. -fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) { +// ---- the variable library ----------------------------------------------------------------- + +/// Caps on the variable library of the editing data, as Load Variable Library has them: the XML +/// nodes read, the variables and data sets kept, the characters of a name, and the bytes of the +/// names and values kept. +const MAX_LIBRARY_NODES: usize = 2_000_000; +const MAX_LIBRARY_VARIABLES: usize = 256; +const MAX_LIBRARY_DATASETS: usize = 1_000; +const MAX_LIBRARY_NAME: usize = 255; +const MAX_LIBRARY_BYTES: usize = 8 << 20; + +/// The variable library in `o` or the dictionaries it holds: XML nodes (`/XMLNode`), each with its +/// name (`xmlnode-nodename`), value (`xmlnode-nodevalue`), type (`xmlnode-nodetype`: 1 an element, 2 +/// an attribute, 3 text), children (`xmlnode-children`, an array in order) and attributes +/// (`xmlnode-attributes`, a dictionary of attribute nodes). The library is a `variableSets` element +/// holding a `variableSet` (its `locked` attribute `none`, or `variableAndTypes` when the variables +/// are locked), with a `variables` element of `variable` elements (`varName`, and `trait`: +/// `textcontent`, `visibility`, `fileref` or `graphdata`; another trait or none is a variable of +/// unknown kind), and a `sampleDataSets` element (`activeDataSet`, the data set shown) of +/// `sampleDataSet` elements (`dataSetName`). In a data set, the element named after a variable holds +/// its value: `p` elements of text, one paragraph each; a `/Bool` text, whether its objects show; a +/// linked file's URL (`file://`) or path. Graph values aren't read. What is malformed, repeated or +/// past the caps (a declaration, a data set, a value, a setting, a second `variableSet`) is left out +/// and counted; a library past [`MAX_LIBRARY_NODES`] isn't read. Bindings aren't read. +fn variable_library(o: &Obj) -> VariableRead { + find_variable_sets(o, 0).map_or_else(VariableRead::default, read_variable_sets) +} + +/// The `variableSets` XML node in `o` or the dictionaries it holds. +fn find_variable_sets(o: &Obj, depth: usize) -> Option<&Obj> { if depth > MAX_DEPTH { - return; + return None; } if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") { - for set in elements(o, "variableSet") { - found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count(); - found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count(); - } - return; + return Some(o); } - for c in o.items() { - variable_library(c, depth + 1, found); + o.items().find_map(|c| find_variable_sets(c, depth + 1)) +} + +/// A value of an XML node's entry, by the type the editing data gives it. +#[derive(Clone, Debug, PartialEq)] +enum XmlValue { + /// A `/String` or `/UnicodeString`: its strings joined (a long one is written across lines). + Text(String), + /// A `/Bool`. + Bool(bool), +} + +/// The value of entry `key` of the XML node `o`; `None` without it, when it is of another type, or +/// when its text is longer than [`MAX_LIBRARY_BYTES`]. +fn xml_value(o: &Obj, key: &str) -> Option { + let (V::Name(ty), rest) = o.get(key)?.split_last()? else { return None }; + match ty.as_str() { + "String" | "UnicodeString" => { + let len = rest.iter().map(|v| if let V::Str(s) = v { s.len() } else { 0 }).fold(0usize, usize::saturating_add); + if len > MAX_LIBRARY_BYTES { + return None; + } + let mut bytes = Vec::with_capacity(len); + for v in rest { + let V::Str(s) = v else { return None }; + bytes.extend_from_slice(s); + } + Some(XmlValue::Text(obj::text(&bytes))) + } + "Bool" => match rest { + [V::Num(n)] if *n == 0.0 => Some(XmlValue::Bool(false)), + [V::Num(n)] if *n == 1.0 => Some(XmlValue::Bool(true)), + _ => None, + }, + _ => None, } } + +/// Whether `o` is an XML node of type `ty` (1 an element, 2 an attribute, 3 text). +fn is_node(o: &Obj, ty: f64) -> bool { + o.ty == "XMLNode" && o.nums("xmlnode-nodetype") == [ty] +} + +/// The name of the XML node `o` as the file writes it, prefix and all. +fn node_name(o: &Obj) -> Option { + match xml_value(o, "xmlnode-nodename")? { + XmlValue::Text(name) => Some(name), + XmlValue::Bool(_) => None, + } +} + +/// Whether `o` is an element whose name without its prefix is `name`. +fn is_element(o: &Obj, name: &str) -> bool { + is_node(o, 1.0) && node_name(o).is_some_and(|n| n.rsplit(':').next() == Some(name)) +} + +/// The text of the attribute `name` (without its prefix) of the element `o`. +fn xml_attribute(o: &Obj, name: &str) -> Option { + let attributes = o.obj("xmlnode-attributes").filter(|a| a.ty == "Dictionary")?; + let node = attributes.items().find(|a| is_node(a, 2.0) && node_name(a).is_some_and(|n| n.rsplit(':').next() == Some(name)))?; + match xml_value(node, "xmlnode-nodevalue")? { + XmlValue::Text(text) => Some(text), + XmlValue::Bool(_) => None, + } +} + +/// A name of the library (a variable's or a data set's): trimmed, not empty and at most +/// [`MAX_LIBRARY_NAME`] characters. +fn library_name(name: Option) -> Option { + let name = name?.trim().to_string(); + (!name.is_empty() && name.chars().count() <= MAX_LIBRARY_NAME).then_some(name) +} + +/// The file path of a linked file's value: a `file://` URL (`file:////Users/…`) as its path, its +/// `%XX` escapes decoded (a URL that names a host other than `localhost` as a network path, +/// `//host/…`), or an absolute path as it is. White space around it isn't part of it. +fn linked_path(text: &str) -> Option { + let text = text.trim_matches([' ', '\t', '\n', '\r']); + let drive = |p: &str| matches!(p.as_bytes(), [l, b':', ..] if l.is_ascii_alphabetic()); + let path = match text.strip_prefix("file://") { + Some(rest) => { + let rest = rest.strip_prefix("localhost").filter(|r| r.starts_with('/')).unwrap_or(rest); + if rest.starts_with('/') { + let p = percent_decoded(rest.trim_start_matches('/')); + if drive(&p) { p } else { format!("/{p}") } + } else { + // A host: the share it names. + format!("//{}", percent_decoded(rest)) + } + } + None => text.to_string(), + }; + (path.len() > 1 && (path.starts_with('/') || path.starts_with("\\\\") || drive(&path))).then_some(path) +} + +/// `s` with `%XX` escapes decoded (a file URL's path); as it is when the decoded bytes aren't UTF-8. +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail + .get(..2) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// The walk through a variable library: the nodes read, the bytes kept, and what was left out. +#[derive(Default)] +struct LibraryWalk { + nodes: usize, + bytes: usize, + skipped: usize, +} + +impl LibraryWalk { + /// The child nodes of the XML node `o`, in order. `None` when they aren't an array, or when the + /// library has more nodes than [`MAX_LIBRARY_NODES`]. + fn children<'o>(&mut self, o: &'o Obj) -> Option> { + let list = o.obj("xmlnode-children").filter(|c| c.ty == "Array")?; + let children: Vec<&Obj> = list.items().collect(); + self.nodes = self.nodes.saturating_add(children.len()); + (self.nodes <= MAX_LIBRARY_NODES).then_some(children) + } + + /// Whether `len` more bytes of names and values fit in [`MAX_LIBRARY_BYTES`] (they are then counted). + fn keep(&mut self, len: usize) -> bool { + let fits = self.bytes.saturating_add(len) <= MAX_LIBRARY_BYTES; + if fits { + self.bytes += len; + } + fits + } + + /// Whether a value of `len` bytes would still fit in [`MAX_LIBRARY_BYTES`] (nothing is counted). + fn room(&self, len: usize) -> bool { + self.bytes.saturating_add(len) <= MAX_LIBRARY_BYTES + } + + /// The `variableSet` `set`, into `vars`. + fn variable_set(&mut self, set: &Obj, vars: &mut Variables) { + vars.locked = match xml_attribute(set, "locked").as_deref() { + None | Some("none") => false, + Some("variableAndTypes") => true, + Some(_) => { + self.skipped += 1; + false + } + }; + let Some(children) = self.children(set) else { + self.skipped += 1; + return; + }; + for list in children.iter().filter(|c| is_element(c, "variables")) { + self.declarations(list, vars); + } + for list in children.iter().filter(|c| is_element(c, "sampleDataSets")) { + self.data_sets(list, vars); + } + } + + /// The `variable` elements of the `variables` element `list`, into `vars`. + fn declarations(&mut self, list: &Obj, vars: &mut Variables) { + let Some(children) = self.children(list) else { + self.skipped += 1; + return; + }; + for v in children.into_iter().filter(|c| !is_node(c, 3.0)) { + let name = is_element(v, "variable").then(|| library_name(xml_attribute(v, "varName"))).flatten(); + let Some(name) = name.filter(|n| vars.variable(n).is_none() && vars.variables.len() < MAX_LIBRARY_VARIABLES && self.keep(n.len())) else { + self.skipped += 1; + continue; + }; + let kind = match xml_attribute(v, "trait").as_deref() { + Some("textcontent") => VariableKind::Text, + Some("visibility") => VariableKind::Visibility, + Some("fileref") => VariableKind::Image, + Some("graphdata") => VariableKind::Graph, + _ => VariableKind::Unknown, + }; + vars.variables.push(Variable { name, kind }); + } + } + + /// The `sampleDataSet` elements of the `sampleDataSets` element `list`, into `vars`, and the + /// data set it shows. + fn data_sets(&mut self, list: &Obj, vars: &mut Variables) { + let Some(children) = self.children(list) else { + self.skipped += 1; + return; + }; + for row in children.into_iter().filter(|c| !is_node(c, 3.0)) { + let name = is_element(row, "sampleDataSet").then(|| library_name(xml_attribute(row, "dataSetName"))).flatten(); + let Some(name) = name.filter(|n| vars.dataset(n).is_none() && vars.datasets.len() < MAX_LIBRARY_DATASETS && self.keep(n.len())) else { + self.skipped += 1; + continue; + }; + let values = self.values(row, vars); + vars.datasets.push(DataSet { name, values }); + } + if let Some(active) = xml_attribute(list, "activeDataSet").and_then(|a| library_name(Some(a))) { + vars.active_dataset = vars.dataset(&active).map(|_| active); + } + } + + /// The values of the `sampleDataSet` element `row`, for the variables of `vars`. + fn values(&mut self, row: &Obj, vars: &Variables) -> std::collections::BTreeMap { + let mut values = std::collections::BTreeMap::new(); + let Some(children) = self.children(row) else { + self.skipped += 1; + return values; + }; + for el in children.into_iter().filter(|c| !is_node(c, 3.0)) { + let variable = is_node(el, 1.0).then(|| node_name(el)).flatten().and_then(|n| vars.variable(n.rsplit(':').next().unwrap_or(&n))); + let Some(variable) = variable.filter(|v| !values.contains_key(&v.name)) else { + self.skipped += 1; + continue; + }; + match self.value(el, variable.kind) { + Ok(Some(value)) => { + values.insert(variable.name.clone(), value); + } + Ok(None) => {} + Err(()) => self.skipped += 1, + } + } + values + } + + /// The value of a variable of `kind` that the element `el` holds: `Ok(None)` when it holds none, + /// `Err` when it can't be read (graph values aren't read). + fn value(&mut self, el: &Obj, kind: VariableKind) -> Result, ()> { + let children = self.children(el).ok_or(())?; + if children.is_empty() { + return Ok(None); + } + let value = match kind { + VariableKind::Text => { + let (mut paragraphs, mut total) = (Vec::new(), 0usize); + // Text between the paragraphs is white space the XML had. + for p in children.into_iter().filter(|c| !is_node(c, 3.0)) { + if !is_element(p, "p") { + return Err(()); + } + let mut paragraph = String::new(); + for t in self.children(p).ok_or(())? { + match (is_node(t, 3.0), xml_value(t, "xmlnode-nodevalue")) { + (true, Some(XmlValue::Text(text))) if self.room(total.saturating_add(text.len())) => { + total += text.len(); + paragraph.push_str(&text); + } + _ => return Err(()), + } + } + // The line break that joins it to the next one. + total = total.saturating_add(1); + paragraphs.push(paragraph); + } + if paragraphs.is_empty() { + return Ok(None); + } + DataValue::Text(paragraphs.join("\n")) + } + VariableKind::Visibility => match children.as_slice() { + [t] if is_node(t, 3.0) => match xml_value(t, "xmlnode-nodevalue") { + Some(XmlValue::Bool(show)) => DataValue::Visible(show), + _ => return Err(()), + }, + _ => return Err(()), + }, + VariableKind::Image => { + let mut text = String::new(); + for t in children { + match (is_node(t, 3.0), xml_value(t, "xmlnode-nodevalue")) { + (true, Some(XmlValue::Text(s))) if self.room(text.len().saturating_add(s.len())) => text.push_str(&s), + _ => return Err(()), + } + } + if text.trim_matches([' ', '\t', '\n', '\r']).is_empty() { + return Ok(None); + } + DataValue::Image(linked_path(&text).ok_or(())?) + } + VariableKind::Graph | VariableKind::Unknown => return Err(()), + }; + let len = match &value { + DataValue::Text(s) | DataValue::Image(s) | DataValue::Graph(s) => s.len(), + DataValue::Visible(_) => 1, + }; + if self.keep(len) { Ok(Some(value)) } else { Err(()) } + } +} + +/// The library of the `variableSets` element `sets` (see [`variable_library`]). +fn read_variable_sets(sets: &Obj) -> VariableRead { + let mut walk = LibraryWalk::default(); + let mut vars = Variables::default(); + match walk.children(sets) { + Some(children) => { + let (sets, other): (Vec<&Obj>, Vec<&Obj>) = children.into_iter().filter(|c| !is_node(c, 3.0)).partition(|c| is_element(c, "variableSet")); + // A node that isn't a variable set, and a second set (VectorCraft keeps one set of + // variables), are left out. + walk.skipped += other.len() + sets.len().saturating_sub(1); + if let Some(first) = sets.first() { + walk.variable_set(first, &mut vars); + } + } + None => walk.skipped += 1, + } + if walk.nodes > MAX_LIBRARY_NODES { + return VariableRead { variables: Variables::default(), skipped: 1, present: true }; + } + VariableRead { variables: vars, skipped: walk.skipped, present: true } +} diff --git a/crates/eps/src/import/ai/read/masks.rs b/crates/eps/src/import/ai/read/masks.rs new file mode 100644 index 000000000..c07ab5868 --- /dev/null +++ b/crates/eps/src/import/ai/read/masks.rs @@ -0,0 +1,185 @@ +//! Multi-layer masks in the legacy format (version 7 specification §10): `Mb`, the mask's path +//! painted with `W`, `Md`, the masked objects (in that layer and, past its `LB`, in the layers +//! after it), `MB`. The objects of each layer the mask covers become one clipping group in that +//! layer, its clipping path a copy of the mask's. + +use super::types::{Frame, Kind, MAX_NODES}; +use super::{Arc, Node, NodeKind, Reader}; + +/// The note for a file with a multi-layer mask. +pub(crate) const PER_LAYER: &str = "a mask that covers several layers comes in as a clipping group in each layer it covers"; + +/// A multi-layer mask being read. +#[derive(Debug)] +pub(super) struct MultiMask { + /// The mask's clipping path, once `Md` has defined it. + mask: Option, + /// What each copy of the mask adds to the objects read: its objects and their points. + weight: usize, + /// How many frames were open when the masked objects started in the frame they go in; 0 + /// outside the layers, until a layer starts. + depth: usize, + /// Where the masked objects start among that frame's children. + start: usize, +} + +impl Reader<'_> { + /// A multi-layer mask's operator `w`: `Mb` (or `Mv`) starts the mask, `Md` (or `ME`) defines + /// it, `MB` ends it, and `Mi` changes nothing here. + pub(super) fn multi_mask_op(&mut self, w: &str) -> Result<(), String> { + match w { + "Mb" | "Mv" => self.begin_multi_mask(), + "Md" | "ME" => self.define_multi_mask(), + "MB" => self.end_multi_mask(), + _ => Ok(()), + } + } + + /// `LB`: the layer ends, and a multi-layer mask goes on in the frame around it. + pub(super) fn end_layer_in_mask(&mut self) -> Result<(), String> { + self.multi_mask_layer_ends()?; + self.end_layer()?; + self.multi_mask_go_on(); + Ok(()) + } + + /// `Mb`: a multi-layer mask starts. + fn begin_multi_mask(&mut self) -> Result<(), String> { + if self.multi_mask.is_some() { + return Err("a multi-layer mask's operators are out of order".into()); + } + self.end_path_quietly(); + let start = self.frames.last().map_or(0, |f| f.children.len()); + self.multi_mask = Some(MultiMask { mask: None, weight: 0, depth: self.frames.len(), start }); + Ok(()) + } + + /// `Md`: the clipping path painted last since `Mb`, in the same group or layer, is the mask. + fn define_multi_mask(&mut self) -> Result<(), String> { + let depth = self.frames.len(); + let Some(m) = self.multi_mask.as_mut() else { return Err("a multi-layer mask's operators are out of order".into()) }; + if m.mask.is_some() || depth == 0 { + return Err("a multi-layer mask's operators are out of order".into()); + } + if m.depth != depth { + return Err("a group crosses a multi-layer mask".into()); + } + let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) }; + let Some(at) = f.clips.iter().copied().filter(|i| *i >= m.start && *i < f.children.len()).max() else { + return Err("a multi-layer mask needs one clipping path".into()); + }; + let mask = f.children.remove(at); + f.clips.retain(|i| *i != at); + for i in &mut f.clips { + if *i > at { + *i -= 1; + } + } + let mut weight = mask.count_with_masks(); + mask.walk(&mut |n| { + if let NodeKind::Path { path, .. } = &n.kind { + weight = weight.saturating_add(path.anchor_count()); + } + }); + m.weight = weight; + m.mask = Some(mask); + self.after_object = false; + Ok(()) + } + + /// `MB`: the masked objects end. + fn end_multi_mask(&mut self) -> Result<(), String> { + if !self.multi_mask.as_ref().is_some_and(|m| m.mask.is_some()) { + return Err("a multi-layer mask's operators are out of order".into()); + } + self.wrap_masked()?; + self.multi_mask = None; + Ok(()) + } + + /// `%AI5_BeginLayer` in a multi-layer mask, before the layer opens: the objects masked so far + /// in the frame around it are grouped, and the mask goes on in the new layer. + pub(super) fn multi_mask_layer_starts(&mut self) -> Result<(), String> { + let Some(m) = self.multi_mask.as_ref() else { return Ok(()) }; + if m.depth != 0 { + if m.mask.is_none() { + return Err("a multi-layer mask's operators are out of order".into()); + } + self.wrap_masked()?; + } + Ok(()) + } + + /// A layer opened or closed in a multi-layer mask: the masked objects go on in the frame now + /// innermost. + pub(super) fn multi_mask_go_on(&mut self) { + let (depth, start) = (self.frames.len(), self.frames.last().map_or(0, |f| f.children.len())); + if let Some(m) = self.multi_mask.as_mut() { + (m.depth, m.start) = (depth, start); + } + } + + /// `LB` in a multi-layer mask, before the layer closes: its masked objects are grouped. + fn multi_mask_layer_ends(&mut self) -> Result<(), String> { + let Some(m) = self.multi_mask.as_ref() else { return Ok(()) }; + if m.mask.is_none() { + return Err("a multi-layer mask's operators are out of order".into()); + } + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })) { + return Err("a group crosses a multi-layer mask".into()); + } + self.wrap_masked() + } + + /// A group, compound path or clipping group closing: it must have opened inside the mask. + pub(super) fn multi_mask_container_ends(&self) -> Result<(), String> { + match &self.multi_mask { + Some(m) if m.depth == self.frames.len() => Err("a group crosses a multi-layer mask".into()), + _ => Ok(()), + } + } + + /// The masked objects in the innermost frame become a clipping group, a copy of the mask first. + fn wrap_masked(&mut self) -> Result<(), String> { + let Some(m) = self.multi_mask.as_ref() else { return Ok(()) }; + if m.depth == 0 { + return Ok(()); + } + if m.depth != self.frames.len() { + return Err("a group crosses a multi-layer mask".into()); + } + let Some(mut mask) = m.mask.clone() else { return Err("a multi-layer mask's operators are out of order".into()) }; + let (start, weight) = (m.start, m.weight); + let Some(f) = self.frames.last_mut() else { return Ok(()) }; + if start >= f.children.len() { + return Ok(()); + } + // A clipping path painted among the masked objects clips the whole layer or group, as + // outside a mask: it stays in the frame, outside the masked objects' group. + let painted: std::collections::HashSet = f.clips.iter().filter_map(|i| i.checked_sub(start)).collect(); + let (clips, kids): (Vec<_>, Vec<_>) = f.children.split_off(start).into_iter().enumerate().partition(|(i, _)| painted.contains(i)); + let (clips, kids): (Vec, Vec) = (clips.into_iter().map(|(_, k)| k).collect(), kids.into_iter().map(|(_, k)| k).collect()); + f.clips.retain(|i| *i < start); + if !kids.is_empty() { + self.nodes = self.nodes.saturating_add(weight).saturating_add(1); + if self.nodes > MAX_NODES { + return Err(format!("it has more than {MAX_NODES} objects")); + } + self.reid(&mut mask); + let children = std::iter::once(mask).chain(kids).map(Arc::new).collect(); + let group = Node::new(self.doc.alloc_id(), NodeKind::Group { children, clip: true }); + if let Some(f) = self.frames.last_mut() { + f.children.push(group); + } + self.warn(PER_LAYER); + } + if let Some(f) = self.frames.last_mut() { + for c in clips { + f.clips.push(f.children.len()); + f.children.push(c); + } + } + self.after_object = false; + Ok(()) + } +} diff --git a/crates/eps/src/import/ai/read/on_path.rs b/crates/eps/src/import/ai/read/on_path.rs new file mode 100644 index 000000000..1e33e09a1 --- /dev/null +++ b/crates/eps/src/import/ai/read/on_path.rs @@ -0,0 +1,188 @@ +//! Objects on Path groups: a `u` … `U` group whose children's dictionaries carry a `Persistent ID` +//! (the path's with `KeyPath` 1, each object's with its `ConstraintArtOrderId` and the angle of its +//! frame, `BBAccumRotation`: radians, counterclockwise in art space), and a dictionary of the +//! `/Document` dictionary's `Constraint Data` array naming them: `Constraint Name`, `Constraint +//! Verison` 1, `Constraint IDs` (the path's id, then the objects' in their order along the path) +//! and `Constraint Dict` with the options (`PivotPosition` 0 to 8, `Rotation` in radians, +//! `IsEquiSpaced`, `StartRangeFraction`, `ObjectsOrder`, `ConstraintArtData`). The group opens +//! with its [`ObjectsOnPath`] record and its art where it is drawn. A record with options the +//! record can't hold (a `Spacing`, uneven spacing, a start other than 0, keys this reader doesn't +//! know), or one that doesn't match one group exactly, leaves the group a plain group. + +use std::collections::{HashMap, HashSet}; + +use vectorcraft_doc::objects_on_path::{Member, ObjectsOnPath, attachable_path}; + +use super::{Affine, Document, NodeId, NodeKind, Obj, Reader, V}; + +/// The constraint name of an Objects on Path record. +const NAME: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files +/// Most records read from one file. +const MAX_RECORDS: usize = 1000; +/// Most objects one record names. +const MAX_IDS: usize = 10_000; +/// Most objects whose ids are kept. +const MAX_TAGGED: usize = 100_000; +/// The keys of `Constraint Dict` this reader reads; any other one leaves the group plain. +const KNOWN: [&str; 6] = ["StartRangeFraction", "ConstraintArtData", "ObjectsOrder", "PivotPosition", "IsEquiSpaced", "Rotation"]; + +/// An Objects on Path record of the `/Document` dictionary. +#[derive(Debug)] +pub(super) struct Record { + /// The path's id, then the objects' in their order along the path. + ids: Vec, + pivot: u8, + /// Degrees, counterclockwise. + rotate: f64, +} + +/// What an object's dictionary says of its part in an Objects on Path group. +#[derive(Clone, Copy, Debug, Default)] +struct Tag { + key_path: bool, + order: Option, + /// `BBAccumRotation`: radians, counterclockwise in art space. + angle: Option, +} + +/// The records and object ids read, for [`apply`]. +#[derive(Debug, Default)] +pub(super) struct OnPath { + records: Vec, + /// The objects with each `Persistent ID`. + ids: HashMap>, + tags: HashMap, +} + +/// The first number of entry `key`. +fn num(o: &Obj, key: &str) -> Option { + o.nums(key).first().copied() +} + +/// An Objects on Path record from a dictionary of the `Constraint Data` array: `None` for another +/// constraint, another version, or options the record can't hold. +fn record(o: &Obj) -> Option { + if o.text("Constraint Name").as_deref() != Some(NAME) || num(o, "Constraint Verison") != Some(1.0) { + return None; + } + let list = o.obj("Constraint IDs")?; + if list.entries.len() > MAX_IDS || !list.content.is_empty() { + return None; + } + let ids = list.entries.iter().map(|(_, v)| v.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)).collect::>>()?; + let members = ids.len().checked_sub(1).filter(|n| *n > 0)?; + let options = o.obj("Constraint Dict")?; + if options.entries.iter().any(|(k, _)| !k.as_deref().is_some_and(|k| KNOWN.contains(&k))) || !options.content.is_empty() { + return None; + } + if num(options, "IsEquiSpaced") != Some(1.0) + || !matches!(options.get("StartRangeFraction").map(|_| num(options, "StartRangeFraction")), None | Some(Some(0.0))) + { + return None; + } + let pivot = num(options, "PivotPosition").filter(|p| p.fract() == 0.0 && (0.0..=8.0).contains(p))? as u8; + let rotate = num(options, "Rotation").map(f64::to_degrees).filter(|r| r.is_finite())?; + let order = options.obj("ObjectsOrder")?; + let order: Vec = order.entries.iter().flat_map(|(_, v)| v.iter().filter_map(V::num)).collect(); + if order.len() != members || order.iter().enumerate().any(|(i, n)| *n != i as f64) { + return None; + } + let data = options.obj("ConstraintArtData")?; + if data.entries.len() != members || data.items().count() != members || data.items().any(|d| !d.entries.is_empty() || !d.content.is_empty()) { + return None; + } + Some(Record { ids, pivot, rotate }) +} + +impl Reader<'_> { + /// The Objects on Path records of a `Constraint Data` array in the `/Document` dictionary `o`. + pub(super) fn constraint_data(&mut self, o: &Obj) { + let Some(list) = super::document::find_entry(o, "Constraint Data", 0) else { return }; + for d in list.items().take(MAX_RECORDS) { + if self.on_path.records.len() >= MAX_RECORDS { + return; + } + if let Some(r) = record(d) { + self.on_path.records.push(r); + } + } + } + + /// The `Persistent ID` an object's dictionary `o` gives the last object, with its part in an + /// Objects on Path group. + pub(super) fn constraint_art(&mut self, o: &Obj) { + let Some(uuid) = o.text("Persistent ID") else { return }; + let Some(id) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })).map(|n| n.id) else { return }; + if self.on_path.tags.len() >= MAX_TAGGED { + return; + } + let angle = o.text("BBAccumRotation").and_then(|s| s.trim().parse::().ok()).filter(|a| a.is_finite()); + let tag = Tag { key_path: num(o, "KeyPath") == Some(1.0), order: num(o, "ConstraintArtOrderId"), angle }; + self.on_path.tags.insert(id, tag); + self.on_path.ids.entry(uuid).or_default().push(id); + } +} + +/// Give each group that a record of `found` names exactly its [`ObjectsOnPath`] record. +pub(super) fn apply(doc: &mut Document, found: &OnPath) { + if found.records.is_empty() { + return; + } + // Where every object with an id is, found in one walk of the tree (setting a record doesn't + // move anything). + let at = doc.index_paths(found.tags.keys().copied()); + // The groups whose path and objects have been measured. + let mut tried = HashSet::new(); + for r in &found.records { + // A record that doesn't match leaves the group as it was read. + let _ = apply_one(doc, found, &at, &mut tried, r); + } +} + +fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap>, tried: &mut HashSet, r: &Record) -> Option<()> { + // The one object still in the document with the id `uuid` (a commented copy may have gone). + let resolve = |uuid: &str| -> Option<(NodeId, &Vec)> { + let mut live = found.ids.get(uuid)?.iter().filter_map(|id| at.get(id).map(|p| (*id, p))); + let one = live.next()?; + live.next().is_none().then_some(one) + }; + let (first, rest) = r.ids.split_first()?; + let (path, path_at) = resolve(first)?; + if !found.tags.get(&path)?.key_path { + return None; + } + let (_, group_at) = path_at.split_last()?; + let g = doc.node_at(group_at)?; + let NodeKind::Group { children, clip: false } = &g.kind else { return None }; + if g.extras().objects_on_path.is_some() || children.len() != r.ids.len() { + return None; + } + let mut seen = HashSet::from([path]); + let mut placed = Vec::with_capacity(rest.len()); + for (i, uuid) in rest.iter().enumerate() { + let (id, place) = resolve(uuid)?; + let tag = found.tags.get(&id)?; + if !seen.insert(id) || place.split_last().map(|(_, up)| up) != Some(group_at) || tag.key_path || tag.order != Some(i as f64) { + return None; + } + placed.push((id, place, -tag.angle?)); + } + if children.iter().any(|c| !seen.contains(&c.id)) { + return None; + } + // What is left depends on the group's objects alone, so each group is measured once. + if !tried.insert(g.id) { + return None; + } + attachable_path(children.iter().find(|c| c.id == path)?)?; + let mut members = Vec::with_capacity(placed.len()); + for (id, place, angle) in placed { + // The frame the object is drawn in: turned by its angle (the document's y points down) + // about the center of its bounds. + let center = doc.node_at(place)?.geometric_bounds()?.center(); + members.push(Member { id, frame: Affine::rotate_about(angle, center), angle }); + } + let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate, spacing: None, start: 0.0 }; + doc.node_mut_at(group_at)?.extras_mut().objects_on_path = Some(Box::new(on_path)); + Some(()) +} diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9431061fe..e8ff52f6c 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1,9 +1,9 @@ use super::containers::close; use super::document::slot_of; -use super::types::{BLENDS, FLIP, Frame, IGNORED, Kind, MAX_DEPTH}; +use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, Place, line_width}; use super::{ - Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, HashMap, HashSet, Instance, Knockout, - LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint, + Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, Graph, HashMap, HashSet, Instance, + Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint, }; impl<'a> Reader<'a> { @@ -48,6 +48,31 @@ impl<'a> Reader<'a> { self.open(Kind::Mask, hidden) } + /// `LB`: a graph started in this layer that hasn't ended doesn't go on past it; the layer ends. + fn layer_end(&mut self) -> Result<(), String> { + if self.graph.as_ref().is_some_and(|g| g.frames >= self.frames.len()) { + self.graph = None; + self.warn(GRAPH_UNENDED); + } + self.end_layer_in_mask() + } + + /// A placed file's `` ` ``: its matrix, box and path; a second one, or operands of another + /// shape, leave it unreadable. + fn place_tick(&mut self, vals: &[V], hidden: bool) { + let unseen = hidden || self.gs.hidden || !self.shown(); + let p = self.place.get_or_insert_with(|| Place { hidden: unseen, ..Place::default() }); + if p.ticked { + p.broken = true; + } else { + p.ticked = true; + match tick(vals) { + Some((m, b, path)) => (p.matrix, p.bounds, p.path) = (Some(m), Some(b), path), + None => p.broken = true, + } + } + } + pub(super) fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { if self.blend_record.is_some() { self.end_blend(); @@ -57,6 +82,14 @@ impl<'a> Reader<'a> { // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. "--" => return self.push(V::Num(f64::NAN)), "[" => { + // Drop marks that operators have taken off, so `marks` is the open nesting. + while self.marks.last().is_some_and(|&m| !matches!(self.stack.get(m), Some(V::Mark))) { + self.marks.pop(); + } + // Arrays nest as deep as their `[`s; dropping a deeper one would overflow the stack. + if self.marks.len() >= MAX_DEPTH { + return Err("its objects are nested too deeply".into()); + } self.marks.push(self.stack.len()); return self.push(V::Mark); } @@ -72,19 +105,18 @@ impl<'a> Reader<'a> { "X+" => return Ok(()), _ => {} } - // What follows an object (its name, transparency and style) ends with anything else. The - // state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the `LB`. - if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { - self.after_object = false; - self.layer_state = None; - } + self.state_block_continues(w); let group_ended = std::mem::take(&mut self.group_ended); + let mesh_ended = std::mem::take(&mut self.mesh_ended); let vals = self.operands(); let vals = self.take_head(vals); + if self.graph_record(w, hidden) { + return Ok(()); + } // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so // the operator is one the reader doesn't know, as before. if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { - self.note_unknown("--"); + self.note_unknown("--", hidden); return Ok(()); } if let Some(d) = self.gradient_def.as_mut() { @@ -129,24 +161,34 @@ impl<'a> Reader<'a> { self.end_path_quietly(); self.open(Kind::Compound, hidden)? } + // A wraparound group takes no operands and isn't an element of a compound path. + "*w" | "*W" if !vals.is_empty() => return Err(format!("`{w}` takes no operands")), + "*w" if self.frames.iter().any(|f| matches!(f.kind, Kind::Compound)) => { + return Err("a wraparound group is inside a compound path".into()); + } + "*w" => { + self.end_path_quietly(); + self.open(Kind::Wrap, hidden)? + } "q" => { self.end_path_quietly(); self.open(Kind::Clip, hidden)? } - "U" | "*U" | "Q" => self.end_container(w, hidden)?, + "U" | "*U" | "*W" | "Q" => self.end_container(w, hidden)?, "Lb" => self.layer_attrs(&vals), "Ln" => { if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); } } - "LB" => self.end_layer()?, + "LB" => self.layer_end()?, // Paths. "m" | "l" | "L" | "c" | "C" | "v" | "V" | "y" | "Y" => self.path_op(w, &vals)?, "h" => close(&mut self.path), // Ends a path that stays open. "H" => {} "W" => self.clip_next = true, + "Mb" | "Mv" | "Md" | "ME" | "MB" | "Mi" => self.multi_mask_op(w)?, // A text object's frame or path. "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(), "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?, @@ -162,6 +204,13 @@ impl<'a> Reader<'a> { "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, + // Show Center (§12): 1 shows the center point of the paths that follow, and 0 leaves + // them at their default, which hides it on an imported path. + "Ap" => match Self::nums(&vals).last().copied() { + Some(1.0) => self.gs.show_center = Some(true), + Some(0.0) => self.gs.show_center = None, + _ => {} + }, "w" => { if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) { self.gs.width = *v; @@ -201,7 +250,7 @@ impl<'a> Reader<'a> { } } // Transparency and state. - "Xy" => self.transparency(&vals), + "Xy" => self.transparency(&vals, mesh_ended), // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. "Xd" if group_ended => { @@ -212,7 +261,13 @@ impl<'a> Reader<'a> { } "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => self.style_marker(&vals, hidden), + "XW" => { + // An object's own state block ends `6 () XW`: an `Xy` after a mesh's end was the mesh's. + if Self::nums(&vals).last() == Some(&6.0) && self.mesh_undo.take().is_some() { + self.layer_state = None; + } + self.style_marker(&vals, hidden) + } // Gradients. "Bd" => self.gradient_def = GradientDef::begin(&vals), "Bs" | "BD" => {} @@ -257,16 +312,10 @@ impl<'a> Reader<'a> { } } // Placed files. - "`" if self.place.is_some() => { - let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }); - let path = vals.iter().rev().find_map(V::text).unwrap_or_default(); - if let Some(p) = self.place.as_mut() { - p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new); - p.bounds = - Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1)); - p.path = path; - } - } + // `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in + // newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art, + // the setup's procedures run. + "`" if self.place.is_some() || !self.frames.is_empty() => self.place_tick(&vals, hidden), "~" if self.place.is_some() => self.end_place(hidden)?, // Type in the legacy format. "To" => self.begin_text(&vals, hidden), @@ -274,15 +323,13 @@ impl<'a> Reader<'a> { "TP" => self.end_text_path(), "TO" => self.end_text(hidden)?, "Tx" | "Tj" | "TX" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), - // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, - // Far-Eastern type) and final-form positions (§15.3). - "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" - | "Tu" | "Xu" => {} - // Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th` - // hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe` - // tab descriptions between them). - "Th" | "XL" | "Xb" | "XB" | "Xe" => {} + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" => self.text_attr(w, &vals), + // Attributes the reader doesn't keep (kerning pairs, Far-Eastern type) and final-form + // positions (§15.3). + "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" | "Tu" | "Xu" => {} + // The hyphenation language and tab rulers, which the reader doesn't keep either (§22): `XL`, + // `Xb`/`XB` a tab definition (and the `Xe` tab descriptions between them). + "XL" | "Xb" | "XB" | "Xe" => {} "TE" => self.te(&vals), // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). "XI" if self.raster.is_some() => { @@ -296,18 +343,50 @@ impl<'a> Reader<'a> { let len = Self::nums(&vals).last().copied().unwrap_or(0.0); if name == crate::import::ai::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= crate::import::ai::blend::MAX_RECORD as f64 { self.blend_record = Some((len as usize, vec![])); + } else if name == crate::import::ai::envelope::PLUGIN { + self.end_envelope(); } } _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} - _ => self.note_unknown(w), + _ => self.note_unknown(w, hidden), } Ok(()) } - /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place - /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). + /// A graph's records (version 7 specification §17 and appendix A): `%_Gs` starts the graph on + /// a layer, `GS` ends it, and its data and settings (the operators in [`GRAPH_OPS`], and any + /// operator on a `%_` line between `Gt` and `GT`) are left out with their operands. Its drawn + /// art, and anything else, is read as usual. `true` when `w` was one of them. + fn graph_record(&mut self, w: &str, hidden: bool) -> bool { + let Some(g) = self.graph.as_mut() else { + // Outside a graph, its operators stay ones the reader doesn't know. + if w == "Gs" && hidden && !self.frames.is_empty() { + self.graph = Some(Graph { settings: false, frames: self.frames.len() }); + self.warn(GRAPH_PLAIN); + return true; + } + return false; + }; + match w { + // A graph doesn't start inside another: the one being read goes on. + "Gs" => self.warn(GRAPH_UNENDED), + "GS" => self.graph = None, + "Gt" => g.settings = true, + "GT" => g.settings = false, + _ if GRAPH_OPS.contains(&w) => {} + _ if hidden && g.settings => {} + _ => return false, + } + true + } + + /// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index + /// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve + /// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after + /// the colour index comes the template flag (eleven operands or more), then the dimming + /// percentage (twelve or more, else 50). A fourteenth operand changes nothing here. fn layer_attrs(&mut self, vals: &[V]) { let n = Self::nums(vals); let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; @@ -316,9 +395,10 @@ impl<'a> Reader<'a> { a.preview = flag(1, true); a.locked = !flag(2, true); a.printable = flag(3, true); - let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); + let at = 5 + usize::from(n.len() >= 10) + usize::from(n.len() >= 13); + a.template = flag(at + 4, false); + let dim_pct = n.get(at + 5).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let at = if n.len() <= 10 { 6 } else { 7 }; let index = n.get(at).copied().unwrap_or(0.0); let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { @@ -330,7 +410,8 @@ impl<'a> Reader<'a> { /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't - /// read can't be drawn. + /// read can't be drawn. For a pattern defined in the version 7 form, the other operands are + /// applied in order before the initial matrix ([`pattern_controls`]). fn pattern_paint(&mut self, w: &str, vals: &[V]) { // In the setup it paints nothing (and the art's space isn't fixed yet). if self.frames.is_empty() { @@ -338,22 +419,47 @@ impl<'a> Reader<'a> { } let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { - self.unreadable("pattern fills"); + self.unread_pattern(w); return; }; - let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + let mut matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + if name.as_ref().is_some_and(|n| self.v7_patterns.contains(n)) { + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + let Some(controls) = pattern_controls(&controls) else { + self.unread_pattern(w); + return; + }; + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * controls); + } let Some(xf) = pattern_xf(self.space(), matrix, tile) else { - self.unreadable("pattern fills"); + self.unread_pattern(w); return; }; let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; if w == "p" { + self.pending_unread_fill = false; self.gs.fill = p; } else { + self.pending_unread_stroke = false; self.gs.stroke = p; } } + fn unread_pattern(&mut self, w: &str) { + if self.shown() { + if w == "p" { + self.pending_unread_fill = true; + } else { + self.pending_unread_stroke = true; + } + } + // In a pattern's definition, only art painted with it makes the tile unreadable (see `paint`). + if self.pattern.is_none() && (!self.shown() || self.gs.hidden) { + self.hidden_unread.insert("pattern fills".into()); + } + } + fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; // Registration (`Xs`, `Xz`) says nothing of the document's colour model. @@ -371,9 +477,11 @@ impl<'a> Reader<'a> { if w.ends_with(|c: char| c.is_ascii_lowercase()) { self.gs.fill = p; self.pending_fill = None; + self.pending_unread_fill = false; } else { self.gs.stroke = p; self.pending_stroke = None; + self.pending_unread_stroke = false; } } @@ -388,8 +496,26 @@ impl<'a> Reader<'a> { }); } - /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. - fn transparency(&mut self, vals: &[V]) { + /// Operator `w` ends what follows an object (its name, transparency and style) unless it is part + /// of a state block. + /// The state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the + /// `LB`, and can have the stroke style between its `Xy` and `Xd`: that keeps the layer's state, + /// and what a mesh or blend before it had (`mesh_undo`). + fn state_block_continues(&mut self, w: &str) { + if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { + return; + } + self.after_object = false; + if !matches!(w, "J" | "j" | "w" | "M" | "d" | "XR") { + self.layer_state = None; + self.mesh_undo = None; + } + } + + /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right + /// after a gradient mesh's `/End X!` or a blend's record (before their `Xd` and `6 () XW`), + /// theirs, unless the layer's `LB` follows: then it is the layer's (see `end_layer`). + fn transparency(&mut self, vals: &[V], mesh_ended: bool) { let n = Self::nums(vals); let [mode, opacity, rest @ ..] = n.as_slice() else { return }; let gs = &mut self.gs; @@ -403,7 +529,16 @@ impl<'a> Reader<'a> { }; gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { + let in_layer = matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })); + if let Some(n) = + self.last_object().filter(|n| (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) || matches!(n.kind, NodeKind::Blend { .. })) + { + // The mesh's or blend's, or the layer's when it is the layer's last object: kept for either. + let before = (n.opacity, n.blend, n.isolate, n.knockout); + (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + self.mesh_undo = Some(before); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + } else if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { if let Some(n) = self.last_object() { n.opacity = gs.opacity; n.blend = gs.blend; @@ -415,9 +550,30 @@ impl<'a> Reader<'a> { // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until // an operator that isn't part of the state block (or the `LB` that takes it) says the // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = - matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); } + self.note_state(); + } + + /// Inside a dictionary in the art (a text's frame, a mask's art), the transparency just set + /// stays after the dictionary: the writer goes on from it, so the objects after an area text + /// whose frame sets one have no `Xy` of their own. + fn note_state(&mut self) { + let gs = &self.gs; + let state = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); + if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))) + && let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Obj(_))) + { + f.state = Some(state); + } + } + + /// A dictionary's art set transparency `state` (see [`Self::note_state`]): it is the current + /// one now, and the enclosing dictionary's. + pub(super) fn keep_state(&mut self, state: (f32, BlendMode, bool, Knockout, bool)) { + let gs = &mut self.gs; + (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape) = state; + self.note_state(); } /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its @@ -427,6 +583,10 @@ impl<'a> Reader<'a> { let code = Self::nums(vals).last().copied().unwrap_or(0.0); let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); + // An object's own state block ends `6 () XW` (a layer's `7 () XW`): an `Xy` before it isn't the layer's. + if code == 6.0 { + self.layer_state = None; + } if code == 9.0 && let Some(look) = self .frames @@ -454,11 +614,20 @@ impl<'a> Reader<'a> { return; } // A style read here: the object, painted as it says. - if let Some(appearance) = self - .styles - .get(&style) - .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) - { + if let Some(mut appearance) = self.styles.get(&style).and_then(|st| { + styled( + object, + look, + st, + &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, v7_patterns: &self.v7_patterns, space }, + ) + }) { + // Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up). + let k = space.determinant().abs().sqrt(); + if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 { + appearance.scale_strokes(k); + appearance.scale_effects(k, vectorcraft_effects::scale_effect); + } let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { @@ -469,6 +638,10 @@ impl<'a> Reader<'a> { (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); // Now the object itself, not a copy an enclosing look's `strip` would remove. self.hidden_ids.remove(&object.id); + // Linked to the style when the style list has its name ([`Reader::finish`]). + if self.style_links.len() < MAX_NODES { + self.style_links.push((object.id, style.clone())); + } f.children.push(object); return; } @@ -542,13 +715,50 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) } -/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. +/// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the +/// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line +/// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't +/// 0, the counterclockwise rotation by `angle` degrees, the scale, then the move to `x y`. `None` +/// unless there are nine finite numbers. +fn pattern_controls(n: &[f64]) -> Option { + let &[x, y, sx, sy, angle, reflect, axis, shear, shear_axis] = n else { return None }; + if !n.iter().all(|v| v.is_finite()) { + return None; + } + let turn = |deg: f64| Affine::rotate(deg.to_radians()); + let reflection = if reflect != 0.0 { turn(axis) * Affine::scale_non_uniform(1.0, -1.0) * turn(-axis) } else { Affine::IDENTITY }; + let shearing = if shear != 0.0 { + turn(shear_axis) * Affine::new([1.0, 0.0, shear.to_radians().tan(), 1.0, 0.0, 0.0]) * turn(-shear_axis) + } else { + Affine::IDENTITY + }; + let xf = Affine::translate((x, y)) * Affine::scale_non_uniform(sx, sy) * turn(angle) * reflection * shearing; + xf.as_coeffs().iter().all(|v| v.is_finite()).then_some(xf) +} + +/// What a style's fills paint with: the file's gradients and pattern tiles, the patterns defined in +/// the version 7 form, and art space → the document. struct StylePaints<'a> { gradients: &'a HashMap, patterns: &'a HashMap, + v7_patterns: &'a HashSet, space: Affine, } +/// A placed file's `` ` `` operands → its matrix, its box and its path: an array of six numbers, four +/// numbers, three more or none, and a string. `None` for any other shape or a number that isn't +/// finite. +fn tick(vals: &[V]) -> Option<(Affine, Rect, String)> { + let [V::Arr(m), rest @ .., V::Str(path)] = vals else { return None }; + let m = <[f64; 6]>::try_from(m.iter().map(V::num).collect::>>()?).ok()?; + let nums: Vec = rest.iter().map(V::num).collect::>()?; + let [x0, y0, x1, y1] = match nums.as_slice() { + [x0, y0, x1, y1] | [x0, y0, x1, y1, _, _, _] => [*x0, *y0, *x1, *y1], + _ => return None, + }; + m.iter().chain(&nums).all(|v| v.is_finite()).then(|| (Affine::new(m), Rect::new(x0, y0, x1, y1), obj::text(path))) +} + /// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name /// (the first of each; a pattern's name after `PATTERN_KEY`). fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { @@ -574,7 +784,8 @@ fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { const PATTERN_KEY: &str = "\u{1}pattern "; /// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one -/// this reads, or asks a group for fills or strokes. +/// this reads, asks a group for fills or strokes, or has Pathfinder effects on anything but a group +/// with no fills or strokes. fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, paints: &StylePaints) -> Option { use crate::import::ai::style::StyleItem; // A text object's slot (an empty group standing for it) takes the style as the type will. @@ -582,6 +793,8 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p if !st.plain || (!slot && !st.simple) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) + || (st.pathfinder + && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) { return None; } @@ -612,29 +825,22 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p }; AppearanceItem::Fill(FillLayer::new(paint)) } - StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{name}")) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), None => { let tile = paints.patterns.get(name).copied()?; - let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if paints.v7_patterns.contains(name) { + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); + } + let xf = pattern_xf(paints.space, matrix, tile)?; AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) } }, - StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => { - let mut layer = StrokeLayer::new(paint.clone(), *width); - (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); - AppearanceItem::Stroke(layer) - } + StyleItem::Stroke { .. } => stroke_layer(item)?, }; if let Some(look) = st.looks.get(n) { - match &mut layer { - AppearanceItem::Fill(f) => { - (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) - } - AppearanceItem::Stroke(s) => { - (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) - } - } + with_look(&mut layer, look); } items.push(layer); } @@ -646,6 +852,44 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p Some(Appearance { items, effects: st.effects.clone(), contents_index }) } +/// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one +/// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them). +pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option { + use crate::import::ai::style::StyleItem; + if !st.plain { + return None; + } + let mut items = vec![]; + for (n, item) in st.items.iter().enumerate() { + let mut layer = match item { + StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), + StyleItem::Stroke { .. } => stroke_layer(item)?, + StyleItem::Gradient { .. } | StyleItem::Pattern { .. } => return None, + }; + if let Some(look) = st.looks.get(n) { + with_look(&mut layer, look); + } + items.push(layer); + } + Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index }) +} + +/// A style's stroke as an appearance item (`None` for a fill). +fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { + let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } = item else { return None }; + let mut layer = StrokeLayer::new(paint.clone(), line_width(*width)); + (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); + Some(AppearanceItem::Stroke(layer)) +} + +/// Give `layer` a fill's or stroke's own visibility, transparency and effects. +fn with_look(layer: &mut AppearanceItem, look: &crate::import::ai::style::ItemLook) { + match layer { + AppearanceItem::Fill(f) => (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()), + AppearanceItem::Stroke(s) => (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()), + } +} + /// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). fn strip(n: &mut Node, hidden: &HashSet) { if let Some(children) = n.children_mut() { diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 95423c94e..39b77cf74 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -1,8 +1,11 @@ -use super::{Affine, BlendMode, Dash, FillRule, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V}; +use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V}; /// Deepest nesting of layers, groups and dictionaries. pub(super) const MAX_DEPTH: usize = 256; +/// Most nested pattern and symbol readers on one call stack. +pub(super) const MAX_SUBREADERS: usize = 32; + /// Most operands on the stack. pub(super) const MAX_STACK: usize = 1 << 20; @@ -22,7 +25,7 @@ pub(super) const MAX_PIXELS: u64 = 1 << 25; pub(super) const MAX_PLACED_ELSEWHERE: usize = 4096; /// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads -/// larger images (#1479). +/// larger images (#1479), unless that part can't be read either; then the image is left out. pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; /// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). @@ -37,6 +40,9 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); /// Most patterns read (others can't fill art). pub(super) const MAX_PATTERNS: usize = 4096; +/// Most characters of a path's note read (the version 7 specification allows 254, §5.3.1). +pub(super) const MAX_NOTE: usize = 1024; + #[derive(Clone, Debug)] pub(crate) struct GState { pub(crate) fill: Paint, @@ -56,6 +62,11 @@ pub(crate) struct GState { pub(crate) overprint_stroke: bool, pub(super) locked: bool, pub(super) hidden: bool, + /// The note (`%AI3_Note:`) the paths that follow get, until another replaces it (`None`: no + /// note comment yet; empty: none). + pub(super) note: Option, + /// Show Center (`1 Ap`) for the paths that follow (`None`: the path's default). + pub(super) show_center: Option, } impl Default for GState { @@ -79,10 +90,24 @@ impl Default for GState { overprint_stroke: false, locked: false, hidden: false, + note: None, + show_center: None, } } } +impl GState { + /// The width its stroke draws at (see [`line_width`]). + pub(crate) fn line_width(&self) -> f64 { + line_width(self.width) + } +} + +/// The width a stroke of `w w` draws at: `0 w` is the thinnest line a device draws, a [`HAIRLINE`]. +pub(super) fn line_width(w: f64) -> f64 { + if w > 0.0 { w } else { HAIRLINE } +} + /// The blend modes by their number in `Xy`. pub(super) const BLENDS: [BlendMode; 16] = [ BlendMode::Normal, @@ -120,6 +145,7 @@ pub(super) struct LayerAttrs { pub(super) preview: bool, pub(super) locked: bool, pub(super) printable: bool, + pub(super) template: bool, pub(super) dim: Option, pub(super) color: Option, } @@ -128,6 +154,9 @@ pub(super) struct LayerAttrs { pub(super) enum Kind { Layer(Box), Group, + /// Wraparound text (`*w` … `*W`, version 7 specification §15.4.2): a group whose objects other + /// than its type are wrap objects. + Wrap, Compound, Clip, Mask, @@ -142,15 +171,39 @@ pub(super) struct Frame { pub(super) hidden: bool, /// [`Reader::after_object`] when it opened. pub(super) after: bool, + /// [`Reader::mesh_ended`] when it opened. + pub(super) mesh_ended: bool, /// The state when it opened (a container's lock and visibility; a dictionary's state to go /// back to). pub(super) gs: GState, + /// Unresolved pattern paint saved by `q`, which restores graphics state at `Q`. + pub(super) pending_unread_fill: bool, + pub(super) pending_unread_stroke: bool, /// The operand stack's height when it opened. pub(super) base: usize, /// The clipping paths' indexes among the children. pub(super) clips: Vec, /// A compound member group explicitly declares its subpaths' winding with `D`. pub(super) compound_members: bool, + /// Unread kinds belonging to this top-level object. + pub(super) unread: Vec, + /// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it. + pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>, +} + +/// An envelope whose dictionary was read, waiting for its drawn art and its plugin's `XP` (see +/// [`super::super::envelope`]). +#[derive(Debug)] +pub(super) struct PendingEnvelope { + pub(super) obj: Obj, + /// The group on `%_` lines before the dictionary. + pub(super) before: Option, + /// The art in the dictionary's `AIDeformArtKey` block. + pub(super) inner: Vec, + /// How many frames were open when the dictionary closed, and how many children the innermost + /// had: its drawn art is the one child after them. + pub(super) depth: usize, + pub(super) siblings: usize, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands @@ -184,8 +237,23 @@ pub(super) struct Place { pub(super) path: String, /// The preview as a PNG, and its size in pixels. pub(super) preview: Option<(Vec, u32, u32)>, + /// The file's folder relative to the document's (`src/`), from the string between + /// `%AI28_BeginPlacedRelativePath` and its end. + pub(super) folder: Option, + /// The operand stack's height at `%AI28_BeginPlacedRelativePath`, until its end. + pub(super) folder_at: Option, + /// Its `` ` `` was read. + pub(super) ticked: bool, + /// It began on a `%_` line, in a hidden object or on a layer that doesn't show. + pub(super) hidden: bool, + /// What it holds can't be read: operands of another shape, a second `` ` ``, or a document + /// included in it that doesn't end. + pub(super) broken: bool, } +/// The longest relative folder of a placed file read (bytes). +pub(super) const MAX_PLACED_FOLDER: usize = 4096; + /// What the open result says of placed files: they show the preview the file keeps of them. pub(super) const LINKED_PREVIEWS: &str = "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel"; @@ -194,7 +262,6 @@ pub(super) const LINKED_PREVIEWS: &str = pub(super) const IGNORED: &[&str] = &[ "Ae", "AE", - "Ap", "As", "Xd", "Xr", @@ -218,7 +285,6 @@ pub(super) const IGNORED: &[&str] = &[ "XD", "Xi", "XI", - "`", "LB2", "Lc", "Xv", @@ -246,6 +312,9 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginSymbolList", "EndSymbolList"), ("BeginTextDocument", "EndTextDocument"), ("BeginEncoding", "EndEncoding"), + // The printer's crop marks, drawn after the layers (`userdict /AI3_noCropMarks known not { + // … } if`); the crop area they mark is `%AI3_Cropmarks`. + ("BeginCrops", "EndCrops"), ]; /// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every @@ -258,3 +327,28 @@ pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) /// The name of the group a `/ForeignObject`'s art comes in as. pub const NON_NATIVE_ART: &str = "Non-native art"; + +/// The note for a file with a graph (`%_Gs` … `%_GS`): its drawn art comes in, its data and +/// settings don't. +pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data or settings"; + +/// The note for a graph that starts inside another or doesn't end before its layer does: the art +/// read so far is kept, and what follows the layer is read as usual. +pub const GRAPH_UNENDED: &str = "a graph's records don't end where they should: its art was read as it is"; + +/// A graph's records (the version 7 specification's chapter 17 and appendix A): two-letter +/// operators starting with `G`, read and left out with their operands. +pub(super) const GRAPH_OPS: &[&str] = &[ + "G1", "G+", "Ga", "GA", "Gb", "Gc", "GC", "Gd", "GD", "Ge", "Gf", "Gg", "Gi", "Gj", "Gl", "Gm", "Go", "Gp", "Gr", "Gs", "GS", "Gt", "GT", "Gv", + "Gw", "GX", "Gx", "Gy", "Gz", +]; + +/// A graph being read (`%_Gs` … `%_GS`). +#[derive(Debug)] +pub(super) struct Graph { + /// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_` + /// lines too. + pub(super) settings: bool, + /// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph. + pub(super) frames: usize, +} diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 57d7842f2..e1947bfda 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -35,26 +35,42 @@ //! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills //! with a pattern or gradient too), each with its own transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and -//! effects; others keep their drawn look. +//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the +//! object's chain) are read on a group with no fills or strokes of its own, which keeps its members. use std::collections::HashMap; use serde_json::json; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile}; +use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile}; +use vectorcraft_effects::is_pathfinder; use super::lex::{Lexer, Tok}; use super::obj::V; use super::paint; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files -const FEATHER: &str = "Adobe Feather"; // brand-ok: the filter's name in the files +const INNER_GLOW: &str = "Adobe Inner Glow"; // brand-ok: the filter's name in the files +const FEATHER: &str = "Adobe Fuzzy Mask"; // brand-ok: the filter's name in the files const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files +const ROUND_CORNERS: &str = "Adobe Round Corners"; // brand-ok: the filter's name in the files +const SCRIBBLE: &str = "Adobe Scribble Fill"; // brand-ok: the filter's name in the files +const EXPAND_VERSION: &str = "Adobe Effect Expand Before Version"; // brand-ok: the key's name in the files const DEFORM: &str = "Adobe Deform"; // brand-ok: the filter's name in the files const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the files +/// Effect › Path › Outline Object. +const OUTLINE_TYPE: &str = "Adobe Outline Type"; // brand-ok: the filter's name in the files +const OUTLINE_STROKE: &str = "Adobe Outline Stroke"; // brand-ok: the filter's name in the files const TRANSFORM: &str = "Adobe Transform"; // brand-ok: the filter's name in the files +const FREE_DISTORT: &str = "Adobe Free Distort"; // brand-ok: the filter's name in the files +const PUCKER_BLOAT: &str = "Adobe Punk and Bloat"; // brand-ok: the filter's name in the files +const TWEAK: &str = "Adobe Scribble and Tweak"; // brand-ok: the filter's name in the files +const TWIST: &str = "Adobe Twirl"; // brand-ok: the filter's name in the files +const ZIG_ZAG: &str = "Adobe Zigzag"; // brand-ok: the filter's name in the files const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; +const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files +const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files const CHAIN: &str = "Chain Style Filter"; const STACK: &str = "Stack Style Filter"; @@ -82,14 +98,41 @@ const WARPS: [&str; 15] = [ "warp.twist", ]; +/// The warp style (`arc`, `arcLower`, …) numbered `n` in `DeformStyle`. +pub(super) fn warp_style(n: i64) -> Option<&'static str> { + let id = WARPS.get(usize::try_from(n.checked_sub(1)?).ok()?)?; + id.strip_prefix("warp.") +} + +/// The keys of an Effect › Pathfinder › Add filter's dictionary besides `DisplayString`, each with +/// the kind and the one value read: `Command` 0 is Add, and the rest are its Pathfinder Options, +/// Soft Mix and Trap settings +/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137). +const PATHFINDER_ADD: [(&str, Kind, f64); 13] = [ + ("Command", Kind::Int, 0.0), + ("ConvertCustom", Kind::Bool, 1.0), + ("ExtractUnpainted", Kind::Bool, 1.0), + ("Mix", Kind::Real, 0.5), + ("Precision", Kind::Real, 10.0), + ("RemovePoints", Kind::Bool, 0.0), + ("TrapAspect", Kind::Real, 1.0), + ("TrapConvertCustom", Kind::Bool, 0.0), + ("TrapMaxTint", Kind::Real, 1.0), + ("TrapReverse", Kind::Bool, 0.0), + ("TrapThickness", Kind::Real, 0.25), + ("TrapTint", Kind::Real, 0.4), + ("TrapTintTolerance", Kind::Real, 0.05), +]; + /// A plain style's paint, as the object comes in. #[derive(Clone, Debug, Default, PartialEq)] pub(super) struct ArtStyle { /// The style reads as [`Self::items`] and [`Self::effects`]. pub plain: bool, /// It is one fill and one stroke at most, in solid colours or the object's own gradient, with - /// no transparency or effects of their own, and only Outer Glow, Feather and Drop Shadow on the - /// object: what any object is read with. Type takes the other plain styles too (its drawn look + /// no transparency or effects of their own, and only Drop Shadow, Inner Glow, Outer Glow, + /// Feather, Round Corners, Scribble, Convert to Shape and the Path effects (Offset Path, Outline Object, Outline Stroke) on the object + /// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. pub simple: bool, /// Its fills and strokes, bottom first. @@ -100,6 +143,11 @@ pub(super) struct ArtStyle { pub effects: Vec, /// How many of [`Self::items`] paint below the object's contents (`None`: none do). pub contents_index: Option, + /// The object's own opacity, blend mode, isolation and knockout (its blend filter), when the + /// style has them. + pub transparency: Option<(f32, BlendMode, bool, Knockout)>, + /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. + pub pathfinder: bool, } #[derive(Clone, Debug, PartialEq)] @@ -111,10 +159,12 @@ pub(super) enum StyleItem { name: Option, angle: f64, }, - /// A fill with pattern swatch `name`, its space taken to art space by `matrix`. + /// A fill with pattern swatch `name`, its space taken to art space by `matrix`; `controls`, the + /// numbers before the matrix, apply first when the pattern was defined in the version 7 form. Pattern { name: String, matrix: Option<[f64; 6]>, + controls: Vec, }, Stroke { paint: Paint, @@ -142,6 +192,17 @@ impl Default for ItemLook { } } +/// How a filter's dictionary writes an entry: `value /Int (key) ,`, `/Real`, `/Bool`, a fill's +/// style (`/FillStyle : … /Def ; (key) ,`), or anything else. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Int, + Real, + Bool, + Fill, + Other, +} + #[derive(Debug)] struct Filter { name: String, @@ -150,6 +211,10 @@ struct Filter { parent: Option, compound: bool, params: HashMap, + /// How each key of its dictionary is written. + kinds: HashMap, + /// A key of its dictionary is written more than once. + repeated: bool, /// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key. arrays: HashMap>, fill: Option, @@ -164,10 +229,12 @@ struct Filter { gradient: bool, /// The gradient's swatch and angle (`Bg`). gradient_of: Option<(Option, f64)>, - /// `(name) … [matrix] p`. - pattern: Option<(String, Option<[f64; 6]>)>, + /// `(name) … [matrix] p`, and the numbers between the name and the matrix. + pattern: Option<(String, Option<[f64; 6]>, Vec)>, /// `mode opacity … Xy`. xy: Option<(usize, f32)>, + /// `… isolate knockout … Xy`. + xy_group: Option<(bool, Knockout)>, } impl Default for Filter { @@ -178,6 +245,8 @@ impl Default for Filter { parent: None, compound: false, params: HashMap::new(), + kinds: HashMap::new(), + repeated: false, arrays: HashMap::new(), fill: None, stroke: None, @@ -191,6 +260,7 @@ impl Default for Filter { gradient_of: None, pattern: None, xy: None, + xy_group: None, } } } @@ -198,6 +268,55 @@ impl Default for Filter { /// Most filters read in one style. const MAX_FILTERS: usize = 4096; +/// Most entries read from a style list. +const MAX_LISTED: usize = 4096; +/// The longest style name read from a style list, in bytes. +const MAX_LISTED_NAME: usize = 1024; + +/// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style, +/// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens, +/// a string the data ends inside and names past [`MAX_LISTED_NAME`] are left out. +pub(super) fn art_style_list(data: &[u8]) -> Vec> { + let mut names = vec![]; + let mut lex = Lexer::new(data); + loop { + let before = lex.offset(); + if names.len() >= MAX_LISTED { + break; + } + let Some(t) = lex.next_token() else { break }; + // The lexer stops a string at its closing `)` or at the end of the data. + let closed = lex.offset() < data.len() || data.get(before..).is_some_and(closes); + if let Tok::Str(s) = t.tok + && closed + && s.len() <= MAX_LISTED_NAME + { + names.push(s); + } + } + names +} + +/// Does the first literal string in `data` end with its own `)` (not an escaped one or one that +/// closes a nested pair)? +fn closes(data: &[u8]) -> bool { + let Some(start) = data.iter().position(|b| *b == b'(') else { return false }; + let mut depth = 0usize; + let mut bytes = data.iter().skip(start + 1); + while let Some(b) = bytes.next() { + match b { + b'\\' => { + bytes.next(); + } + b'(' => depth += 1, + b')' if depth == 0 => return true, + b')' => depth -= 1, + _ => {} + } + } + false +} + /// The styles of the section `data` (between its markers), by name. pub(super) fn art_styles(data: &[u8]) -> HashMap { let mut styles = HashMap::new(); @@ -217,6 +336,10 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { }; let mut array: Option> = None; let mut dict_array: Option> = None; + // A paint style being read as a dictionary entry: its key follows its `/Def ;`. + let mut style_entry: Option = None; + // A name of a basic filter's that may be an entry's type, with the string that follows it. + let mut other_entry: Option> = None; let mut lex = Lexer::new(data); while let Some(t) = lex.next_token() { if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) { @@ -225,14 +348,21 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } match t.tok { Tok::Num(v) => operands.push(V::Num(v)), - Tok::Str(s) | Tok::Hex(s) => operands.push(V::Str(s)), + Tok::Str(s) | Tok::Hex(s) => { + if let Some(key @ None) = other_entry.as_mut() { + *key = Some(String::from_utf8_lossy(&s).into_owned()); + } + operands.push(V::Str(s)); + } Tok::Name(n) => match n { b"KnownStyle" => { finish(name.take(), &mut filters); operands.clear(); open.clear(); - (pending, in_basic) = (None, false); + (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } + b"FillStyle" => style_entry = Some(Kind::Fill), + b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text), b"CompoundFilter" => pending = Some(true), b"BasicFilter" => pending = Some(false), @@ -259,23 +389,38 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } b"Array" => dict_array = Some(vec![]), b"Int" | b"Real" | b"Bool" => { - let value = operands.iter().rev().find_map(V::num); + // The number right before the type: an earlier one isn't this value. + let value = operands.last().and_then(V::num); operands.clear(); if let Some(a) = dict_array.as_mut() { a.extend(value); continue; } + let kind = match n { + b"Int" => Kind::Int, + b"Real" => Kind::Real, + _ => Kind::Bool, + }; // The key follows: `value /Real (key) ,`. - if let (Some(value), Some(Tok::Str(key))) = (value, lex.next_token().map(|t| t.tok)) + if let Some(Tok::Str(key)) = lex.next_token().map(|t| t.tok) && let Some(f) = filters.last_mut() { - f.params.insert(String::from_utf8_lossy(&key).into_owned(), value); + let key = String::from_utf8_lossy(&key).into_owned(); + match value { + Some(value) => f.params.insert(key.clone(), value), + None => f.params.remove(&key), + }; + f.repeated |= f.kinds.insert(key, kind).is_some(); } continue; } + // Perhaps an entry of another type (`(text) /String (key) ,`): its key, if any, is + // the string before the `,`. + _ if in_basic => other_entry = Some(None), _ => {} }, Tok::Word(w) => { + let entry = other_entry.take().flatten(); let op = std::str::from_utf8(w).unwrap_or_default(); if op == "[" { array = Some(vec![]); @@ -298,6 +443,15 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { if op == ";" && !in_basic { open.pop(); } + if op == "," { + let key = match style_entry.take() { + Some(kind) => operands.iter().rev().find_map(V::text).map(|k| (k, kind)), + None => entry.map(|k| (k, Kind::Other)), + }; + if let (Some((key, kind)), Some(f)) = (key, filters.last_mut()) { + f.repeated |= f.kinds.insert(key, kind).is_some(); + } + } if let Some(f) = filters.last_mut() { paint_op(f, op, &operands); } @@ -306,7 +460,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } continue; } - Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) => {} + Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) | Tok::Document(_) => {} } if operands.len() > 64 { operands.clear(); @@ -364,11 +518,18 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { } "Xy" => { let n: Vec = vals.iter().filter_map(V::num).collect(); - if let [mode, opacity, ..] = n.as_slice() + if let [mode, opacity, rest @ ..] = n.as_slice() && mode.is_finite() && opacity.is_finite() { f.xy = Some((mode.max(0.0) as usize, opacity.clamp(0.0, 1.0) as f32)); + // As an object's own `Xy` reads them. + let knockout = match rest.get(1).map(|v| *v as i64) { + Some(1) => Knockout::On, + Some(2) => Knockout::Neutral, + _ => Knockout::Off, + }; + f.xy_group = Some((rest.first().is_some_and(|v| *v != 0.0), knockout)); } } // `flag (name) x y angle length a b c d tx ty Bg`. @@ -389,8 +550,10 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { } _ => None, }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); if let Some(name) = name { - f.pattern = Some((name, matrix)); + f.pattern = Some((name, matrix, controls)); } } _ => {} @@ -422,39 +585,413 @@ fn width_profile(arrays: &HashMap>) -> Option { (!uniform).then_some(WidthProfile { points }) } -/// The live effect filter `f` is, when it is one read here. -fn effect(f: &Filter) -> Option { +/// Entry `key` of `f`'s dictionary, written as `kind`: `Ok(None)` when the dictionary doesn't hold +/// it, `Err` when it holds it as another kind, without a finite number, or as an `/Int` that isn't +/// whole or a `/Bool` other than 0 and 1. +fn entry(f: &Filter, key: &str, kind: Kind) -> Result, ()> { + let Some(k) = f.kinds.get(key) else { return Ok(None) }; + match f.params.get(key) { + Some(v) + if *k == kind + && v.is_finite() + && match kind { + Kind::Real => true, + Kind::Int => v.fract() == 0.0, + Kind::Bool => *v == 0.0 || *v == 1.0, + Kind::Fill | Kind::Other => false, + } => + { + Ok(Some(*v)) + } + _ => Err(()), + } +} + +/// Entry `key` as a switch, 0 or 1 written as `kind`: `Ok(None)` when it is missing. +fn switch(f: &Filter, key: &str, kind: Kind) -> Result, ()> { + match entry(f, key, kind)? { + None => Ok(None), + Some(0.0) => Ok(Some(false)), + Some(1.0) => Ok(Some(true)), + Some(_) => Err(()), + } +} + +/// Real entry `key` within `range`, `missing` when the dictionary doesn't hold it. +fn real(f: &Filter, key: &str, missing: f64, range: std::ops::RangeInclusive) -> Option { + entry(f, key, Kind::Real).ok()?.map_or(Some(missing), |v| range.contains(&v).then_some(v)) +} + +/// What an effect in a style could change the paint of, for the Transform options whose value, +/// when the dictionary leaves it out, comes from the app's settings rather than the file. +#[derive(Clone, Copy, Debug)] +struct Under { + /// A stroke, or other effects with distances that Scale Strokes & Effects would scale. + strokes: bool, + /// A pattern. + patterns: bool, +} + +/// Anything: an effect on the whole object, whose contents can paint in any way. +const ANYTHING: Under = Under { strokes: true, patterns: true }; + +/// The Transform effect `f` (the factor and radian entries; the percent and degree ones are what +/// its dialog shows). `None` keeps the drawn look: an entry not read, Random (the file holds no +/// seed), or a missing Scale Strokes & Effects or Transform Patterns that would change `under`. +fn transform(f: &Filter, under: Under) -> Option { + for key in ["scaleH_Percent", "scaleV_Percent", "rotate_Degrees"] { + entry(f, key, Kind::Real).ok()?; + } + // The renderer's limits on scales and moves (`vectorcraft_effects`' `MAX_SCALE` and `MAX_MOVE`). + let scale = |key: &str| real(f, key, 1.0, -1000.0..=1000.0).map(|v| v * 100.0); + let (h, v) = (scale("scaleH_Factor")?, scale("scaleV_Factor")?); + let (move_h, move_v) = (real(f, "moveH_Pts", 0.0, -1e7..=1e7)?, real(f, "moveV_Pts", 0.0, -1e7..=1e7)?); + let rotate = Some(entry(f, "rotate_Radians", Kind::Real).ok()?.unwrap_or(0.0).to_degrees()).filter(|d| d.is_finite())?; + let copies = entry(f, "numCopies", Kind::Int).ok()?.unwrap_or(0.0); + let reference = entry(f, "pinPoint", Kind::Int).ok()?.unwrap_or(4.0); + if !(0.0..=1000.0).contains(&copies) || !(0.0..=8.0).contains(&reference) { + return None; + } + let on = |key: &str| switch(f, key, Kind::Bool); + let (reflect_x, reflect_y) = (on("reflectX").ok()?.unwrap_or(false), on("reflectY").ok()?.unwrap_or(false)); + let objects = on("transformObjects").ok()?.unwrap_or(true); + let (strokes, patterns) = (on("scaleLines").ok()?, on("transformPatterns").ok()?); + if on("randomize").ok()?.unwrap_or(false) { + return None; + } + let moves = h != 100.0 || v != 100.0 || move_h != 0.0 || move_v != 0.0 || rotate != 0.0 || reflect_x || reflect_y; + let scales = ((h * v).abs().sqrt() - 100.0).abs() > 1e-9; + if (strokes.is_none() && scales && under.strokes) || (patterns.is_none() && moves && under.patterns) { + return None; + } + // Art space has y up: a move up is a negative `moveV` in the document. + Some(json!({ + "scaleH": h, + "scaleV": v, + "moveH": move_h, + "moveV": 0.0 - move_v, + "rotate": rotate, + "copies": copies as i64, + "reflectX": reflect_x, + "reflectY": reflect_y, + "reference": reference as i64, + "objects": objects, + "patterns": patterns.unwrap_or(false), + "strokes": strokes.unwrap_or(false), + })) +} + +/// Free Distort's corners: the destination corners `dst0h`… measured in the source frame `src0h`… +/// (top left, top right, bottom left, bottom right, y up), as the catalog's unit-box corners (top +/// left, top right, bottom right, bottom left, y down). The source frame is only the unit the +/// corners are measured in: the distorted box is laid over the object's own bounds, as the catalog +/// effect lays it, also when the frame is another size (a unit square, or bounds with the stroke). +/// No corners is no change; some but not all, or a frame that isn't an upright rectangle, isn't read. +fn free_distort(f: &Filter) -> Option { + let mut corners = [[[0.0; 2]; 4]; 2]; + let mut held = 0; + for (which, c) in ["src", "dst"].into_iter().zip(&mut corners) { + for (i, corner) in c.iter_mut().enumerate() { + for (axis, v) in ["h", "v"].into_iter().zip(corner.iter_mut()) { + if let Some(x) = entry(f, &format!("{which}{i}{axis}"), Kind::Real).ok()? { + *v = x; + held += 1; + } + } + } + } + if held == 0 { + return Some(json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]})); + } + let [[tl, tr, bl, br], dst] = corners; + let (left, top) = (tl[0], tl[1]); + let (width, height) = (tr[0] - left, top - bl[1]); + let tolerance = 1e-5 * width.abs().max(height.abs()); + let upright = (tr[1] - top).abs() <= tolerance + && (br[1] - bl[1]).abs() <= tolerance + && (bl[0] - left).abs() <= tolerance + && (br[0] - tr[0]).abs() <= tolerance; + let extent = |d: f64| d.is_finite() && d > 0.0; + if held < 16 || !upright || !extent(width) || !extent(height) { + return None; + } + let mut out = vec![]; + for i in [0, 1, 3, 2] { + let [h, v] = *dst.get(i)?; + let (x, y) = ((h - left) / width, (top - v) / height); + if !(x.is_finite() && y.is_finite() && x.abs() <= 1e4 && y.abs() <= 1e4) { + return None; + } + out.push([x, y]); + } + Some(json!({ "corners": out })) +} + +/// A `0` relative, `1` absolute switch (`absoluteness`), `absolute` when it is missing, and the size +/// it picks: `relative_key` in percent or `absolute_key` in points, 0 when missing. +fn sized(f: &Filter, absolute: bool, relative_key: &str, absolute_key: &str) -> Option<(bool, f64)> { + let absolute = switch(f, "absoluteness", Kind::Real).ok()?.unwrap_or(absolute); + let relative = real(f, relative_key, 0.0, 0.0..=1e4)?; + let points = real(f, absolute_key, 0.0, 0.0..=1e4)?; + Some((absolute, if absolute { points } else { relative })) +} + +/// Corner (`roundness` 0, or missing) or Smooth (1). +fn points(f: &Filter) -> Option<&'static str> { + Some(if switch(f, "roundness", Kind::Real).ok()?.unwrap_or(false) { "smooth" } else { "corner" }) +} + +/// The Tweak effect `f` (`Adobe Scribble and Tweak`): relative unless `absoluteness` says otherwise, +/// moving anchors and both handles unless the dictionary turns them off. +fn tweak(f: &Filter) -> Option { + let (absolute, h) = sized(f, false, "horz", "ahor")?; + let (_, v) = sized(f, false, "vert", "aver")?; + let on = |key: &str| switch(f, key, Kind::Bool).map(|v| v.unwrap_or(true)); + let (anchors, ins, outs) = (on("anch").ok()?, on("in").ok()?, on("out").ok()?); + Some(json!({"h": h, "v": v, "relative": !absolute, "anchors": anchors, "in": ins, "out": outs})) +} + +/// The Zig Zag effect `f`: absolute unless `absoluteness` says otherwise, 4 ridges and Corner points +/// when they are missing. +fn zig_zag(f: &Filter) -> Option { + let (absolute, size) = sized(f, true, "relAmount", "amount")?; + let ridges = real(f, "ridges", 4.0, 0.0..=100.0).filter(|r| r.fract() == 0.0)?; + Some(json!({"size": size, "relative": !absolute, "ridges": ridges as i64, "points": points(f)?})) +} + +/// Whether Tweak params `p` leave the outline as it is: its random outline isn't in the file, so any +/// other keeps the drawn look. +fn still(p: &serde_json::Value) -> bool { + (p["h"].as_f64() == Some(0.0) && p["v"].as_f64() == Some(0.0)) || ["anchors", "in", "out"].iter().all(|k| p[k] == json!(false)) +} + +/// A Stylize effect's dictionary, read only as it is written: each setting a key of the kind it +/// names, with a finite value in the effect's range. +struct Settings<'a>(&'a Filter); + +impl Settings<'_> { + /// Every key is one of `known`, or one of `fixed` holding the value it names. + /// The choices (`blnd`, `csrc`, `gtyp`) are written as `/Int`, the colors as fill styles and + /// the other settings as `/Real`. + fn only(&self, known: &[&str], fixed: &[(&str, Kind, f64)]) -> bool { + let f = self.0; + let kind_of = |key: &str| match key { + "blnd" | "csrc" | "gtyp" => Kind::Int, + "sclr" | "gclr" => Kind::Fill, + _ => Kind::Real, + }; + f.arrays.is_empty() + && f.kinds.iter().all(|(key, kind)| { + (known.contains(&key.as_str()) && kind_of(key) == *kind) + || fixed.iter().any(|(k, want, v)| k == key && want == kind && f.params.get(key.as_str()) == Some(v)) + }) + } + + fn real(&self, key: &str, range: std::ops::RangeInclusive) -> Option { + (self.0.kinds.get(key) == Some(&Kind::Real)).then_some(())?; + self.0.params.get(key).copied().filter(|v| v.is_finite() && range.contains(v)) + } + + /// A choice numbered from 0 to `max`. + fn choice(&self, key: &str, max: u8) -> Option { + (self.0.kinds.get(key) == Some(&Kind::Int)).then_some(())?; + let v = self.0.params.get(key).copied().filter(|v| v.is_finite() && v.fract() == 0.0 && (0.0..=f64::from(max)).contains(v))?; + Some(v as usize) + } + + /// The blend mode in `blnd`, numbered as transparency's (`Xy`). + fn mode(&self) -> Option { + Some(super::read::blend_name(self.choice("blnd", 15)?)) + } + + /// The color of the fill style written as `key`: a solid color, in Normal at full opacity. + fn color(&self, key: &str) -> Option { + let f = self.0; + let normal = f.xy.is_none_or(|(mode, opacity)| mode == 0 && opacity >= 1.0); + (f.kinds.get(key) == Some(&Kind::Fill) && !f.gradient && f.pattern.is_none() && normal).then_some(())?; + f.fill.as_ref()?.color() + } +} + +/// Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners and Scribble (`None`: another +/// filter). `Some(None)` is one whose settings aren't all read: an unknown key, a key of another +/// kind, a value out of range or a missing setting keeps the drawn look. The keys are LiveEffect +/// XML's +/// (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450). +fn stylize(f: &Filter) -> Option> { + let s = Settings(f); + let opacity = || Some(s.real("opac", 0.0..=1.0)? * 100.0); + Some(match f.name.as_str() { + // `horz` and `vert` are the X and Y Offset, right and down + // (https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/create-drop-shadows.html); + // `csrc` 0 is Color (`sclr`), 1 Darkness (`dark`, a percent). `blur` is half the catalog's. + SHADOW => (|| { + let fixed = [("pair", Kind::Bool, 1.0), ("usePSLBlur", Kind::Bool, 1.0), (EXPAND_VERSION, Kind::Int, 16.0)]; + s.only(&["blnd", "opac", "horz", "vert", "blur", "csrc", "dark", "sclr"], &fixed).then_some(())?; + let mut params = json!({ + "mode": s.mode()?, + "opacity": opacity()?, + "blur": 2.0 * s.real("blur", 0.0..=500.0)?, + "x": s.real("horz", -1e4..=1e4)?, + "y": s.real("vert", -1e4..=1e4)?, + }); + if s.choice("csrc", 1)? == 0 { + params["color"] = json!(s.color("sclr")?); + } else { + params["shade"] = json!("darkness"); + params["darkness"] = json!(s.real("dark", 0.0..=100.0)?); + if let Some(c) = s.color("sclr") { + params["color"] = json!(c); + } + } + Some(("stylize.dropShadow", params)) + })(), + // `gtyp` 0 is Center, 1 Edge (https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/create-an-inner-or-outer-glow.html); + // `blur` is the catalog's. + INNER_GLOW => (|| { + s.only(&["blnd", "opac", "blur", "gtyp", "gclr"], &[]).then_some(())?; + let source = if s.choice("gtyp", 1)? == 0 { "center" } else { "edge" }; + let params = json!({ + "mode": s.mode()?, + "opacity": opacity()?, + "blur": s.real("blur", 0.0..=1000.0)?, + "color": s.color("gclr")?, + "source": source, + }); + Some(("stylize.innerGlow", params)) + })(), + // `blur` is half the catalog's. + GLOW => (|| { + s.only(&["blnd", "opac", "blur", "sclr"], &[("usePSLBlur", Kind::Bool, 1.0), (EXPAND_VERSION, Kind::Int, 16.0)]).then_some(())?; + let params = json!({"mode": s.mode()?, "opacity": opacity()?, "blur": 2.0 * s.real("blur", 0.0..=500.0)?, "color": s.color("sclr")?}); + Some(("stylize.outerGlow", params)) + })(), + // https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/feather-object-edges.html + FEATHER => s.only(&["Radius"], &[]).then(|| s.real("Radius", 0.0..=1000.0)).flatten().map(|r| ("stylize.feather", json!({"radius": r}))), + ROUND_CORNERS => { + s.only(&["radius"], &[]).then(|| s.real("radius", 0.0..=1e4)).flatten().map(|r| ("stylize.roundCorners", json!({"radius": r}))) + } + // `Angle` in degrees; widths, spacings and overlaps in points; `Scribbliness` (Curviness) + // and `ScribbleVariation` as fractions. The catalog's Scribble has no color, so a Scribble + // with `sclr` keeps the drawn look. + SCRIBBLE => (|| { + let keys = + ["Angle", "EdgeOverlap", "EdgeOverlapVariation", "StrokeWidth", "Scribbliness", "ScribbleVariation", "Spacing", "SpacingVariation"]; + s.only(&keys, &[]).then_some(())?; + let params = json!({ + "angle": s.real("Angle", -360.0..=360.0)?, + "overlap": s.real("EdgeOverlap", -1000.0..=1000.0)?, + "overlapVariation": s.real("EdgeOverlapVariation", 0.0..=100.0)?, + "strokeWidth": s.real("StrokeWidth", 0.01..=1000.0)?, + "curviness": s.real("Scribbliness", 0.0..=1.0)? * 100.0, + "curvinessVariation": s.real("ScribbleVariation", 0.0..=1.0)? * 100.0, + "spacing": s.real("Spacing", 0.1..=1000.0)?, + "spacingVariation": s.real("SpacingVariation", 0.0..=100.0)?, + }); + Some(("stylize.scribble", params)) + })(), + _ => return None, + }) +} + +/// The largest size or radius Convert to Shape reads, in points. +const MAX_SHAPE_SIZE: f64 = 1e5; + +/// Convert to Shape: Rectangle, Rounded Rectangle or Ellipse, with an absolute size or one relative +/// to the object, and a corner radius +/// (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). +/// The keys are LiveEffect XML's: `Shape` 0 Rectangle, 1 Rounded Rectangle, 2 Ellipse +/// (https://community.adobe.com/questions-652/effect-convert-to-shape-ellipse-in-javascript-761645), +/// `Absolute` 1 for an absolute `AbsWidth` × `AbsHeight`, else `RelWidth` and `RelHeight` added to +/// each side, and `CornerRadius`; a missing size is 18 pt a side or 36 pt, and a missing radius +/// 9 pt. `None`: a key, a kind or a value that isn't read, which keeps the drawn look. +fn shape_effect(f: &Filter) -> Option { + const SIZES: [&str; 5] = ["AbsWidth", "AbsHeight", "RelWidth", "RelHeight", "CornerRadius"]; + let known = f.kinds.iter().all(|(key, kind)| match key.as_str() { + "DisplayString" => *kind == Kind::Other, + "Shape" => *kind == Kind::Int, + key => (key == "Absolute" || SIZES.contains(&key)) && *kind == Kind::Real, + }); + if !known || !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() { + return None; + } + // A written size is a finite number from `min`; a missing one is `default`. + let size = |key: &str, min: f64, default: f64| match f.kinds.get(key) { + None => Some(default), + Some(_) => f.params.get(key).copied().filter(|v| v.is_finite() && (min..=MAX_SHAPE_SIZE).contains(v)), + }; + let shape = f.params.get("Shape").copied().filter(|_| f.kinds.get("Shape") == Some(&Kind::Int))?; + let absolute = match f.params.get("Absolute").copied() { + Some(0.0) => false, + Some(1.0) => true, + _ => return None, + }; + let (rel_w, rel_h) = (size("RelWidth", -MAX_SHAPE_SIZE, 18.0)?, size("RelHeight", -MAX_SHAPE_SIZE, 18.0)?); + let (abs_w, abs_h, radius) = (size("AbsWidth", 0.0, 36.0)?, size("AbsHeight", 0.0, 36.0)?, size("CornerRadius", 0.0, 9.0)?); + let id = match shape { + 0.0 => "convertToShape.rectangle", + 1.0 => "convertToShape.roundedRectangle", + // A relative Ellipse in the file is drawn round the enlarged box, and VectorCraft's inside + // it: it keeps its drawn look. + 2.0 if absolute => "convertToShape.ellipse", + _ => return None, + }; + let mut params = json!({"relative": !absolute, "extraW": 2.0 * rel_w, "extraH": 2.0 * rel_h, "width": abs_w, "height": abs_h}); + if id == "convertToShape.roundedRectangle" { + params["radius"] = json!(radius); + } + Some(Effect { id: id.into(), params, visible: f.visible }) +} + +/// The largest offset Offset Path reads, in points (the effect's own limit). +const MAX_OFFSET: f64 = 1e5; + +/// Effect › Path: Offset Path with its Offset, Joins and Miter Limit +/// (https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/offset-duplicate-objects.html), +/// Outline Object and Outline Stroke, which have no settings. Offset Path's keys are LiveEffect +/// XML's: `ofst` in points, `jntp` 0 round, 1 bevel or 2 miter, and `mlim` +/// (https://community.adobe.com/questions-652/illustrator-javascript-count-area-with-offset-782386). +/// `None`: a missing, unknown or out-of-range setting, or one of another kind, which keeps the +/// drawn look; there is no default to stand in for a missing one. +fn path_effect(f: &Filter) -> Option { + if !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() { + return None; + } + let (id, params) = match f.name.as_str() { + OFFSET => { + const KEYS: [(&str, Kind); 3] = [("ofst", Kind::Real), ("jntp", Kind::Int), ("mlim", Kind::Real)]; + if f.kinds.len() != KEYS.len() || KEYS.iter().any(|(key, kind)| f.kinds.get(*key) != Some(kind)) { + return None; + } + let value = |key: &str| f.params.get(key).copied().filter(|v| v.is_finite()); + let offset = value("ofst").filter(|v| v.abs() <= MAX_OFFSET)?; + let miter = value("mlim").filter(|v| (1.0..=500.0).contains(v))?; + let joins = match value("jntp")? { + 0.0 => "round", + 1.0 => "bevel", + 2.0 => "miter", + _ => return None, + }; + ("path.offsetPath", json!({"offset": offset, "joins": joins, "miterLimit": miter})) + } + // Outline Stroke without a `width`: the stroke it is on, or the object's, gives it. + OUTLINE_STROKE if f.kinds.is_empty() => ("path.outlineStroke", json!({})), + OUTLINE_TYPE if f.kinds.is_empty() => ("path.outlineObject", json!({})), + _ => return None, + }; + Some(Effect { id: id.into(), params, visible: f.visible }) +} + +/// The live effect filter `f` is, when it is one read here; `under` is what it could change the +/// paint of. +fn effect(f: &Filter, under: Under) -> Option { + if let Some(stylized) = stylize(f) { + let (id, params) = stylized?; + return Some(Effect { id: id.into(), params, visible: f.visible }); + } let param = |k: &str| f.params.get(k).copied().filter(|v| v.is_finite()); let flag = |k: &str| param(k).is_some_and(|v| v != 0.0); - let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize)); - let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK); let (id, params) = match f.name.as_str() { - // `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ. - GLOW => ( - "stylize.outerGlow", - json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}), - ), - // LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect). - FEATHER => ("stylize.feather", json!({"radius": param("rad").or_else(|| param("radius")).unwrap_or(5.0)})), - SHADOW => { - let mut params = json!({ - "mode": mode(1), - "opacity": param("opac").unwrap_or(0.75) * 100.0, - "blur": 2.0 * param("blur").unwrap_or(5.0), - // Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is - // right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html). - "x": param("horz").unwrap_or(7.0), - "y": param("vert").unwrap_or(7.0), - "color": color, - }); - // A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as - // Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite. - if param("csrc").unwrap_or(0.0) != 0.0 { - params["shade"] = json!("darkness"); - params["darkness"] = json!(param("dark").map_or(100.0, |v| v.clamp(0.0, 100.0))); - } - ("stylize.dropShadow", params) - } // Effect › Warp: `DeformStyle` numbers the styles from 1 as the dialog lists them, `Rotate` // is Vertical, and the bend and distortions are fractions. DEFORM => { @@ -471,46 +1008,46 @@ fn effect(f: &Filter) -> Option { }), ) } - // `jntp`: 0 round, 1 bevel, 2 miter (the joins LiveEffect XML writes in public scripts). - OFFSET => ( - "path.offsetPath", - json!({ - "offset": param("ofst").unwrap_or(10.0).clamp(-1e5, 1e5), - "joins": match param("jntp").map(|v| v as i64) { Some(0) => "round", Some(1) => "bevel", _ => "miter" }, - "miterLimit": param("mlim").unwrap_or(4.0).clamp(1.0, 500.0), - }), - ), - // Art space has y up: a move up is a negative `moveV` in the document. - TRANSFORM => ( - "distort.transform", - json!({ - "scaleH": param("scaleH_Percent").unwrap_or(100.0), - "scaleV": param("scaleV_Percent").unwrap_or(100.0), - "moveH": param("moveH_Pts").unwrap_or(0.0), - "moveV": -param("moveV_Pts").unwrap_or(0.0), - "rotate": param("rotate_Degrees").unwrap_or(0.0), - "copies": param("numCopies").unwrap_or(0.0).clamp(0.0, 1000.0) as i64, - "reflectX": flag("reflectX"), - "reflectY": flag("reflectY"), - "reference": param("pinPoint").unwrap_or(4.0).clamp(0.0, 8.0) as i64, - "random": flag("randomize"), - }), - ), + OFFSET | OUTLINE_TYPE | OUTLINE_STROKE => return path_effect(f), + TRANSFORM => ("distort.transform", transform(f, under)?), + FREE_DISTORT => ("distort.freeDistort", free_distort(f)?), + // Negative puckers (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). + PUCKER_BLOAT => ("distort.puckerBloat", json!({"amount": real(f, "d_factor", 0.0, -200.0..=200.0)?})), + TWEAK => ("distort.tweak", tweak(f).filter(still)?), + // A positive angle turns clockwise in both. + TWIST => ("distort.twist", json!({"angle": real(f, "angle", 0.0, -3600.0..=3600.0)?})), + ZIG_ZAG => ("distort.zigZag", zig_zag(f)?), GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})), // Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in. BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})), + SHAPE_EFFECTS => return shape_effect(f), + PATHFINDER => return pathfinder_effect(f), _ => return None, }; Some(Effect { id: id.into(), params, visible: f.visible }) } +/// Effect › Pathfinder › Add, when every key of [`PATHFINDER_ADD`] is there with its kind and value +/// and the only other key is the `DisplayString` text, each written once. `None` for anything else, +/// which keeps the drawn look. +fn pathfinder_effect(f: &Filter) -> Option { + let known = f.kinds.iter().all(|(key, kind)| (key == "DisplayString" && *kind == Kind::Other) || PATHFINDER_ADD.iter().any(|(k, ..)| k == key)); + if !known || f.repeated || !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() { + return None; + } + if !PATHFINDER_ADD.iter().all(|(key, kind, value)| f.kinds.get(*key) == Some(kind) && f.params.get(*key) == Some(value)) { + return None; + } + Some(Effect { id: "pathfinder.add".into(), params: json!({}), visible: f.visible }) +} + /// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it /// paints in a way this doesn't read. fn paint_item(f: &Filter) -> Result, ()> { match f.name.as_str() { FILL if !f.paints.1 => Ok(None), FILL => match (&f.pattern, &f.gradient_of, &f.fill) { - (Some((name, matrix)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix })), + (Some((name, matrix, controls)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix, controls: controls.clone() })), (None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })), (None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })), (None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))), @@ -568,7 +1105,25 @@ impl Builder<'_> { let mut look = ItemLook { visible: f.visible, ..ItemLook::default() }; let mut painted: Option = None; let mut paints = 0; - for k in self.children(id) { + let kids = self.children(id); + // What this item's effects could change: its own paint, and its other effects' distances. + let mut under = Under { strokes: false, patterns: false }; + let mut effects = 0; + for &k in &kids { + let c = &filters[k]; + match c.name.as_str() { + _ if c.compound => {} + FILL | STROKE => match paint_item(c) { + Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true, + Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true, + _ => {} + }, + BLEND => {} + _ => effects += 1, + } + } + under.strokes |= effects > 1; + for k in kids { let c = &filters[k]; match c.name.as_str() { _ if c.compound => self.plain = false, @@ -585,9 +1140,11 @@ impl Builder<'_> { (look.blend, look.opacity) = (super::read::blend_mode(mode), opacity); } } - _ => match effect(c) { - Some(e) => look.effects.push(e), - None => self.plain = false, + // Convert to Shape on one fill or stroke isn't read yet: what it draws there is + // the drawn look's. A fill's or stroke's own Pathfinder effect isn't read either. + _ => match effect(c, under) { + Some(e) if !e.id.starts_with("convertToShape.") && !is_pathfinder(&e.id) => look.effects.push(e), + _ => self.plain = false, }, } } @@ -624,6 +1181,7 @@ fn style(filters: &[Filter]) -> ArtStyle { let mut st = ArtStyle { plain: true, ..ArtStyle::default() }; let Some(root) = filters.iter().position(|f| f.parent.is_none()) else { return st }; let mut b = Builder { filters, plain: true }; + let chained = filters[root].compound && filters[root].name == CHAIN; let top = if filters[root].compound { b.children(root) } else { vec![root] }; for id in top { let f = &filters[id]; @@ -637,13 +1195,25 @@ fn style(filters: &[Filter]) -> ArtStyle { } CONDUIT => {} // The object's own transparency, which its look carries too. - BLEND => {} - _ => match effect(f) { - Some(e) => st.effects.push(e), - None => b.plain = false, + BLEND => { + if let Some((mode, opacity)) = f.xy { + let (isolate, knockout) = f.xy_group.unwrap_or((false, Knockout::Off)); + st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout)); + } + } + // The object's Pathfinder effects are read only in its own chain. + _ => match effect(f, ANYTHING) { + Some(e) if chained || !is_pathfinder(&e.id) => st.effects.push(e), + _ => b.plain = false, }, } } + st.pathfinder = st.effects.iter().any(|e| is_pathfinder(&e.id)); + // A group's Pathfinder effects are drawn before its other effects: one listed after another + // effect keeps the drawn look. + if st.effects.iter().skip_while(|e| is_pathfinder(&e.id)).any(|e| is_pathfinder(&e.id)) { + b.plain = false; + } st.plain = b.plain; let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); st.simple = st.plain @@ -651,7 +1221,25 @@ fn style(filters: &[Filter]) -> ArtStyle { && count(false) <= 1 && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) - && st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow")); + && st.effects.iter().all(|e| { + matches!( + e.id.as_str(), + "stylize.dropShadow" + | "stylize.innerGlow" + | "stylize.outerGlow" + | "stylize.feather" + | "stylize.roundCorners" + | "stylize.scribble" + | "convertToShape.rectangle" + | "convertToShape.roundedRectangle" + | "convertToShape.ellipse" + | "path.offsetPath" + | "path.outlineObject" + | "path.outlineStroke" + ) || is_pathfinder(&e.id) + }) + // Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group). + && (!st.pathfinder || st.items.is_empty()); st } diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 964b74f10..2815f589c 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -26,94 +26,236 @@ fn dashes_aligned_to_corners_keep_the_drawn_look() { assert!(!art_styles(data)["Stitches"].plain); } -#[test] -fn a_drop_shadow_offset_down_points_down() { - // GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's - // default shadow (7 pt right, 7 pt down) upwards. - let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(data); - let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) }; - assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0))); -} - /// The style `Shade`: a Drop Shadow whose dictionary holds `keys`. fn shadow_style(keys: &str) -> ArtStyle { + effect_style("Adobe Drop Shadow", keys) +} + +/// The style `Fx`: an effect `name` on the whole object, its dictionary holding `keys`. +fn effect_style(name: &str, keys: &str) -> ArtStyle { let data = format!( - "/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" + "/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n({name}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" ); - art_styles(data.as_bytes()).remove("Shade").unwrap_or_default() + art_styles(data.as_bytes()).remove("Fx").unwrap_or_default() +} + +/// A colour entry `key`: a fill style in RGB `r g b`. +fn color_entry(key: &str, rgb: &str) -> String { + format!("/FillStyle : 0 O\n0 0 0 0 {rgb} Xa\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Def ;\n ({key}) ,\n") +} + +/// A Drop Shadow's dictionary: every setting, with Color or Darkness set by `shade`. +fn shadow_keys(shade: &str) -> String { + format!( + "2 /Int (blnd) ,\n0.43 /Real (opac) ,\n37 /Real (dark) ,\n-13 /Real (vert) ,\n11 /Real (horz) ,\n17 /Real (blur) ,\n{}{shade}", + color_entry("sclr", "0 1 0") + ) +} + +/// `style`'s one effect on the whole object. +fn only_effect(style: &ArtStyle) -> &Effect { + assert!(style.plain, "{style:?}"); + let [e] = style.effects.as_slice() else { panic!("{style:?}") }; + e +} + +/// `params[key]`, a number within rounding of `want`. +fn near(params: &serde_json::Value, key: &str, want: f64) { + let got = params[key].as_f64(); + assert!(got.is_some_and(|v| (v - want).abs() < 1e-9), "{key}: {got:?}, not {want}: {params}"); +} + +#[test] +fn a_drop_shadow_reads_its_settings() { + // GUARDS: `vert` read as an art-space y would flip a shadow offset down to up. + let style = shadow_style(&shadow_keys("0 /Int (csrc) ,\n")); + let shadow = only_effect(&style); + let p = &shadow.params; + assert_eq!( + (shadow.id.as_str(), &p["mode"], &p["color"]), + ("stylize.dropShadow", &json!("Screen"), &json!(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0))) + ); + for (key, want) in [("x", 11.0), ("y", -13.0), ("blur", 34.0), ("opacity", 43.0)] { + near(p, key, want); + } + assert_eq!((p.get("shade"), p.get("darkness")), (None, None)); } #[test] fn a_drop_shadow_with_darkness_reads_its_darkness() { - // GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look. - let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n"); - assert!(style.plain, "{style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{style:?}") }; - let p = &shadow.params; + let shadow = only_effect(&shadow_style(&shadow_keys("1 /Int (csrc) ,\n"))).clone(); + assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(37.0))); +} + +#[test] +fn a_drop_shadow_with_settings_not_read_keeps_the_drawn_look() { + // GUARDS: an unknown `csrc` was read as Darkness, `blnd` was cast to a mode, a missing setting + // took a default and an unknown key was skipped: each became an editable shadow the file doesn't draw. + let full = shadow_keys("0 /Int (csrc) ,\n"); + let cases = [ + full.replace("0 /Int (csrc)", "99 /Int (csrc)"), + full.replace("2 /Int (blnd)", "99 /Int (blnd)"), + full.replace("2 /Int (blnd)", "2.5 /Int (blnd)"), + full.replace("0 /Int (csrc)", "0.5 /Int (csrc)"), + full.replace("11 /Real (horz)", "11 /Int (horz)"), + full.replace("37 /Real (dark)", "37 /Int (dark)"), + full.replace("2 /Int (blnd)", "2 /Real (blnd)"), + full.replace("0.43 /Real (opac)", "1.5 /Real (opac)"), + full.replace("11 /Real (horz) ,\n", ""), + full.replace(&color_entry("sclr", "0 1 0"), ""), + full.replace("0 1 0 0 0 Xy", "2 0.5 0 0 0 Xy"), + format!("{full}1 /Real (spread) ,\n"), + format!("{full}0 /Bool (pair) ,\n"), + format!("{full}(x) /String (note) ,\n"), + format!("{full}3 4 /RealPointRelToROrigin (spot) ,\n"), + shadow_keys("1 /Int (csrc) ,\n").replace("37 /Real (dark)", "250 /Real (dark)"), + shadow_keys("1 /Int (csrc) ,\n").replace("37 /Real (dark)", "(fifty) /Real (dark)"), + ]; + for keys in cases { + assert!(!shadow_style(&keys).plain, "{keys}"); + } + let observed = format!("{full}1 /Bool (pair) ,\n1 /Bool (usePSLBlur) ,\n16 /Int (Adobe Effect Expand Before Version) ,\n"); + assert!(shadow_style(&observed).plain); +} + +#[test] +fn an_inner_glow_reads_its_settings() { + let keys = + |gtyp: &str| format!("10 /Int (blnd) ,\n0.41 /Real (opac) ,\n13 /Real (blur) ,\n{}{gtyp} /Int (gtyp) ,\n", color_entry("gclr", "1 0 0")); + let style = effect_style("Adobe Inner Glow", &keys("0")); + let glow = only_effect(&style); + let p = &glow.params; assert_eq!( - (shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]), - ("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0)) + (glow.id.as_str(), &p["mode"], &p["source"], &p["color"]), + ("stylize.innerGlow", &json!("Difference"), &json!("center"), &json!(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))) ); + near(p, "opacity", 41.0); + near(p, "blur", 13.0); + assert!(style.simple); + assert_eq!(only_effect(&effect_style("Adobe Inner Glow", &keys("1"))).params["source"], json!("edge")); + assert!(!effect_style("Adobe Inner Glow", &keys("2")).plain); } #[test] -fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() { - for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] { - let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}")); - assert!(style.plain, "{dark:?}: {style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") }; - assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}"); +fn an_outer_glow_reads_its_settings_and_keeps_the_drawn_look_for_others() { + let keys = format!( + "16 /Int (Adobe Effect Expand Before Version) ,\n10 /Int (blnd) ,\n0.42 /Real (opac) ,\n19 /Real (blur) ,\n{}1 /Bool (usePSLBlur) ,\n", + color_entry("sclr", "0 0 1") + ); + let glow = only_effect(&effect_style("Adobe Outer Glow", &keys)).clone(); + assert_eq!((glow.id.as_str(), &glow.params["mode"]), ("stylize.outerGlow", &json!("Difference"))); + near(&glow.params, "opacity", 42.0); + near(&glow.params, "blur", 38.0); + for other in [ + format!("{keys}1 /Real (spread) ,\n"), + keys.replace("19 /Real (blur)", "19 /Int (blur)"), + keys.replace("1 /Bool (usePSLBlur)", "0 /Bool (usePSLBlur)"), + ] { + assert!(!effect_style("Adobe Outer Glow", &other).plain, "{other}"); } } #[test] -fn a_drop_shadow_with_color_keeps_its_params() { - // GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`. - for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] { - let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}")); - assert!(style.plain, "{csrc:?}: {style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") }; - let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK}); - assert_eq!(shadow.params, want, "{csrc:?}"); +fn a_feather_reads_its_radius() { + // GUARDS: Feather was looked for under another name and key, so the files' Feather kept the drawn look. + let style = effect_style("Adobe Fuzzy Mask", "14 /Real (Radius) ,\n"); + let feather = only_effect(&style); + assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(14.0))); + assert!(style.simple); + for other in ["14 /Real (radius) ,\n", "", "-1 /Real (Radius) ,\n"] { + assert!(!effect_style("Adobe Fuzzy Mask", other).plain, "{other}"); } } #[test] -fn a_feather_reads_its_radius_from_rad() { - let data = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n12 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(data); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(12.0))); +fn round_corners_read_their_radius() { + let style = effect_style("Adobe Round Corners", "21 /Real (radius) ,\n"); + let round = only_effect(&style); + assert_eq!((round.id.as_str(), round.params["radius"].as_f64()), ("stylize.roundCorners", Some(21.0))); + assert!(style.simple); + for other in ["21 /Real (Radius) ,\n", "21 /Int (radius) ,\n", "-1 /Real (radius) ,\n"] { + assert!(!effect_style("Adobe Round Corners", other).plain, "{other}"); + } +} + +/// A Scribble's dictionary with every setting. +const SCRIBBLE_KEYS: &str = "0.8 /Real (SpacingVariation) ,\n0.23 /Real (ScribbleVariation) ,\n4 /Real (EdgeOverlapVariation) ,\n67 /Real (Angle) ,\n\ + 2.3 /Real (StrokeWidth) ,\n-7 /Real (EdgeOverlap) ,\n0.17 /Real (Scribbliness) ,\n13 /Real (Spacing) ,\n"; + +#[test] +fn a_scribble_reads_its_settings() { + let style = effect_style("Adobe Scribble Fill", SCRIBBLE_KEYS); + let scribble = only_effect(&style); + assert_eq!(scribble.id, "stylize.scribble"); + let want = [ + ("angle", 67.0), + ("overlap", -7.0), + ("overlapVariation", 4.0), + ("strokeWidth", 2.3), + ("curviness", 17.0), + ("curvinessVariation", 23.0), + ("spacing", 13.0), + ("spacingVariation", 0.8), + ]; + for (key, v) in want { + near(&scribble.params, key, v); + } + assert!(style.simple); } #[test] -fn a_feather_falls_back_to_radius_and_defaults_to_five_points() { - let with_name = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (radius) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(with_name); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(3.0)); +fn a_scribble_with_a_colour_or_settings_not_read_keeps_the_drawn_look() { + let cases = [ + format!("{SCRIBBLE_KEYS}{}", color_entry("sclr", "0 1 0")), + format!("{SCRIBBLE_KEYS}0.5 /Real (MinDensity) ,\n"), + SCRIBBLE_KEYS.replace("13 /Real (Spacing) ,\n", ""), + SCRIBBLE_KEYS.replace("0.17 /Real (Scribbliness)", "17 /Real (Scribbliness)"), + ]; + for keys in cases { + assert!(!effect_style("Adobe Scribble Fill", &keys).plain, "{keys}"); + } +} - let bare = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(bare); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); +#[test] +fn effects_on_the_object_keep_their_order() { + let data = format!( + "/KnownStyle :\n(Two) /Name ,\n/ActiveStyle :\n{}", + compound( + "Chain Style Filter", + &[basic("Adobe Round Corners", "10 /Real (radius) ,\n"), basic("Adobe Drop Shadow", &shadow_keys("0 /Int (csrc) ,\n"))] + ) + ); + let st = &art_styles(data.as_bytes())["Two"]; + assert!(st.plain && st.simple, "{st:?}"); + assert_eq!(st.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.roundCorners", "stylize.dropShadow"]); +} + +#[test] +fn a_glow_on_one_fill_stays_on_that_fill() { + // An ordinary object keeps the drawn look (`simple` is false); type takes the fill's glow. + let glow = basic( + "Adobe Inner Glow", + &format!("2 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n{}1 /Int (gtyp) ,\n", color_entry("gclr", "1 1 1")), + ); + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), glow]); + let data = + format!("/KnownStyle :\n(Lit) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + let st = &art_styles(data.as_bytes())["Lit"]; + assert!(st.plain && !st.simple, "{st:?}"); + assert!(st.effects.is_empty()); + assert_eq!(st.looks[0].effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.innerGlow"]); } #[test] fn art_styles_clear_operands_beyond_the_cap() { let mut data = Vec::from( - b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n", + b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Fuzzy Mask) 1 0 /Filter ,\n1 /Visible ,\n", ); for _ in 0..80 { data.extend_from_slice(b"1 "); } - data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"); + data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n5 /Real (Radius) ,\n; /Dict ;\n /Part ,\n;\n"); let styles = art_styles(&data); let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); @@ -121,7 +263,8 @@ fn art_styles_clear_operands_beyond_the_cap() { #[test] fn art_styles_ignore_filters_beyond_the_cap() { - const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; + const UNIT: &[u8] = + b"/BasicFilter :\n(Adobe Fuzzy Mask) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Real (Radius) ,\n; /Dict ;\n /Part ,\n"; let mut data = Vec::from(b"/KnownStyle :\n(Capped) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n"); for _ in 0..MAX_FILTERS + 16 { @@ -132,6 +275,19 @@ fn art_styles_ignore_filters_beyond_the_cap() { assert!(style.effects.len() <= MAX_FILTERS); } +#[test] +fn a_style_list_keeps_its_strings_in_order() { + let list = art_style_list(b"(A)\n(B\\)C)\n(D(E))\n/Name 3 x\n(Caf\\303\\251)\n(F"); + let want: [&[u8]; 4] = [b"A", b"B)C", b"D(E)", "Caf\u{e9}".as_bytes()]; + assert_eq!(list, want, "the string the data ends inside is left out"); + assert!(art_style_list(b"").is_empty()); + let unended: [&[u8]; 1] = [b"A"]; + assert_eq!(art_style_list(b"(A)\n(B\\)"), unended, "an escaped `)` doesn't end the string"); + assert_eq!(art_style_list(b"(A)\n(B(C)"), unended, "nor does one that closes a nested pair"); + assert_eq!(art_style_list(format!("({})", "x".repeat(MAX_LISTED_NAME + 1)).as_bytes()), Vec::>::new()); + assert_eq!(art_style_list("(x)\n".repeat(MAX_LISTED + 5).as_bytes()).len(), MAX_LISTED); +} + #[test] fn hostile_dash_offsets_and_width_profiles_are_tamed() { let mut f = Filter::default(); @@ -309,12 +465,492 @@ fn contents_above_some_items_count_them() { #[test] fn an_effect_not_read_here_keeps_the_drawn_look() { - let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Zig Zag", "")]); let data = format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); assert!(!art_styles(data.as_bytes())["Odd"].plain); } +/// The dictionary of an Effect › Pathfinder filter with `Command` `command` and `Mix` `mix`, and +/// the other options at the values the effect reads. +fn pathfinder_keys(command: &str, mix: &str) -> String { + format!( + "0 /Bool (RemovePoints) ,\n{mix} /Real (Mix) ,\n1 /Bool (ConvertCustom) ,\n{command} /Int (Command) ,\n1 /Real (TrapMaxTint) ,\n\ + 1 /Real (TrapAspect) ,\n0.4 /Real (TrapTint) ,\n0.25 /Real (TrapThickness) ,\n0 /Bool (TrapReverse) ,\n10 /Real (Precision) ,\n\ + (Op) /UnicodeString (DisplayString) ,\n0 /Bool (TrapConvertCustom) ,\n0.05 /Real (TrapTintTolerance) ,\n1 /Bool (ExtractUnpainted) ,\n" + ) +} + +/// The style `Group`: the object's chain holding `effects`, a stack whose fill and stroke paint +/// nothing, and the object's transparency. +fn group_style(effects: &[String]) -> ArtStyle { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + basic("Conduit Filter", ""), + basic( + "Fill Style Filter", + "/FillStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n/Def ;\n (FillStyle) ,\n0 /Bool (UseEvenOdd) ,\n", + ), + ], + ); + let mut kids = effects.to_vec(); + kids.extend([stack, blend(0, 1.0)]); + let data = format!("/KnownStyle :\n(Group) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &kids)); + art_styles(data.as_bytes()).remove("Group").unwrap_or_default() +} + +fn pathfinder(keys: &str) -> String { + basic("Adobe Pathfinder", keys) +} + +#[test] +fn pathfinder_add_is_read_as_its_effect() { + // GUARDS: an `Adobe Pathfinder` filter made the style not plain, so a group with it came in as its drawn look. + let st = group_style(&[pathfinder(&pathfinder_keys("0", "0.5"))]); + assert!(st.plain && st.simple && st.pathfinder, "{st:?}"); + assert!(st.items.is_empty(), "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + assert_eq!((e.id.as_str(), &e.params, e.visible), ("pathfinder.add", &json!({}), true)); +} + +#[test] +fn pathfinder_commands_other_than_add_keep_the_drawn_look() { + for command in 1..=12 { + let st = group_style(&[pathfinder(&pathfinder_keys(&command.to_string(), "0.5"))]); + assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{command}: {st:?}"); + } +} + +fn stroke(paint: &str) -> String { + basic("Stroke Style Filter", &format!("/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d0 XR\n{paint}\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n")) +} + +fn offset(ofst: f64, jntp: i64, mlim: f64) -> String { + basic("Adobe Offset Path", &format!("{ofst} /Real (ofst) ,\n{mlim} /Real (mlim) ,\n{jntp} /Int (jntp) ,\n")) +} + +/// Style `Fx`: `effects` on the whole object over a stack of a fill (with `on_fill` before its +/// fill style, `after_fill` after it) and a stroke (with `on_stroke` and `after_stroke`). +fn path_style(effects: &[String], on_fill: &[String], after_fill: &[String], on_stroke: &[String], after_stroke: &[String]) -> ArtStyle { + let item = |before: &[String], paint: String, after: &[String]| { + if before.is_empty() && after.is_empty() { + return paint; + } + compound("Chain Style Filter", &[before.concat(), paint, after.concat()]) + }; + let stack = compound( + "Stack Style Filter", + &[ + item(on_stroke, stroke("0 0 0 1 0 0 0 XA"), after_stroke), + item(on_fill, fill("0 0 0 0 1 0 0 Xa"), after_fill), + basic("Conduit Filter", ""), + ], + ); + let root = compound("Chain Style Filter", &[effects.concat(), stack]); + let data = format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}"); + art_styles(data.as_bytes()).remove("Fx").unwrap() +} + +fn ids(effects: &[Effect]) -> Vec<&str> { + effects.iter().map(|e| e.id.as_str()).collect() +} + +#[test] +fn offset_path_on_the_object_is_read_with_its_settings() { + // GUARDS: an object with Offset Path kept its drawn look, and the effect was read only on type. + let st = path_style(&[offset(4.0, 1, 6.0)], &[], &[], &[], &[]); + assert!(st.plain && st.simple, "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; + assert_eq!((e.id.as_str(), &e.params), ("path.offsetPath", &json!({"offset": 4.0, "joins": "bevel", "miterLimit": 6.0}))); + assert!(e.visible); + for (jntp, joins) in [(0, "round"), (2, "miter")] { + let st = path_style(&[offset(-5.0, jntp, 7.0)], &[], &[], &[], &[]); + assert_eq!((&st.effects[0].params["joins"], &st.effects[0].params["offset"]), (&json!(joins), &json!(-5.0))); + } +} + +#[test] +fn a_hidden_pathfinder_effect_comes_in_hidden() { + let st = group_style(&[pathfinder(&pathfinder_keys("0", "0.5")).replacen("1 /Visible", "0 /Visible", 1)]); + assert!(st.simple, "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + assert_eq!((e.id.as_str(), e.visible), ("pathfinder.add", false)); +} + +#[test] +fn pathfinder_effects_keep_their_order_among_the_objects_effects() { + let offset = basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"); + let st = group_style(&[pathfinder(&pathfinder_keys("0", "0.5")), offset.clone()]); + assert!(st.plain, "{st:?}"); + assert_eq!(ids(&st.effects), ["pathfinder.add", "path.offsetPath"]); + // GUARDS: an effect before the Pathfinder effect was read too, and the group drew the Pathfinder + // effect first, which isn't what the file draws. + let st = group_style(&[offset, pathfinder(&pathfinder_keys("0", "0.5"))]); + assert!(!st.plain, "{st:?}"); +} + +#[test] +fn pathfinder_settings_this_does_not_read_keep_the_drawn_look() { + let add = pathfinder_keys("0", "0.5"); + let cases = [ + ("no Command", add.replace("0 /Int (Command) ,\n", "")), + ("Trap", add.replace("0 /Int (Command)", "12 /Int (Command)")), + ("unknown Command", add.replace("0 /Int (Command)", "40 /Int (Command)")), + ("negative Command", add.replace("0 /Int (Command)", "-1 /Int (Command)")), + ("fractional Command", add.replace("0 /Int (Command)", "2.5 /Int (Command)")), + ("huge Command", add.replace("0 /Int (Command)", "1e999 /Int (Command)")), + ("Command as a real", add.replace("0 /Int (Command)", "0 /Real (Command)")), + ("Command without a number", add.replace("0 /Int (Command)", "(zero) /Int (Command)")), + // A number before the string isn't its value. + ("Command with a string after a number", add.replace("0 /Int (Command)", "3 (zero) /Int (Command)")), + ("Command twice", add.clone() + "1 /Int (Command) ,\n"), + // GUARDS: the last of a repeated key was read, so a Trap command followed by Add was read as Add. + ("Command twice, Add last", add.replace("0 /Int (Command)", "12 /Int (Command) ,\n0 /Int (Command)")), + ("Command twice, both Add", add.replace("0 /Int (Command)", "0 /Int (Command) ,\n0 /Int (Command)")), + ("an option twice, the read value last", add.replace("10 /Real (Precision)", "5 /Real (Precision) ,\n10 /Real (Precision)")), + ("unknown key", add.clone() + "1 /Int (Extra) ,\n"), + ("unknown string", add.clone() + "(x) /String (Extra) ,\n"), + ("an array", add.clone() + "/Array :\n1 /Real ,\n; (Extra) ,\n"), + ("many strings", add.clone() + &(0..40).map(|i| format!("(x) /String (DisplayString{i}) ,\n")).collect::()), + ("missing option", add.replace("0.25 /Real (TrapThickness) ,\n", "")), + ("missing Mix", add.replace("0.5 /Real (Mix) ,\n", "")), + ("option of the wrong type", add.replace("10 /Real (Precision)", "10 /Int (Precision)")), + ("other Precision", add.replace("10 /Real (Precision)", "5 /Real (Precision)")), + ("huge Precision", add.replace("10 /Real (Precision)", "1e999 /Real (Precision)")), + ("Remove Redundant Points", add.replace("0 /Bool (RemovePoints)", "1 /Bool (RemovePoints)")), + ("unpainted art kept", add.replace("1 /Bool (ExtractUnpainted)", "0 /Bool (ExtractUnpainted)")), + ("other trap tint", add.replace("0.4 /Real (TrapTint)", "0.25 /Real (TrapTint)")), + ("Mix on Add", add.replace("0.5 /Real (Mix)", "0.3 /Real (Mix)")), + ("Mix as a bool", add.replace("0.5 /Real (Mix)", "1 /Bool (Mix)")), + ]; + for (case, keys) in cases { + let st = group_style(&[pathfinder(&keys)]); + assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{case}: {st:?}"); + } +} + +#[test] +fn a_pathfinder_effect_outside_the_objects_chain_keeps_the_drawn_look() { + let pf = pathfinder(&pathfinder_keys("0", "0.5")); + // On a fill of its own. + let item = compound("Chain Style Filter", &[pf.clone(), fill("0 0 0 1 0 0 0 Xa")]); + let data = + format!("/KnownStyle :\n(Item) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + let st = &art_styles(data.as_bytes())["Item"]; + assert!(!st.plain, "{st:?}"); + // In a style that isn't an object's chain. + let data = format!("/KnownStyle :\n(Bare) /Name ,\n/ActiveStyle :\n{pf}"); + let st = &art_styles(data.as_bytes())["Bare"]; + assert!(!st.plain && st.effects.is_empty(), "{st:?}"); +} + +#[test] +fn a_pathfinder_effect_with_fills_of_its_own_keeps_the_drawn_look() { + let stack = compound("Stack Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Conduit Filter", "")]); + let data = format!( + "/KnownStyle :\n(Filled) /Name ,\n/ActiveStyle :\n{}", + compound("Chain Style Filter", &[pathfinder(&pathfinder_keys("0", "0.5")), stack]) + ); + let st = &art_styles(data.as_bytes())["Filled"]; + assert!(st.pathfinder && !st.simple, "{st:?}"); +} + +#[test] +fn outline_object_and_outline_stroke_are_read_with_no_settings() { + // GUARDS: `Adobe Outline Type` and `Adobe Outline Stroke` weren't read, so the style kept its drawn look. + let outline = basic("Adobe Outline Type", ""); + let outline_stroke = basic("Adobe Outline Stroke", ""); + let st = path_style(&[outline.clone(), outline_stroke.clone()], &[], &[], &[], &[]); + assert!(st.plain && st.simple, "{st:?}"); + assert_eq!(ids(&st.effects), ["path.outlineObject", "path.outlineStroke"]); + // No `width`: the stroke gives it. + assert!(st.effects.iter().all(|e| e.params == json!({})), "{:?}", st.effects); + // On a fill and on a stroke: Outline Object before the item's own style, Outline Stroke after it. + let (before, after) = (std::slice::from_ref(&outline), std::slice::from_ref(&outline_stroke)); + let st = path_style(&[], before, after, before, after); + assert!(st.plain && !st.simple, "{st:?}"); + // Bottom first: the fill, then the stroke. + assert!(matches!(st.items.as_slice(), [StyleItem::Fill(_), StyleItem::Stroke { .. }]), "{:?}", st.items); + for look in &st.looks { + assert_eq!(ids(&look.effects), ["path.outlineObject", "path.outlineStroke"]); + } +} + +#[test] +fn path_effects_keep_their_order_and_visibility() { + let hidden = offset(3.0, 0, 4.0).replacen("1 /Visible", "0 /Visible", 1); + let st = path_style(&[hidden, offset(6.0, 1, 4.0)], &[], &[], &[], &[]); + assert!(st.plain && st.simple, "{st:?}"); + let offsets: Vec<_> = st.effects.iter().map(|e| (e.params["offset"].as_f64(), e.visible)).collect(); + assert_eq!(offsets, [(Some(3.0), false), (Some(6.0), true)]); + // On a fill and a stroke, with another effect after it. + let transform = basic("Adobe Transform", "2 /Int (numCopies) ,\n"); + let after = std::slice::from_ref(&transform); + let st = path_style(&[], &[offset(2.0, 2, 4.0)], after, &[offset(-1.0, 0, 4.0)], after); + assert!(st.plain, "{st:?}"); + assert_eq!(ids(&st.looks[0].effects), ["path.offsetPath", "distort.transform"]); + assert_eq!(ids(&st.looks[1].effects), ["path.offsetPath", "distort.transform"]); + assert_eq!((st.looks[0].effects[0].params["offset"].as_f64(), st.looks[1].effects[0].params["offset"].as_f64()), (Some(2.0), Some(-1.0))); +} + +#[test] +fn offset_path_with_settings_not_read_keeps_the_drawn_look() { + // GUARDS: a missing setting was read as 10 pt, miter or 4, and an out-of-range one was clamped. + let complete = "4 /Real (ofst) ,\n1 /Int (jntp) ,\n6 /Real (mlim) ,\n"; + let bodies = [ + String::new(), + "4 /Real (ofst) ,\n".into(), + "1 /Int (jntp) ,\n".into(), + "4 /Real (ofst) ,\n1 /Int (jntp) ,\n".into(), + format!("{complete}1 /Int (later) ,\n"), + format!("{complete}(x) /UnicodeString (DisplayString) ,\n"), + complete.replace("/Int (jntp)", "/Real (jntp)"), + complete.replace("4 /Real (ofst)", "(4) /Real (ofst)"), + complete.replace("4 /Real (ofst)", "1e999 /Real (ofst)"), + complete.replace("4 /Real (ofst)", "100001 /Real (ofst)"), + complete.replace("6 /Real (mlim)", "0.5 /Real (mlim)"), + complete.replace("6 /Real (mlim)", "501 /Real (mlim)"), + complete.replace("1 /Int (jntp)", "3 /Int (jntp)"), + complete.replace("1 /Int (jntp)", "-1 /Int (jntp)"), + complete.replace("1 /Int (jntp)", "0.5 /Int (jntp)"), + ]; + for body in &bodies { + let filter = basic("Adobe Offset Path", body); + let one = std::slice::from_ref(&filter); + for st in [path_style(one, &[], &[], &[], &[]), path_style(&[], one, &[], &[], &[])] { + assert!(!st.plain, "{body:?}: {st:?}"); + } + } + assert!(path_style(&[basic("Adobe Offset Path", complete)], &[], &[], &[], &[]).plain); + // The outline effects have no settings. + for name in ["Adobe Outline Type", "Adobe Outline Stroke"] { + assert!(!path_style(&[basic(name, "1 /Real (width) ,\n")], &[], &[], &[], &[]).plain, "{name}"); + assert!(!path_style(&[basic(name, "/Array : 1 /Real , ; (width) ,\n")], &[], &[], &[], &[]).plain, "{name}"); + } +} + +/// The style `Fx`: effect `name` on the whole object, its dictionary holding `keys`. +fn distort_effect_style(name: &str, keys: &str) -> ArtStyle { + let data = format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[basic(name, keys)])); + art_styles(data.as_bytes()).remove("Fx").unwrap_or_default() +} + +/// The params of effect `name` with `keys`, `None` when the style keeps the drawn look. +fn read_effect(name: &str, keys: &str) -> Option { + let st = distort_effect_style(name, keys); + if !st.plain { + return None; + } + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + Some(e.params.clone()) +} + +/// A filter named `name` whose dictionary holds `entries`. +fn dict(name: &str, entries: &[(&str, Kind, f64)]) -> Filter { + let kinds = entries.iter().map(|(k, kind, _)| (k.to_string(), *kind)).collect(); + let params = entries.iter().map(|(k, _, v)| (k.to_string(), *v)).collect(); + Filter { name: name.into(), kinds, params, ..Filter::default() } +} + +fn close(a: &serde_json::Value, want: f64) -> bool { + a.as_f64().is_some_and(|a| (a - want).abs() < 1e-6) +} + +/// Every switch of the Transform effect, as files write them. +const SWITCHES: &str = "0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"; + +#[test] +fn a_transform_reads_its_factors_and_radians() { + // GUARDS: the percent and degree entries (what the dialog shows) were read, and the options were not. + let p = read_effect( + "Adobe Transform", + "150 /Real (scaleH_Percent) ,\n1.4 /Real (scaleH_Factor) ,\n80 /Real (scaleV_Percent) ,\n1.2 /Real (scaleV_Factor) ,\n\ + 30 /Real (rotate_Degrees) ,\n0.471238898 /Real (rotate_Radians) ,\n-8 /Real (moveV_Pts) ,\n3 /Real (moveH_Pts) ,\n\ + 2 /Int (numCopies) ,\n0 /Int (pinPoint) ,\n1 /Bool (reflectX) ,\n0 /Bool (transformObjects) ,\n1 /Bool (transformPatterns) ,\n\ + 1 /Bool (scaleLines) ,\n0 /Bool (randomize) ,\n", + ) + .unwrap(); + assert!(close(&p["scaleH"], 140.0) && close(&p["scaleV"], 120.0) && close(&p["rotate"], 27.0), "{p}"); + assert_eq!((&p["moveH"], &p["moveV"], &p["copies"], &p["reference"]), (&json!(3.0), &json!(8.0), &json!(2), &json!(0))); + assert_eq!((&p["reflectX"], &p["reflectY"]), (&json!(true), &json!(false))); + assert_eq!((&p["objects"], &p["patterns"], &p["strokes"]), (&json!(false), &json!(true), &json!(true))); +} + +#[test] +fn a_transforms_percent_and_degrees_alone_change_nothing() { + let p = read_effect("Adobe Transform", &format!("150 /Real (scaleH_Percent) ,\n30 /Real (rotate_Degrees) ,\n{SWITCHES}")).unwrap(); + assert_eq!((&p["scaleH"], &p["scaleV"], &p["rotate"]), (&json!(100.0), &json!(100.0), &json!(0.0))); +} + +#[test] +fn a_transforms_reference_point_and_move_up() { + for pin in 0..9 { + let p = read_effect("Adobe Transform", &format!("0.5 /Real (scaleH_Factor) ,\n{pin} /Int (pinPoint) ,\n{SWITCHES}")).unwrap(); + assert_eq!(p["reference"], json!(pin)); + } + // A move up the page in the file is a move up in the document, whose y runs down. + let p = read_effect("Adobe Transform", &format!("10 /Real (moveV_Pts) ,\n{SWITCHES}")).unwrap(); + assert_eq!(p["moveV"], json!(-10.0)); + let p = read_effect("Adobe Transform", &format!("4 /Int (pinPoint) ,\n{SWITCHES}")).unwrap(); + assert_eq!((&p["objects"], &p["copies"], &p["scaleH"]), (&json!(true), &json!(0), &json!(100.0))); +} + +#[test] +fn a_transform_without_the_options_its_change_depends_on_keeps_the_drawn_look() { + // Missing Scale Strokes & Effects or Transform Patterns are the app's settings, not the file's. + let half = "0.5 /Real (scaleH_Factor) ,\n0.5 /Real (scaleV_Factor) ,\n"; + assert_eq!(read_effect("Adobe Transform", &format!("{half}0 /Bool (transformPatterns) ,\n")), None); + assert_eq!(read_effect("Adobe Transform", &format!("{half}0 /Bool (scaleLines) ,\n")), None); + // A move scales nothing; with Transform Patterns given, it reads. + let p = read_effect("Adobe Transform", "5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n").unwrap(); + assert_eq!(p["strokes"], json!(false)); + // On a fill's own Transform, only that fill's paint counts: a solid fill moves the same either way. + let moved = basic("Adobe Transform", "5 /Real (moveH_Pts) ,\n"); + let item = |paint: &str| { + let item = compound("Chain Style Filter", &[fill(paint), moved.clone()]); + let data = + format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + art_styles(data.as_bytes()).remove("Fx").unwrap_or_default().plain + }; + assert!(item("0 0 0 1 0 0 0 Xa")); + assert!(!item("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p")); +} + +#[test] +fn a_random_transform_keeps_the_drawn_look() { + // The file holds no seed for its random share. + assert_eq!(read_effect("Adobe Transform", &format!("1 /Bool (randomize) ,\n{SWITCHES}")), None); +} + +/// Free Distort's dictionary: the source corners `src` and the destination `dst`, top left, top +/// right, bottom left, bottom right, y up. +fn corners(src: [[f64; 2]; 4], dst: [[f64; 2]; 4]) -> String { + let mut out = String::new(); + for (which, c) in [("src", src), ("dst", dst)] { + for (i, [h, v]) in c.into_iter().enumerate() { + out += &format!("{h} /Real ({which}{i}h) ,\n{v} /Real ({which}{i}v) ,\n"); + } + } + out +} + +fn unit_corners(p: &serde_json::Value) -> Vec<[f64; 2]> { + p["corners"].as_array().unwrap().iter().map(|c| [c[0].as_f64().unwrap(), c[1].as_f64().unwrap()]).collect() +} + +fn corners_near(a: &[[f64; 2]], b: &[[f64; 2]]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9) +} + +#[test] +fn free_distort_measures_its_corners_in_its_own_frame() { + // GUARDS: Free Distort wasn't read, and type with it came in as its drawn look. + let dst = [[180.0, 310.0], [320.0, 300.0], [210.0, 240.0], [250.0, 260.0]]; + // A frame wider than the object (its stroke's): the corners are fractions of that frame. + let p = read_effect("Adobe Free Distort", &corners([[195.0, 305.0], [305.0, 305.0], [195.0, 245.0], [305.0, 245.0]], dst)).unwrap(); + let want = [[-15.0 / 110.0, -5.0 / 60.0], [125.0 / 110.0, 5.0 / 60.0], [0.5, 0.75], [15.0 / 110.0, 65.0 / 60.0]]; + assert!(corners_near(&unit_corners(&p), &want), "{p}"); + // A unit frame: the same corners measured in it, far outside the unit box. + let p = read_effect("Adobe Free Distort", &corners([[0.0, 0.0], [1.0, 0.0], [0.0, -1.0], [1.0, -1.0]], dst)).unwrap(); + assert!(corners_near(&unit_corners(&p), &[[180.0, -310.0], [320.0, -300.0], [250.0, -260.0], [210.0, -240.0]]), "{p}"); + // No corners: no change. + let p = read_effect("Adobe Free Distort", "").unwrap(); + assert!(corners_near(&unit_corners(&p), &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), "{p}"); +} + +#[test] +fn free_distort_without_every_corner_or_an_upright_frame_keeps_the_drawn_look() { + let src = [[0.0, 0.0], [1.0, 0.0], [0.0, -1.0], [1.0, -1.0]]; + let dst = [[0.1, 0.0], [1.0, 0.1], [0.0, -1.0], [1.0, -1.2]]; + let all = corners(src, dst); + // The destination alone, one entry missing, a frame that is turned or of no height. + let dst_only: String = all.lines().filter(|l| l.contains("(dst")).map(|l| format!("{l}\n")).collect(); + let short = all.replace("-1.2 /Real (dst3v) ,\n", ""); + for keys in [ + dst_only, + short, + corners([[0.0, 0.0], [1.0, 0.5], [0.0, -1.0], [1.0, -1.0]], dst), + corners([[0.0, 0.0], [1.0, 0.0], [0.0, 0.0], [1.0, 0.0]], dst), + // A frame too wide for a number. + corners([[-1e308, 0.0], [1e308, 0.0], [-1e308, -1.0], [1e308, -1.0]], dst), + ] { + assert_eq!(read_effect("Adobe Free Distort", &keys), None, "{keys}"); + } +} + +#[test] +fn pucker_and_twist_keep_their_sense() { + // Negative is Pucker; a positive angle turns clockwise in the file and in the catalog. + assert_eq!(read_effect("Adobe Punk and Bloat", "-35 /Real (d_factor) ,\n"), Some(json!({"amount": -35.0}))); + assert_eq!(read_effect("Adobe Punk and Bloat", ""), Some(json!({"amount": 0.0}))); + assert_eq!(read_effect("Adobe Twirl", "45 /Real (angle) ,\n"), Some(json!({"angle": 45.0}))); + assert_eq!(read_effect("Adobe Twirl", ""), Some(json!({"angle": 0.0}))); + assert_eq!(read_effect("Adobe Punk and Bloat", "250 /Real (d_factor) ,\n"), None); + assert_eq!(read_effect("Adobe Twirl", "4000 /Real (angle) ,\n"), None); +} + +#[test] +fn roughen_keeps_the_drawn_look() { + // Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the + // path's segments. + assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None); + assert_eq!(read_effect("Adobe Roughen", ""), None); +} + +#[test] +fn tweak_picks_its_amounts_by_its_mode_and_moves_every_point_unless_told() { + let amounts = [("horz", Kind::Real, 30.0), ("vert", Kind::Real, 20.0), ("ahor", Kind::Real, 2.0), ("aver", Kind::Real, 3.0)]; + let mut absolute = amounts.to_vec(); + absolute.push(("absoluteness", Kind::Real, 1.0)); + let want = |h: f64, v: f64, relative: bool| json!({"h": h, "v": v, "relative": relative, "anchors": true, "in": true, "out": true}); + assert_eq!(tweak(&dict("Adobe Scribble and Tweak", &amounts)), Some(want(30.0, 20.0, true))); + assert_eq!(tweak(&dict("Adobe Scribble and Tweak", &absolute)), Some(want(2.0, 3.0, false))); + let off = tweak(&dict("Adobe Scribble and Tweak", &[("in", Kind::Bool, 0.0)])).unwrap(); + assert_eq!((&off["anchors"], &off["in"], &off["out"]), (&json!(true), &json!(false), &json!(true))); + // Random too: it reads only when it moves nothing. + assert_eq!(read_effect("Adobe Scribble and Tweak", "30 /Real (horz) ,\n"), None); + assert!(read_effect("Adobe Scribble and Tweak", "").is_some()); + let still = "30 /Real (horz) ,\n0 /Bool (anch) ,\n0 /Bool (in) ,\n0 /Bool (out) ,\n"; + assert!(read_effect("Adobe Scribble and Tweak", still).is_some()); +} + +#[test] +fn zig_zag_is_absolute_with_four_ridges_and_corners_unless_told() { + let p = read_effect("Adobe Zigzag", "30 /Real (relAmount) ,\n2 /Real (amount) ,\n").unwrap(); + assert_eq!(p, json!({"size": 2.0, "relative": false, "ridges": 4, "points": "corner"})); + let p = read_effect( + "Adobe Zigzag", + "30 /Real (relAmount) ,\n2 /Real (amount) ,\n0 /Real (absoluteness) ,\n6 /Real (ridges) ,\n1 /Real (roundness) ,\n", + ) + .unwrap(); + assert_eq!(p, json!({"size": 30.0, "relative": true, "ridges": 6, "points": "smooth"})); + assert_eq!(read_effect("Adobe Zigzag", "2.5 /Real (ridges) ,\n"), None); +} + +#[test] +fn a_distort_entry_this_cant_read_keeps_the_drawn_look_and_unknown_ones_are_ignored() { + // Not a number, the wrong kind, a switch other than 0 or 1, a whole number that isn't. + for (name, keys) in [ + ("Adobe Twirl", "NaN /Real (angle) ,\n"), + ("Adobe Twirl", "45 /Int (angle) ,\n"), + ("Adobe Zigzag", "2 /Real (absoluteness) ,\n"), + ("Adobe Transform", "4 /Real (pinPoint) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"), + ("Adobe Transform", "2 /Bool (reflectX) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"), + ("Adobe Transform", "1e999 /Real (scaleH_Percent) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"), + ("Adobe Transform", "1001 /Int (numCopies) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"), + // In degrees, too large for a number. + ("Adobe Transform", "1e308 /Real (rotate_Radians) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"), + ("Adobe Free Distort", "(x) /Real (src0h) ,\n"), + ] { + assert_eq!(read_effect(name, keys), None, "{name}: {keys}"); + } + assert_eq!(read_effect("Adobe Twirl", "37 /Real (angle) ,\n9 /Real (futureKey) ,\n"), Some(json!({"angle": 37.0}))); + // The last of a repeated entry stands. + assert_eq!(read_effect("Adobe Twirl", "37 /Real (angle) ,\n12 /Real (angle) ,\n"), Some(json!({"angle": 12.0}))); +} + #[test] fn a_hidden_fill_comes_in_hidden() { let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); @@ -326,3 +962,139 @@ fn a_hidden_fill_comes_in_hidden() { assert!(st.plain, "{st:?}"); assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); } + +/// A Convert to Shape filter whose dictionary holds `keys`. +fn shape(keys: &str) -> String { + basic("Adobe Shape Effects", &format!("(Shape) /UnicodeString (DisplayString) ,\n{keys}")) +} + +/// The style `Boxed`: a fill and a stroke, and `effects` on the whole object after them. +fn shape_style(effects: &[String]) -> ArtStyle { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 R\n0 0 0 1 0 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + ], + ); + let mut kids = vec![stack]; + kids.extend_from_slice(effects); + let data = format!("/KnownStyle :\n(Boxed) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &kids)); + art_styles(data.as_bytes()).remove("Boxed").unwrap_or_default() +} + +/// Every setting of a Relative or Absolute size, with a corner radius. +const SIZES: &str = "150 /Real (AbsWidth) ,\n40 /Real (AbsHeight) ,\n20 /Real (RelWidth) ,\n6 /Real (RelHeight) ,\n15 /Real (CornerRadius) ,\n"; + +#[test] +fn convert_to_shape_comes_in_live_with_its_settings() { + // GUARDS: `Adobe Shape Effects` wasn't read, so an object with it kept its drawn look. + for (keys, id, want) in [ + ( + format!("0 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}"), + "convertToShape.rectangle", + json!({"relative": true, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0}), + ), + ( + format!("1 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}"), + "convertToShape.roundedRectangle", + json!({"relative": true, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0, "radius": 15.0}), + ), + ( + format!("2 /Int (Shape) ,\n1 /Real (Absolute) ,\n{SIZES}"), + "convertToShape.ellipse", + json!({"relative": false, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0}), + ), + ( + format!("0 /Int (Shape) ,\n1 /Real (Absolute) ,\n{SIZES}"), + "convertToShape.rectangle", + json!({"relative": false, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0}), + ), + // A relative size alone, and one that shrinks the shape: each is added to every side. + ( + "0 /Int (Shape) ,\n0 /Real (Absolute) ,\n20 /Real (RelWidth) ,\n6 /Real (RelHeight) ,\n".to_string(), + "convertToShape.rectangle", + json!({"relative": true, "extraW": 40.0, "extraH": 12.0, "width": 36.0, "height": 36.0}), + ), + ( + "0 /Int (Shape) ,\n0 /Real (Absolute) ,\n-10 /Real (RelWidth) ,\n-4 /Real (RelHeight) ,\n".to_string(), + "convertToShape.rectangle", + json!({"relative": true, "extraW": -20.0, "extraH": -8.0, "width": 36.0, "height": 36.0}), + ), + // Missing sizes: 18 pt a side, 36 pt, and a 9 pt radius. + ( + "0 /Int (Shape) ,\n0 /Real (Absolute) ,\n20 /Real (RelWidth) ,\n".to_string(), + "convertToShape.rectangle", + json!({"relative": true, "extraW": 40.0, "extraH": 36.0, "width": 36.0, "height": 36.0}), + ), + ( + "1 /Int (Shape) ,\n1 /Real (Absolute) ,\n".to_string(), + "convertToShape.roundedRectangle", + json!({"relative": false, "extraW": 36.0, "extraH": 36.0, "width": 36.0, "height": 36.0, "radius": 9.0}), + ), + ] { + let st = shape_style(&[shape(&keys)]); + assert!(st.plain && st.simple, "{keys}: {st:?}"); + let [e] = st.effects.as_slice() else { panic!("{keys}: {:?}", st.effects) }; + assert_eq!((e.id.as_str(), &e.params, e.visible), (id, &want, true), "{keys}"); + assert_eq!(st.items.len(), 2, "{keys}"); + } +} + +#[test] +fn convert_to_shape_keeps_its_place_among_effects_and_its_visibility() { + let offset = basic("Adobe Offset Path", "2 /Int (jntp) ,\n4 /Real (mlim) ,\n4 /Real (ofst) ,\n"); + let st = shape_style(&[shape(&format!("0 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}")), offset.clone()]); + assert!(st.plain, "{st:?}"); + let ids: Vec<_> = st.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["convertToShape.rectangle", "path.offsetPath"]); + // Offset Path is read on any object too. + assert!(st.simple); + // A hidden one stays, hidden, with its settings. + let hidden = shape(&format!("1 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}")).replacen("1 /Visible", "0 /Visible", 1); + let st = shape_style(&[hidden]); + assert!(st.plain && st.simple, "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; + assert_eq!( + (e.id.as_str(), e.visible, &e.params["radius"], &e.params["extraW"]), + ("convertToShape.roundedRectangle", false, &json!(15.0), &json!(40.0)) + ); +} + +#[test] +fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { + let valid = format!("0 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}"); + assert!(shape_style(&[shape(&valid)]).plain); + for keys in [ + // Another shape, a shape that isn't a whole number or an /Int, and a missing one. + valid.replace("0 /Int (Shape)", "3 /Int (Shape)"), + valid.replace("0 /Int (Shape)", "0.5 /Int (Shape)"), + valid.replace("0 /Int (Shape)", "0 /Real (Shape)"), + valid.replace("0 /Int (Shape) ,\n", ""), + // A mode other than 0 and 1, or a missing one. + valid.replace("0 /Real (Absolute)", "2 /Real (Absolute)"), + valid.replace("0 /Real (Absolute) ,\n", ""), + valid.replace("0 /Real (Absolute)", "0 /Int (Absolute)"), + // Sizes that aren't finite numbers, negative absolute sizes and radii, and huge ones. + valid.replace("20 /Real (RelWidth)", "NaN /Real (RelWidth)"), + valid.replace("6 /Real (RelHeight)", "1e999 /Real (RelHeight)"), + valid.replace("6 /Real (RelHeight)", "(x) /Real (RelHeight)"), + valid.replace("150 /Real (AbsWidth)", "-150 /Real (AbsWidth)"), + valid.replace("15 /Real (CornerRadius)", "-1 /Real (CornerRadius)"), + valid.replace("20 /Real (RelWidth)", "1e6 /Real (RelWidth)"), + valid.replace("40 /Real (AbsHeight)", "40 /Int (AbsHeight)"), + // A key that isn't read. + format!("{valid}1 /Real (Future) ,\n"), + format!("{valid}(x) /String (Future) ,\n"), + // A relative Ellipse. + valid.replace("0 /Int (Shape)", "2 /Int (Shape)"), + ] { + let st = shape_style(&[shape(&keys)]); + assert!(!st.plain, "{keys}: {st:?}"); + } + // On one fill: the drawn look, for type too. + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), shape(&valid)]); + let data = + format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + assert!(!art_styles(data.as_bytes())["Odd"].plain); +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9b453de24..52248aa1b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2,10 +2,10 @@ use std::sync::Arc; -use kurbo::{Point, Rect}; +use kurbo::{Affine, Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; +use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; use super::*; @@ -33,6 +33,13 @@ fn read_ok(body: &str) -> Structure { } } +fn assert_unread(r: &Structure, kind: &str) { + assert_eq!(r.unread.len(), 1); + let (id, kinds) = &r.unread[0]; + assert_eq!(kinds, &[kind]); + assert!(r.doc.node(*id).is_some(), "the unread object has a placeholder"); +} + #[test] fn ruler_units_from_the_header_set_the_documents_units() { let body = layer(""); @@ -47,6 +54,14 @@ fn ruler_units_from_the_header_set_the_documents_units() { assert_eq!(read(with_px.as_bytes()).unwrap().doc.units, Unit::Pixels); } +#[test] +fn arrays_nested_past_the_depth_limit_are_refused() { + let deep = |n: usize| layer(&format!("{}{} pop", "[".repeat(n), "]".repeat(n))); + let nested = |n: usize| read(stream(&deep(n)).as_bytes()).err().is_some_and(|e| e.contains("nested too deeply")); + assert!(!nested(200)); + assert!(nested(100_000)); +} + /// The children of the first layer. fn art(doc: &Document) -> Vec> { doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default() @@ -111,6 +126,109 @@ fn groups_names_hidden_and_locked() { assert!(!hidden.visible); } +/// A node's Attributes panel note (empty: none). +fn note(n: &Node) -> String { + n.extras().attrs.as_deref().map(|a| a.note.clone()).unwrap_or_default() +} + +/// The notes of the first layer's objects, in order. +fn notes(doc: &Document) -> Vec { + art(doc).iter().map(|n| note(n)).collect() +} + +/// A note comment names the paths after it until another one replaces it, and an empty one clears +/// it (§5.3.1: `%AI3_Note: `). +#[test] +fn a_note_comment_is_the_note_of_the_paths_after_it() { + let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0)); + let body = format!("%AI3_Note:hello\n{a}f\n{b}f\n%AI3_Note:\n{c}f\n%AI3_Note:world\n{d}f\n"); + assert_eq!(notes(&read_ok(&layer(&body)).doc), ["hello", "hello", "", "world"]); + // `%%Note:` sets it too. + assert_eq!(notes(&read_ok(&layer(&format!("%%Note:old style\n{a}f\n"))).doc), ["old style"]); + // A path without a note has no attributes. + assert!(art(&read_ok(&layer(&format!("{a}f\n"))).doc)[0].extras().attrs.is_none()); +} + +/// `%AI5_NoteMore:` adds a line to the note; the text is kept as written, and its bytes are read as +/// UTF-8, else Windows-1252. +#[test] +fn a_note_continues_on_more_lines_and_keeps_its_text() { + let a = rect_path(0.0, 0.0, 10.0, 10.0); + let body = format!("%AI3_Note: two (words) caf@ \n%AI5_NoteMore:more\n{a}f\n"); + let data = stream(&layer(&body)).replace('@', "\u{1}").into_bytes(); + let data: Vec = data.into_iter().map(|b| if b == 1 { 0xE9 } else { b }).collect(); + assert_eq!(notes(&read(&data).unwrap().doc), [" two (words) café \nmore"]); + // A continuation of an empty note is its second line; one with no note before it is ignored. + assert_eq!(notes(&read_ok(&layer(&format!("%AI3_Note:\n%AI5_NoteMore:x\n{a}f\n"))).doc), ["\nx"]); + assert_eq!(notes(&read_ok(&layer(&format!("%AI5_NoteMore:x\n{a}f\n"))).doc), [""]); + // A note too long is cut at 1,024 characters, however many lines it has. + let long = format!("%AI3_Note:{}\n{}{a}f\n", "x".repeat(5000), "%AI5_NoteMore:yy\n".repeat(3000)); + assert_eq!(notes(&read_ok(&layer(&long)).doc)[0].chars().count(), 1024); + let long = format!("%AI3_Note:x\n{}{a}f\n", "%AI5_NoteMore:yy\n".repeat(3000)); + assert_eq!(notes(&read_ok(&layer(&long)).doc)[0].chars().count(), 1024); + // A long UTF-8 line stays UTF-8 where it is cut. + let long = format!("%AI3_Note:{}\n{a}f\n", "é".repeat(3000)); + assert_eq!(notes(&read_ok(&layer(&long)).doc), ["é".repeat(1024)]); + // A note on a `%_` line is read too. + assert_eq!(notes(&read_ok(&layer(&format!("%_%AI3_Note:hidden\n{a}f\n"))).doc), ["hidden"]); +} + +/// A note before a group or a compound path isn't theirs: their paths keep their own. +#[test] +fn a_note_before_a_group_or_compound_path_is_not_theirs() { + let [a, b] = [0.0, 20.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0)); + for (open, close) in [("u", "U"), ("*u", "*U")] { + let body = format!("%AI3_Note:outer\n0 Ae\n{open}\n%AI3_Note:inner\n{a}f\n{b}f\n{close}\n"); + let doc = read_ok(&layer(&body)).doc; + let top = &art(&doc)[0]; + assert_eq!(note(top), "", "{open}"); + let kids: Vec = top.children().unwrap().iter().map(|k| note(k)).collect(); + assert_eq!(kids, ["inner", "inner"], "{open}"); + } +} + +/// A clipping path's note is its own, not that of the copy that paints its fill. +#[test] +fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() { + let body = + format!("0 Ae\nq\n{RED}%AI3_Note:clip\n{}W\nf\n%AI3_Note:\n{}f\nQ\n", rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 30.0, 30.0)); + let doc = read_ok(&layer(&body)).doc; + fn walk(nodes: &[Arc], out: &mut Vec<(bool, String)>) { + for n in nodes { + out.push((matches!(n.kind, NodeKind::Path { clipping: true, .. }), note(n))); + walk(n.children().map(Vec::as_slice).unwrap_or_default(), out); + } + } + let mut all = Vec::new(); + walk(&art(&doc), &mut all); + let noted: Vec<_> = all.iter().filter(|(_, n)| n == "clip").collect(); + assert_eq!(noted, [&(true, "clip".to_string())], "{all:?}"); + assert!(all.len() >= 3, "the painted copy is there: {all:?}"); +} + +/// `1 Ap` shows the center point of the paths after it until `0 Ap` (§12, showCenter), which leaves +/// them at an imported path's default: hidden. +#[test] +fn ap_shows_the_center_of_the_paths_after_it() { + let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0)); + let ellipse = "50 0 m\n50 5.5 45.5 10 40 10 c\n34.5 10 30 5.5 30 0 c\n30 -5.5 34.5 -10 40 -10 c\n45.5 -10 50 -5.5 50 0 c\n"; + let body = format!("1 Ap\n{a}f\n{b}f\n0 Ap\n{c}f\n{ellipse}f\n2 Ap\n{a}f\n"); + let doc = read_ok(&layer(&body)).doc; + let shown: Vec<_> = art(&doc).iter().map(|n| (n.extras().attrs.as_deref().and_then(|a| a.show_center), n.shows_center())).collect(); + assert_eq!(shown, [(Some(true), true), (Some(true), true), (None, false), (None, false), (None, false)]); +} + +/// What a skipped section sets doesn't reach the art after it. +#[test] +fn a_note_or_ap_in_a_skipped_section_does_not_reach_the_art_after_it() { + let a = rect_path(0.0, 0.0, 10.0, 10.0); + let body = format!("%AI3_BeginBrushPattern\n1 Ap\n%AI3_Note:brush\n{a}f\n%AI3_EndBrushPattern\n{}", layer(&format!("{a}f\n"))); + let doc = read_ok(&body).doc; + let n = &art(&doc)[0]; + assert_eq!(note(n), ""); + assert!(!n.shows_center()); +} + #[test] fn paths_colours_and_strokes() { let art_ = format!( @@ -151,6 +269,140 @@ fn paths_colours_and_strokes() { assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some()); } +/// Each subpath's anchor kinds, in order. +fn anchor_kinds(n: &Node) -> Vec> { + let Some(path) = n.path_data() else { panic!("{:?}", n.kind) }; + path.subpaths.iter().map(|s| s.anchors.iter().map(|a| a.kind).collect()).collect() +} + +/// A path's anchors at (x, y) in document space, and their handles. +fn anchor_points(n: &Node) -> Vec<[(f64, f64); 3]> { + let Some(path) = n.path_data() else { panic!("{:?}", n.kind) }; + path.subpaths.iter().flat_map(|s| s.anchors.iter()).map(|a| [(a.p.x, a.p.y), (a.h_in.x, a.h_in.y), (a.h_out.x, a.h_out.y)]).collect() +} + +/// A segment operator in lower case makes the anchor it ends on smooth, in upper case a corner +/// (§5.3.2), whether the segment is straight or curved and whatever its handles: the points and +/// handles stay where they are. +#[test] +fn a_segment_operators_case_sets_the_kind_of_the_anchor_it_ends_on() { + use vectorcraft_geom::AnchorKind::{Corner, Smooth}; + let doc = read_ok(&layer("0 0 m\n10 10 20 0 30 0 c\n40 10 50 0 60 0 C\nh\nS\n")).doc; + let a = art(&doc); + assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Smooth, Corner]], "handles that aren't in line stay a smooth point's"); + assert_eq!( + anchor_points(&a[0]), + [[(0.0, 100.0), (0.0, 100.0), (10.0, 90.0)], [(30.0, 100.0), (20.0, 100.0), (40.0, 90.0)], [(60.0, 100.0), (50.0, 100.0), (60.0, 100.0)]] + ); + // `l`, `L`, `v`, `y`, `C` with handles in line (a corner all the same) and `c`, on an open path. + let doc = read_ok(&layer("0 0 m\n10 0 l\n20 0 L\n30 10 40 10 v\n50 10 60 10 y\n70 10 80 10 90 10 C\n100 10 110 0 110 0 c\nS\n")).doc; + let a = art(&doc); + assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Smooth, Corner, Smooth, Smooth, Corner, Smooth]]); + assert_eq!(anchor_points(&a[0])[5], [(90.0, 90.0), (80.0, 90.0), (100.0, 90.0)]); +} + +/// A closed path's first anchor takes the kind of the segment back to it; a path closed without +/// one keeps its first anchor's kind from its handles. +#[test] +fn a_closed_paths_start_takes_the_case_of_the_segment_back_to_it() { + use vectorcraft_geom::AnchorKind::{Corner, Smooth}; + let start = |close: &str| { + let doc = read_ok(&layer(&format!("{close}s\n"))).doc; + anchor_kinds(&art(&doc)[0]) + }; + assert_eq!(start("0 0 m\n40 0 L\n40 40 L\n0 0 l\n"), [vec![Smooth, Corner, Corner]]); + assert_eq!(start("0 0 m\n40 0 l\n40 40 l\n0 0 L\n"), [vec![Corner, Smooth, Smooth]]); + // The start's handles are in line, so it would be smooth by them alone. + assert_eq!(start("0 0 m\n10 0 40 30 40 40 c\n40 50 -10 0 0 0 C\n"), [vec![Corner, Smooth]]); + assert_eq!(start("0 0 m\n10 0 40 30 40 40 C\n40 50 -10 0 0 0 c\n"), [vec![Smooth, Corner]]); + assert_eq!(start("0 0 m\n40 0 L\n40 40 l\nh\n"), [vec![Corner, Corner, Smooth]], "no segment back to the start"); + assert_eq!(start("0 0 m\n40 0 L\n40 40 l\n"), [vec![Corner, Corner, Smooth]], "`s` closes it the same way"); +} + +/// Every `m` starts a subpath of its own, in a compound path's members and within one path. +#[test] +fn each_subpath_takes_the_case_of_its_own_segments() { + use vectorcraft_geom::AnchorKind::{Corner, Smooth}; + let body = "*u\n0 0 m\n10 0 l\n10 10 L\nh\nf\n20 0 m\n30 0 L\n30 10 l\n20 0 l\nf\n*U\n\ + 0 50 m\n10 50 L\n10 60 l\nh\n20 50 m\n30 50 l\n30 60 L\n20 50 l\nf\n"; + let a = art(&read_ok(&layer(body)).doc); + let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert_eq!(anchor_kinds(&children[0]), [vec![Corner, Smooth, Corner]]); + assert_eq!(anchor_kinds(&children[1]), [vec![Smooth, Corner, Smooth]]); + assert_eq!(anchor_kinds(&a[1]), [vec![Corner, Corner, Smooth], vec![Smooth, Smooth, Corner]]); +} + +/// Guide paths and clipping paths keep the case of their segments too. +#[test] +fn guide_and_clipping_paths_take_the_case_of_their_segments() { + use vectorcraft_geom::AnchorKind::{Corner, Smooth}; + let a = art(&read_ok(&layer("(N) *\n10 10 m\n50 10 l\n90 10 L\n(N) *\n")).doc); + assert!(matches!(a[0].kind, NodeKind::Path { guide: true, .. })); + assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Smooth, Corner]]); + let clipped = format!("0 Ae\nq\n{}f\n0 0 m\n30 0 l\n30 30 L\n0 0 l\nh\nW\nn\nQ\n", rect_path(0.0, 0.0, 40.0, 40.0)); + let a = art(&read_ok(&layer(&clipped)).doc); + let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert_eq!(anchor_kinds(&children[0]), [vec![Smooth, Smooth, Corner]]); +} + +/// Operands that don't make a segment leave no kind behind, so the kinds after them stay with +/// their own anchors. +#[test] +fn a_segment_that_isnt_read_leaves_the_kinds_in_step() { + use vectorcraft_geom::AnchorKind::{Corner, Smooth}; + let a = art(&read_ok(&layer("10 c\n0 0 m\n5 l\n10 0 L\n(x) 20 y\n20 0 l\n30 0 L\nS\n")).doc); + assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Corner, Smooth, Corner]]); + // A subpath of a lone `m` is a point of its own. + let a = art(&read_ok(&layer("5 5 m\n0 0 m\n10 0 l\n20 0 L\nS\n")).doc); + assert_eq!(anchor_kinds(&a[0]), [vec![Corner], vec![Corner, Smooth, Corner]]); +} + +/// `0 w` is the thinnest line the device draws (PostScript's `setlinewidth`), so a stroke of width 0 +/// comes in as a hairline: on a path, on a gradient-filled path that `1 BB` strokes, and on type. +#[test] +fn a_line_width_of_zero_is_a_hairline() { + let red = Some(Color::rgb(1.0, 0.0, 0.0)); + let a = art(&read_ok(&layer("0 1 1 0 1 0 0 XA\n2 J\n0 w\n20 20 m\n180 80 L\nS\n")).doc); + assert_eq!(a.len(), 1); + let st = a[0].appearance.stroke().unwrap(); + assert_eq!((st.width, st.paint.color(), st.cap), (0.25, red, LineCap::Square)); + + let art_ = format!( + "0 1 1 0 1 0 0 XA\n0 w\n{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n1 BB\n", + rect_path(50.0, 0.0, 150.0, 100.0) + ); + let a = art(&read_ok(&format!("{G_DEFS}{}", layer(&art_))).doc); + assert!(matches!(fill(&a[0]), Paint::Gradient(_)), "{:?}", fill(&a[0])); + let st = a[0].appearance.stroke().unwrap(); + assert_eq!((st.width, st.paint.color()), (0.25, red)); + + let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n1 Tr\n0 1 1 0 1 0 0 XA\n0 w\n/_Helvetica 9 Tf\n(hi) Tx\nTO\n"; + let a = art(&read_ok(&layer(text)).doc); + let Some(NodeKind::Text(t)) = a.first().map(|n| &n.kind) else { panic!("{a:#?}") }; + let style = &t.runs[0].style; + assert_eq!((style.stroke_width, style.stroke.color(), style.fill.clone()), (0.25, red, Paint::None)); +} + +/// An art style's stroke of width 0 comes in as a hairline too. +#[test] +fn an_art_style_s_line_width_of_zero_is_a_hairline() { + let styles = "%AI9_BeginArtStyles\n/KnownStyle :\n(Thin) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 0 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n"; + let line = "20 20 m\n180 80 L\n"; + let art_ = format!("0 Ae\nu\n0 1 1 0 1 0 0 XA\n0 w\n{line}S\nU\n%_0 1 1 0 1 0 0 XA\n%_0 w\n%_{}S\n1 (Thin) XW\n", line.replace('\n', "\n%_")); + let r = read_ok(&format!("{styles}{}", layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let st = a[0].appearance.stroke().unwrap(); + assert_eq!((st.width, st.paint.color()), (0.25, Some(Color::rgb(1.0, 0.0, 0.0)))); +} + /// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as /// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint. #[test] @@ -281,23 +533,271 @@ fn a_gradient_in_a_patterns_definition_is_placed_from_the_canvas() { /// has that isn't read, where it shows. Its definition costs nothing else. #[test] fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() { - let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\ + let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L frobnicate S\n) &\n] E\n%AI3_EndPattern\n\ %AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n"; let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. }))); for name in ["Old", "Odd", "Flat"] { - let e = read( + let r = read( stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))) .as_bytes(), ) - .unwrap_err(); - assert!(e.contains("pattern fills"), "{name}: {e}"); + .unwrap(); + assert_unread(&r, "pattern fills"); } // Type in a pattern isn't read either. let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n"; assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty()); } +/// A pattern whose definition paints with a pattern it doesn't have isn't kept, on `%_` lines or +/// not: a fill with it is something the file has that isn't read. +#[test] +fn a_pattern_painting_with_an_unread_pattern_is_not_kept() { + for prefix in ["%_", ""] { + let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', &format!("\n{prefix}")); + let def = format!( + "%AI3_BeginPattern: (Nest)\n(Nest) 0 0 8 8\n{prefix}(Missing) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{prefix}{tile}f\nE\n%AI3_EndPattern\n" + ); + let s = read_ok(&format!("{def}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.patterns.is_empty(), "{prefix:?}: {:?}", s.doc.patterns.iter().map(|p| &p.name).collect::>()); + let r = read( + stream(&format!("{def}{}", layer(&format!("(Nest) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))).as_bytes(), + ) + .unwrap(); + assert_unread(&r, "pattern fills"); + // A color set before the tile is drawn replaces the missing pattern, and the pattern is kept. + let repainted = def.replace(&format!("{prefix}{tile}f"), &format!("{prefix}0 g\n{prefix}{tile}f")); + let s = read_ok(&format!("{repainted}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.pattern("Nest").is_some(), "{prefix:?}"); + } +} + +/// A pattern's definition inside another's isn't read, so the outer one isn't kept either. +#[test] +fn a_pattern_with_a_definition_inside_it_isnt_kept() { + let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', "\n%_"); + let inner = "%AI3_BeginPattern: (In)\n(In) 0 0 1 1\n%_0 0 m\n%_1 1 L\n%_S\nE\n%AI3_EndPattern\n"; + let def = format!("%AI3_BeginPattern: (Out)\n(Out) 0 0 8 8\n{inner}%_{tile}f\nE\n%AI3_EndPattern\n"); + let s = read_ok(&format!("{def}{}", layer(""))); + assert!(s.doc.patterns.is_empty(), "{:?}", s.doc.patterns.iter().map(|p| &p.name).collect::>()); + // Without the definition inside it, it is kept. + let s = read_ok(&format!("{}{}", def.replace(inner, ""), layer(""))); + assert!(s.doc.pattern("Out").is_some()); +} + +/// A definition whose art follows its head as operators is still read when that art starts with a +/// dash array (`[]0 d`) off `%_` lines, which isn't a version 7 layer list. +#[test] +fn a_pattern_whose_art_starts_with_a_dash_array_is_read() { + let def = format!("%AI3_BeginPattern: (Dash)\n(Dash) 0 0 8 8 []0 d\n{}f\nE\n%AI3_EndPattern\n", rect_path(0.0, 0.0, 8.0, 8.0)); + let s = read_ok(&format!("{def}{}", layer(""))); + assert_eq!(s.doc.pattern("Dash").map(|p| p.art.len()), Some(1)); +} + +/// Editing data in the version 7 form: `%AI5_FileFormat 2.0`, the art size around the template +/// box's center (an artboard 200 by 100 from the origin), `setup` (pattern definitions), then `body`. +fn pattern_stream7(setup: &str, body: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 2.0\n%AI5_ArtSize: 200 100\n%AI3_TemplateBox: 100 50 100 50\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n{setup}%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n" + ) +} + +/// One layer named `name` with the version 7 form's ten operands before `Lb`, holding `art`. +fn pattern_layer7(name: &str, art: &str) -> String { + format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n({name}) Ln\n0 A\n{art}LB\n%AI5_EndLayer--\n") +} + +/// A definition of `name` in the version 7 form: its 20-point tile, then `layers`, the layer list. +fn pattern7(name: &str, layers: &str) -> String { + format!("%AI3_BeginPattern: ({name})\n({name}) 0 0 20 20 [\n%AI3_Tile\n{layers}] E\n%AI3_EndPattern\n") +} + +fn pattern_read7(setup: &str, body: &str) -> Structure { + match read(pattern_stream7(setup, body).as_bytes()) { + Ok(s) => s, + Err(e) => panic!("{e}"), + } +} + +/// A definition in the version 7 form (§3.4): each layer's colors (`@`) paint its tile's art (`&`), +/// the first layer the lowest. The operator lines in the strings end in CR, and each layer starts +/// from the definition's state, so the second layer's stroke doesn't take the first one's width. +#[test] +fn version_7_pattern_layers_paint_in_order_with_their_own_colors() { + let layers = "(0 O 0 R 1 0 0 0 k\r1 0 0 0 K) @\n(\r%AI6_BeginPatternLayer\r0 J 0 j 3 w 4 M []0 d\r2 2 m\r9 2 L\r9 9 L\r2 9 L\rf\r%AI6_EndPatternLayer\r) &\n\ + (0 O 1 R 0 0 0 1 k\r0 0 0 1 K) @\n(\r2 12 m\r18 12 L\rS\r) &\n"; + let fill_with = format!("(Grid) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 10.0, 90.0, 90.0)); + let s = pattern_read7(&pattern7("Grid", layers), &pattern_layer7("Art", &fill_with)); + assert!(s.unread.is_empty() && s.warnings.is_empty(), "{:?} {:?}", s.unread, s.warnings); + let def = s.doc.pattern("Grid").unwrap(); + assert_eq!(def.tile, Rect::new(0.0, -20.0, 20.0, 0.0), "the tile, y down"); + assert_eq!(def.art.len(), 2, "one path for each layer"); + let (square, line) = (&def.art[0], &def.art[1]); + assert_eq!(square.geometric_bounds(), Some(Rect::new(2.0, -9.0, 9.0, -2.0))); + assert_eq!(fill(square).color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); + assert!(square.appearance.stroke().is_none()); + assert!(line.appearance.fill().is_none()); + let stroke = line.appearance.stroke().unwrap(); + assert_eq!(stroke.paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!(stroke.width, 1.0, "the first layer's width stays with it"); + assert!(stroke.overprint); + assert!(matches!(fill(&art(&s.doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Grid")); +} + +/// `_ &` fills the tile's box with the layer's fill color, without a stroke (§3.4). +#[test] +fn a_version_7_patterns_underbar_fills_the_tile_box() { + let layers = "(0 O 0 R 0 1 0 0 k 0 0 0 1 K) @ _ &\n(0 0 0 1 k 0 0 0 1 K) @ (2 2 m 18 18 L S) &\n"; + let s = pattern_read7(&pattern7("Back", layers), &pattern_layer7("Art", "")); + let def = s.doc.pattern("Back").unwrap(); + let back = &def.art[0]; + assert_eq!(back.geometric_bounds(), Some(def.tile)); + assert_eq!(fill(back).color(), Some(Color::cmyk(0.0, 1.0, 0.0, 0.0))); + assert!(back.appearance.stroke().is_none()); + assert!(def.art[1].appearance.stroke().is_some()); +} + +/// A layer's strings are read with their escapes undone: `\r`, `\n`, an octal tab, a backslash at +/// a line's end, and escaped parentheses around a custom color's name. +#[test] +fn a_version_7_patterns_strings_are_read_with_their_escapes_undone() { + let layers = "(0 O 0 R 0 0 0 1 \\(Ink \\(A\\)\\) 0 x) @\n(2 2 m\\r9 2 L\\n9 9 L\\0112 9 \\\nL f) &\n"; + let s = pattern_read7(&pattern7("Ink", layers), &pattern_layer7("Art", "")); + let def = s.doc.pattern("Ink").unwrap(); + assert_eq!(def.art.len(), 1); + assert_eq!(def.art[0].geometric_bounds(), Some(Rect::new(2.0, -9.0, 9.0, -2.0))); + assert!(s.doc.swatch("Ink (A)").is_some(), "{:?}", s.doc.swatches.iter().map(|w| &w.name).collect::>()); +} + +/// A definition in the version 7 form that can't be read whole isn't kept, and a fill with it is +/// something the file has that isn't read: an operator it doesn't know, type, a definition inside +/// it, colors with other operators, a layer without its tile or its `&`, a string longer than the +/// format allows, an `E`, a group or an array a layer doesn't end, more than 65,536 layers, a list +/// without its `]` or its `E`, anything after its `E`. +#[test] +fn a_version_7_pattern_it_cant_read_whole_isnt_kept() { + let line = "(0 0 m 8 8 L S) &\n"; + let long = format!("(0 g) @ ({}0 0 m 8 8 L S) &\n", " ".repeat(65_536)); + let open = "%AI3_BeginPattern: (P)\n(P) 0 0 20 20 [\n(0 g) @ (0 0 m 8 8 L S) &\n"; + let defs = [ + pattern7("P", "(0 g) @ (0 0 m 8 8 L frobnicate S) &\n"), + pattern7("P", "(0 g) @ (0 To 1 0 0 1 2 2 0 Tp TP (a) Tx TO) &\n"), + pattern7("P", "(0 g) @ (\r%AI3_BeginPattern: (In)\r(In) 0 0 1 1\rE\r%AI3_EndPattern\r0 0 m 8 8 L S) &\n"), + pattern7("P", &format!("%AI3_BeginPattern: (In)\n(In) 0 0 1 1 [\n] E\n%AI3_EndPattern\n(0 g) @ {line}")), + pattern7("P", &format!("(0 g 0 0 m) @ {line}")), + pattern7("P", "(0 g) @\n"), + pattern7("P", "(0 g) @ _\n"), + pattern7("P", &long), + pattern7("P", "(0 g) @ (0 0 m 8 8 L S E) &\n"), + pattern7("P", "(0 g) @ (u 0 0 m 8 8 L S) &\n"), + pattern7("P", "(0 g) @ (0 0 m 8 8 L S [) &\n"), + pattern7("P", "(0 g) @ ([ 0 0 m 8 8 L S) &\n"), + pattern7("P", &"(0 g) @ _ &\n".repeat(65_537)), + format!("{open}E\n%AI3_EndPattern\n"), + format!("{open}]\n%AI3_EndPattern\n"), + format!("{open}] E\n0 0 m 8 8 L S\n%AI3_EndPattern\n"), + ]; + let fill_with = format!("(P) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)); + for (i, def) in defs.iter().enumerate() { + assert!(pattern_read7(def, &pattern_layer7("Art", "")).doc.patterns.is_empty(), "case {i}"); + assert_unread(&pattern_read7(def, &pattern_layer7("Art", &fill_with)), "pattern fills"); + } + // The same definition, whole, is kept. + let s = pattern_read7(&pattern7("P", &format!("(0 g) @ {line}")), &pattern_layer7("Art", &fill_with)); + assert!(s.doc.pattern("P").is_some() && s.unread.is_empty()); +} + +/// With a pattern defined in the version 7 form, a fill's other operands are applied in order +/// before its initial matrix (§5.4.1): the shear along its axis, the reflection about the line at +/// its angle, the rotation, the scale, then the offset. Identity ones move the tiles by the offset +/// alone. `P` places a stroke's tiles the same way. +#[test] +fn a_pattern_fill_applies_its_offset_scale_rotation_reflection_and_shear() { + let def = pattern7("Grid", "(0 g) @ _ &\n"); + // The operands, and the pattern's space → art space they make (a b c d e f). + let cases = [ + ("10 15 1 1 0 0 0 0 0 [1 0 0 1 0 0]", [1.0, 0.0, 0.0, 1.0, 10.0, 15.0]), + ("25 30 1.5 .75 30 1 45 10 0 [1 0 0 1 0 0]", [-0.75, 0.649519, 1.166793, 0.489528, 25.0, 30.0]), + ("-5 7 2 .5 -45 0 0 -15 45 [1 0 0 1 0 0]", [1.793151, -0.353553, 1.035276, 0.353553, -5.0, 7.0]), + ("3 -4 1.2 .8 20 1 30 12 25 [.8 .1 -.2 1.1 4 -6]", [-0.028557, 0.816531, 1.054026, 0.117492, 7.2, -10.1]), + ]; + // Art space → the document (the artboard is 100 high), and a tile's art (y down from its top + // left) → the pattern's space. + let space = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let tile = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 20.0]); + for (ops, want) in cases { + for (op, paint) in [("p", "f"), ("P", "S")] { + let s = pattern_read7(&def, &pattern_layer7("Art", &format!("(Grid) {ops} {op}\n{}{paint}\n", rect_path(10.0, 10.0, 90.0, 90.0)))); + let node = &art(&s.doc)[0]; + let p = if op == "p" { fill(node) } else { node.appearance.stroke().unwrap().paint.clone() }; + let Paint::Pattern { xf, .. } = p else { panic!("{ops} {op}: {p:?}") }; + let got = (space.inverse() * xf * tile.inverse()).as_coeffs(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{ops} {op}: {got:?}"); + } + } + // The tiles' corners: with identity operands but the offset, the tile's top left (0, 20) is at + // (10, 35) in art space; with the last ones, its lower right (20, 0) is at (6.628860, 6.230623). + let placed = |ops: &str| { + let s = pattern_read7(&def, &pattern_layer7("Art", &format!("(Grid) {ops} p\n{}f\n", rect_path(10.0, 10.0, 90.0, 90.0)))); + let Paint::Pattern { xf, .. } = fill(&art(&s.doc)[0]) else { panic!() }; + xf + }; + assert_eq!(placed(cases[0].0) * Point::ZERO, Point::new(10.0, 65.0)); + assert!((placed(cases[3].0) * Point::new(20.0, 20.0)).distance(Point::new(6.628860, 93.769377)) < 1e-5); + // Without its nine operands, a fill with the pattern isn't read. + let r = pattern_read7(&def, &pattern_layer7("Art", &format!("(Grid) 0 0 1 1 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 10.0, 90.0, 90.0)))); + assert_unread(&r, "pattern fills"); +} + +/// With a pattern defined in the newer form (art on `%_` lines), the matrix alone places the tiles +/// (#1025): the other operands don't move them, and a fill without all nine is still read. +#[test] +fn a_newer_pattern_fill_is_placed_by_its_matrix_alone() { + let placed = |ops: &str| { + let s = read_ok(&format!("{DOTS}{}", layer(&format!("(Dots) {ops} p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))))); + assert!(s.unread.is_empty() && s.warnings.is_empty(), "{ops}: {:?} {:?}", s.unread, s.warnings); + let Paint::Pattern { xf, .. } = fill(&art(&s.doc)[0]) else { panic!("{ops}: {:?}", fill(&art(&s.doc)[0])) }; + xf + }; + let plain = placed("0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0]"); + assert_eq!(plain * Point::ZERO, Point::new(0.0, 92.0)); + assert_eq!(placed("25 30 1.5 .75 30 1 45 10 0 [1 0 0 1 0 0]"), plain); + assert_eq!(placed("0 0 1 1 0 0 0 0 [1 0 0 1 0 0]"), plain); +} + +/// An art style `Tiled` whose fill is `(name) {ops} p`. +fn tiled_style(name: &str, ops: &str) -> String { + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Tiled) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n({name}) {ops} p\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// A fill set through an art style on a text object places the tiles as a `p` fill with the same +/// operands does: with the other operands before the matrix for a pattern defined in the version 7 +/// form, by the matrix alone for one defined in the newer form. +#[test] +fn a_style_pattern_fill_is_placed_as_a_direct_one() { + let rect = rect_path(10.0, 20.0, 70.0, 80.0); + // The text object's drawn look (without a pattern to take the placement from), then the object. + let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n"; + let own = "%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_0 /StoryIndex ,\n%_;\n"; + let ops = "25 30 1.5 .75 30 1 45 10 0 [.8 .1 -.2 1.1 4 -6]"; + for (name, def) in [("Grid", pattern7("Grid", "(0 g) @ _ &\n")), ("Dots", DOTS.to_string())] { + let styled = read_ok(&format!("{}{def}{}", tiled_style(name, ops), layer(&format!("{look}{own}1 (Tiled) XW\n")))); + let direct = read_ok(&format!("{def}{}", layer(&format!("({name}) {ops} p\n{rect}f\n")))); + let xf = |s: &Structure| match fill(&art(&s.doc)[0]) { + Paint::Pattern { xf, .. } => xf, + p => panic!("{name}: {p:?} {:?} {:?}", s.unread, s.warnings), + }; + let (got, want) = (xf(&styled).as_coeffs(), xf(&direct).as_coeffs()); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-9), "{name}: {got:?} {want:?}"); + } +} + #[test] fn compound_paths_and_clipping() { let art_ = format!( @@ -330,6 +830,53 @@ fn compound_paths_and_clipping() { assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); } +/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke on the clipping path, and a +/// fill goes on the painted copy under the clipped art. +#[test] +fn a_clipping_paths_stroke_stays_on_the_clipping_path() { + let blue = Some(Color::rgb(0.0, 0.0, 1.0)); + let red = Some(Color::rgb(1.0, 0.0, 0.0)); + // (clipping, fill colour, a gradient fill, stroke colour and width) of each of the clipping group's children. + let children = |doc: &Document| { + let NodeKind::Group { children, clip: true } = &art(doc)[0].kind else { panic!("{:?}", art(doc)[0].kind) }; + children + .iter() + .map(|c| { + let clipping = matches!(c.kind, NodeKind::Path { clipping: true, .. }); + (clipping, fill(c).color(), matches!(fill(c), Paint::Gradient(_)), c.appearance.stroke().map(|s| (s.paint.color(), s.width))) + }) + .collect::>() + }; + let under = rect_path(0.0, 0.0, 30.0, 30.0); + let clip = rect_path(5.0, 5.0, 25.0, 25.0); + let stroked = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nS\nQ\n"); + assert_eq!(children(&read_ok(&layer(&stroked)).doc), [(true, None, false, Some((blue, 3.0))), (false, red, false, None)]); + let both = format!("0 Ae\nq\n{RED}{under}f\n0 1 0 0 0 1 0 Xa\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nB\nQ\n"); + assert_eq!( + children(&read_ok(&layer(&both)).doc), + [(true, None, false, Some((blue, 3.0))), (false, Some(Color::rgb(0.0, 1.0, 0.0)), false, None), (false, red, false, None)] + ); + let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n0 0 0 0 1 0 0 2 0 6 %_BS\n%_0 0 0 0 1 0 0 2 0 6 0 0 Bs\n\ + %_0 0 0 0 0 0 1 2 1 6 100 50 Bs\nBD\n%AI5_EndGradient\n"; + let gradient = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nBb\n0 0 0 0 Bh\n1 (G) 5 5 0 20 1 0 0 1 0 0 1 Bg\nf\n1 BB\nQ\n"); + assert_eq!( + children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc), + [(true, None, false, Some((blue, 3.0))), (false, None, true, None), (false, red, false, None)] + ); + // Several clipping paths clip as one compound path, which keeps their stroke when they all have the same one. + let inner = rect_path(10.0, 10.0, 20.0, 20.0); + let compound_stroke = |first: &str, second: &str| { + let body = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{first}{clip}W\nS\n{second}{inner}W\nS\nQ\n"); + let doc = read_ok(&layer(&body)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }), "{:?}", children[0].kind); + (children[0].appearance.stroke().map(|s| (s.paint.color(), s.width)), children[0].opacity, children[0].blend) + }; + assert_eq!(compound_stroke("", ""), (Some((blue, 3.0)), 1.0, BlendMode::Normal)); + assert_eq!(compound_stroke("9 0.5 0 0 0 Xy\n", ""), (Some((blue, 3.0)), 0.5, BlendMode::Lighten)); + assert_eq!(compound_stroke("", "5 w\n"), (None, 1.0, BlendMode::Normal), "strokes that differ"); +} + /// A clipping group's clipping path can sit alone in a group of its own, after the art it clips (`q … u W n U /// … Q`): it still clips the clipping group. #[test] @@ -391,6 +938,53 @@ fn a_layer_keeps_its_own_transparency() { let doc = read_ok(&layer(&art_)).doc; assert_eq!(doc.layers[0].opacity, 1.0, "no layer state written: the layer stays opaque"); assert_eq!(art(&doc)[0].opacity, 0.5, "the group's own state"); + // A layer whose last object is a gradient mesh: an `Xy` right after the mesh's end is the + // layer's when `LB` follows (directly or after the layer's `Xd` and `7 () XW`), and the mesh's + // when its own `6 () XW` follows. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = format!( + "0 0.8 0 0 0 Xy\n/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + for (after, layer_state, mesh_state) in [ + ("1 0.35 0 0 0 Xy\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n6 () XW\n", (1.0, BlendMode::Normal), (0.35, BlendMode::Multiply)), + // The stroke style in the layer's state block. + ("1 0.35 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ] { + let doc = read_ok(&layer(&format!("{mesh}{after}"))).doc; + let l = &doc.layers[0]; + assert!(matches!(art(&doc)[0].kind, NodeKind::Mesh(_)), "{after:?}"); + assert_eq!((l.opacity, l.blend), layer_state, "the layer's state after {after:?}"); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), mesh_state, "the mesh's state after {after:?}"); + } +} + +#[test] +fn a_layers_state_keeps_through_the_stroke_style_written_in_it() { + // GUARDS: a layer's state block can have the stroke style between its `Xy` and `Xd`; read as + // the art moving on, it lost the layer's opacity and the layer drew opaque. + let state = "0 0.25 0 2 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n7 () XW\n"; + let clip_group = format!( + "q\n{}h\nW\nn\n0 Ae\nu\n{RED}{}f\nU\n9 () XW\nQ\n9 () XW\n{state}", + rect_path(10.0, 10.0, 90.0, 90.0), + rect_path(0.0, 0.0, 100.0, 100.0) + ); + let path = format!("{RED}{}f\n{state}", rect_path(0.0, 0.0, 10.0, 10.0)); + for body in [clip_group, path] { + let doc = read_ok(&layer(&body)).doc; + let l = &doc.layers[0]; + assert_eq!((l.opacity, l.knockout), (0.25, Knockout::Neutral), "{body}"); + assert_eq!(art(&doc)[0].opacity, 1.0, "{body}"); + } + // An object's own state block (`6 () XW`) isn't the layer's. + let note = format!("{RED}{}f\n{}", rect_path(0.0, 0.0, 10.0, 10.0), state.replace("7 () XW", "6 () XW")); + assert_eq!(read_ok(&layer(¬e)).doc.layers[0].opacity, 1.0); } #[test] @@ -410,6 +1004,72 @@ fn transparency_of_objects_and_groups() { assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's"); } +#[test] +fn a_gradient_mesh_takes_the_transparency_written_after_it() { + // GUARDS: a mesh's own state (`Xy`) comes right after its `/End X!`, before `Xd` and `XW`, as a + // group's does after `U`; read as the next object's, a Multiply shadow mesh painted opaque. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = |x: i32| { + format!( + "/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(x, 0, (x, 10), (x + 10, 0)), + corner(x, 40, (x + 10, 40), (x, 30)), + corner(x + 40, 40, (x + 40, 30), (x + 30, 40)), + corner(x + 40, 0, (x + 30, 0), (x + 40, 10)), + ) + }; + let art_ = format!( + "0 1 0 0 0 Xy\n{}1 0.8 0 0 0 Xy\n0 0 Xd\n6 () XW\n{}0 0 Xd\n6 () XW\n2 0.7 0 0 0 Xy\n{RED}{}f\n", + mesh(0), + mesh(50), + rect_path(100.0, 0.0, 110.0, 10.0) + ); + let doc = read_ok(&layer(&art_)).doc; + let a = art(&doc); + assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && matches!(a[1].kind, NodeKind::Mesh(_)), "{a:#?}"); + assert_eq!((a[0].opacity, a[0].blend), (0.8, BlendMode::Multiply), "the state after the mesh is its own"); + assert_eq!((a[1].opacity, a[1].blend), (0.8, BlendMode::Multiply), "the next mesh keeps the current state"); + assert_eq!((a[2].opacity, a[2].blend), (0.7, BlendMode::Screen), "a state after the mesh's `Xd` and `XW` is the next object's"); +} + +#[test] +fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() { + // GUARDS: a mesh with an opacity mask has its state after the mask's dictionary, whose art (on + // `%_` lines) has operators of its own; read as the next object's, each masked mesh in a run + // drew with the blend mode of the one before it. + let corners: String = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"] + .iter() + .map(|g| format!("%_[0.5 1 {g}] /N X#\n")) + .collect(); + let mesh = format!("/Mesh X!\n%_/DeviceGray /CS X#\n{corners}%_/E X#\n/End X!\n"); + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 50 L\n%_40 50 L\n%_40 20 L\n%_f\n%_U\n%_X+\n\ + %_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(60.0, 20.0, 80.0, 40.0)); + for (state, expected) in [("1 0.5 0 0 0 Xy\n", (0.5, BlendMode::Multiply)), ("", (1.0, BlendMode::Overlay))] { + let a = art(&read_ok(&layer(&format!("3 1 0 0 0 Xy\n{mesh}{mask}{state}0 0 Xd\n6 () XW\n{next}"))).doc); + assert_eq!(a.len(), 2, "{a:?}"); + assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && a[0].extras().mask.is_some(), "{:?}", a[0]); + assert_eq!((a[0].opacity, a[0].blend), expected, "{state:?}: the state after the mask is the mesh's"); + assert_eq!((a[1].opacity, a[1].blend), (1.0, BlendMode::Normal), "the next object's own state"); + } +} + +#[test] +fn a_transparency_set_in_a_dictionary_s_art_stays_after_it() { + // GUARDS: an area text's frame (its `/ConfiningPath` art) sets its own transparency; the objects + // after the text, with no `Xy` of their own, draw with it. Read as the dictionary's alone, they + // drew with the Lighten of the path before the text and vanished on a light background. + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n0 Ap\n0 1 0 0 0 Xy\n0 w\n100 0 m\n100 10 L\nn\nX+\n\ + ; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; + let body = format!("9 0.5 0 0 0 Xy\n{RED}{}f\n{text}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0)); + let a = art(&read_ok(&layer(&body)).doc); + let paths: Vec<_> = a.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(paths.len(), 2, "{a:#?}"); + assert_eq!((paths[0].opacity, paths[0].blend), (0.5, BlendMode::Lighten), "the state before the text"); + assert_eq!((paths[1].opacity, paths[1].blend), (1.0, BlendMode::Normal), "the state the text's art set"); +} + #[test] fn gradients() { let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ @@ -575,34 +1235,421 @@ fn artboards() { assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0)); } -/// A variable library in the document data isn't read: a note gives how many variables and data -/// sets it has, counting the elements of the library alone (a data set's element for a variable named -/// `variable` isn't a variable). +/// The document data of artboards side by side, 100 points wide, each with the entries `extra`. +fn boards_with(extra: &[&str]) -> String { + let boards: String = extra + .iter() + .enumerate() + .map(|(i, e)| { + let x = i * 100; + format!( + "%_/Dictionary :\n{e}%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + x + 100 + ) + }) + .collect(); + format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{boards}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n") +} + #[test] -fn a_variable_library_is_noted_with_its_counts() { - use vectorcraft_testkit::ai::variable_library; +fn artboards_keep_their_display_options() { let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); - let notes = |library: &str| read_ok(&format!("{library}{square}")).warnings.into_iter().filter(|w| w.contains("variables")).collect::>(); + let options = |extra: &[&str]| { + read_ok(&format!("{}{square}", boards_with(extra))) + .doc + .artboards + .iter() + .map(|a| (a.show_center_mark, a.show_cross_hairs, a.show_video_safe_areas, a.video_ruler_pixel_aspect_ratio)) + .collect::>() + }; assert_eq!( - notes(&variable_library(&["variable", "Team"], &["Home", "Away", "Third"])), - ["the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them"] + options(&["%_1 /Int (DisplayMark) ,\n%_1.33 /Real (PAR) ,\n", "%_6 /Int (DisplayMark) ,\n%_2 /Real (PAR) ,\n"]), + [(true, false, false, 1.33), (false, true, true, 2.0)], + "each artboard has its own" ); + assert_eq!(options(&["%_7 /Int (DisplayMark) ,\n%_1 /Real (PAR) ,\n"]), [(true, true, true, 1.0)]); + // Values of another type or out of range leave the defaults. + for bad in [ + "%_1 /Real (DisplayMark) ,\n%_1 /Int (PAR) ,\n", + "%_-1 /Int (DisplayMark) ,\n%_20 /Real (PAR) ,\n", + "%_1.5 /Int (DisplayMark) ,\n%_0.05 /Real (PAR) ,\n", + "%_1 2 /Int (DisplayMark) ,\n%_(2) /Real (PAR) ,\n", + ] { + assert_eq!(options(&[bad]), [(false, false, false, 1.0)], "{bad}"); + } +} + +/// The document data with the Document Raster Effects Settings `entries`. +fn raster_settings(entries: &str) -> String { + format!("%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n{entries}%_; (AI Auto Rasterize) ,\n%_; /NotRecorded ,\n%_;\n") +} + +#[test] +fn document_raster_effects_settings_come_from_the_document() { + use vectorcraft_doc::{Background, RasterColorModel, RasterEffectsSettings}; + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let settings = |entries: &str| { + let doc = read_ok(&format!("{}{square}", raster_settings(entries))).doc; + (doc.raster_effects_ppi, doc.raster_effects) + }; + let all = "%_6 /Int (colr) ,\n%_0 /Bool (spot) ,\n%_150 /Int (dpi.) ,\n%_1 /Bool (mask) ,\n%_0 /Bool (alis) ,\n%_7 /Real (padd) ,\n"; assert_eq!( - notes(&variable_library(&["Team"], &["Home"])), - ["the file's variables and data sets weren't read (variables: 1, data sets: 1): the Variables panel doesn't list them"] + settings(all), + ( + 150.0, + RasterEffectsSettings { + color_model: RasterColorModel::Grayscale, + background: Background::Transparent, + anti_alias: false, + clipping_mask: true, + add_around: 7.0, + preserve_spot_colors: false, + } + ) ); - assert!(notes(&variable_library(&[], &[])).is_empty(), "an empty library gives no warning"); - assert!(notes("").is_empty()); - // When the reader can't read the layers, `variables` still counts the library, for the warning - // of the file read from its PDF part. - let unread = stream(&format!("{}{}", variable_library(&["Team"], &["Home", "Away"]), layer("1 Zz\n"))); + let (_, white) = settings("%_0 /Int (colr) ,\n"); + assert_eq!((white.color_model, white.background), (RasterColorModel::Document, Background::White)); + let (_, document) = settings("%_4 /Int (colr) ,\n"); + assert_eq!((document.color_model, document.background), (RasterColorModel::Document, Background::Transparent)); + // Values of another type, out of range or unknown leave the defaults. + let defaults = (72.0, RasterEffectsSettings::default()); + for bad in [ + "%_150 /Real (dpi.) ,\n%_1 /Int (mask) ,\n%_7 /Int (padd) ,\n%_6 /Real (colr) ,\n", + "%_5000 /Int (dpi.) ,\n%_2 /Bool (alis) ,\n%_-1 /Real (padd) ,\n%_3 /Int (colr) ,\n", + "%_0 /Int (dpi.) ,\n%_(0) /Bool (spot) ,\n%_2000 /Real (padd) ,\n%_1 /Int (colr) ,\n", + "%_150.5 /Int (dpi.) ,\n%_0.5 /Bool (mask) ,\n%_4.5 /Int (colr) ,\n", + ] { + assert_eq!(settings(bad), defaults, "{bad}"); + } +} + +/// Editing data whose header has the comments `header` before `%%EndComments`. +fn stream_with_header(header: &str, body: &str) -> String { + stream(body).replacen("%%EndComments", &format!("{header}%%EndComments"), 1) +} + +#[test] +fn the_header_gives_the_author_and_custom_colors() { + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let header = "%%For: (Ann \\(QA\\) Lee) (Studio)\n%%DocumentCustomColors: (Ink A)\n%%+ (Ink B)\n%%+ (Ink C)\n\ + %%CMYKCustomColor: 0.1 0.2 0.3 0.4 (Ink A)\n%%+ 0 1 0 0 (Ink B)\n%%RGBCustomColor: 1 0.5 0 (Ink C)\n\ + %%+ 2 0 0 (Too bright)\n%%CMYKCustomColor: 0.1 0.2 0.3 (Too few)\n%%+ 0 0 0 1 (Unended\n"; + let after = "%%CMYKCustomColor: 0 0 0 1 (After the header)\n%%For: (Someone else) ()\n"; + let r = read(stream_with_header(header, &format!("{after}{square}")).as_bytes()).unwrap(); + assert_eq!(r.doc.metadata.author, "Ann (QA) Lee"); + let ink = |name: &str| r.doc.swatches.iter().find(|s| s.name == name).map(|s| (s.paint.color(), s.global, s.spot)); + assert_eq!(ink("Ink A"), Some((Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)), true, true))); + assert_eq!(ink("Ink B"), Some((Some(Color::cmyk(0.0, 1.0, 0.0, 0.0)), true, true))); + assert_eq!(ink("Ink C"), Some((Some(Color::rgb(1.0, 0.5, 0.0)), true, true))); + for name in ["Too bright", "Too few", "Unended", "After the header"] { + assert_eq!(ink(name), None, "{name}"); + } + // A color the art defines keeps its definition. + let used = layer(&format!("0.5 0 0 0 (Ink A) 0 x\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read(stream_with_header(header, &used).as_bytes()).unwrap(); + let a: Vec<_> = r.doc.swatches.iter().filter(|s| s.name == "Ink A").map(|s| s.paint.color()).collect(); + assert_eq!(a, [Some(Color::cmyk(0.5, 0.0, 0.0, 0.0))]); + // A color with the name of a default swatch takes its place in a file without a palette, + // whether the header or the art defines it. + let red = "%%CMYKCustomColor: 0 0.5 0.5 0 (Red)\n%%+ 0 0 0 1 ([None])\n"; + let r = read(stream_with_header(red, &square).as_bytes()).unwrap(); + let reds: Vec<_> = r.doc.swatches.iter().filter(|s| s.name == "Red").map(|s| (s.paint.color(), s.spot)).collect(); + assert_eq!(reds, [(Some(Color::cmyk(0.0, 0.5, 0.5, 0.0)), true)]); + assert!(matches!(r.doc.swatch("[None]").map(|s| &s.paint), Some(Paint::None)), "a reserved name keeps its swatch"); + let art_red = layer(&format!("0 0.5 0.5 0 (Red) 0 x\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read(stream(&art_red).as_bytes()).unwrap(); + let reds: Vec<_> = r.doc.swatches.iter().filter(|s| s.name == "Red").map(|s| (s.paint.color(), s.spot)).collect(); + assert_eq!(reds, [(Some(Color::cmyk(0.0, 0.5, 0.5, 0.0)), true)]); + // Without the comments: no author, the default swatches. + let plain = read_ok(&square).doc; + assert_eq!(plain.metadata.author, ""); + assert!(plain.swatches.iter().all(|s| !s.name.starts_with("Ink"))); +} + +#[test] +fn an_older_files_template_box_places_its_artboard() { + let old = + format!("%!PS-Adobe-3.0\n%AI5_ArtSize: 600 400\n%%TemplateBox: 300.5 199.5 300.5 199.5\n%%EndComments\n{}", layer("10 10 m\n20 20 L\nS\n")); + let r = read(old.as_bytes()).unwrap(); + assert_eq!(r.doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0)); + assert_eq!(r.template, Some([300.5, 199.5, 300.5, 199.5])); +} + +#[test] +fn art_after_the_printers_crop_marks_reads() { + // GUARDS: the crop marks between `%AI3_BeginCrops` and `%AI3_EndCrops` are skipped, and the art + // after them is read. + let crops = format!("%AI3_BeginCrops\n{}S\n%AI3_EndCrops\n", rect_path(-20.0, -20.0, -10.0, -10.0)); + let doc = read_ok(&format!("{crops}{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))))).doc; + assert_eq!(art(&doc).len(), 1); +} + +/// The document data `library` (a `variableSets` element) read with a layer holding a square: the +/// variables read and the notes about the library. +fn read_library(library: &str) -> (vectorcraft_doc::Variables, Vec) { + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read_ok(&format!("{}{square}", vectorcraft_testkit::ai::variable_library_data(library))); + let notes = r.warnings.into_iter().filter(|w| w.contains("variable library")).collect(); + ((*r.doc.variables).clone(), notes) +} + +/// The note of a library read but for `n` entries. +fn partly_read(n: usize) -> String { + format!("the file's variable library was partly read (entries skipped: {n}): the Variables panel lists the rest") +} + +/// A variable library in the document data comes in with its variables in order, each of the kind +/// its trait gives (a variable without one is of unknown kind), and without bindings. A library +/// read whole gives no note. +#[test] +fn ai_variable_library_definitions() { + use vectorcraft_doc::VariableKind; + let (vars, notes) = read_library(&vectorcraft_testkit::ai::sample_variable_sets("none", &[])); + let kinds: Vec<(&str, VariableKind)> = vars.variables.iter().map(|v| (v.name.as_str(), v.kind)).collect(); + assert_eq!( + kinds, + [ + ("v_text", VariableKind::Text), + ("v_visible", VariableKind::Visibility), + ("v_file", VariableKind::Image), + ("v_graph", VariableKind::Graph), + ("v_unbound", VariableKind::Unknown) + ] + ); + assert!(vars.bindings.is_empty()); + assert!(notes.is_empty(), "{notes:?}"); + // The library of the testkit's text variables, and an empty one. + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read_ok(&format!("{}{square}", vectorcraft_testkit::ai::variable_library(&["variable", "Team"], &["Home", "Away"]))); + let names: Vec<&str> = r.doc.variables.variables.iter().map(|v| v.name.as_str()).collect(); + assert_eq!(names, ["variable", "Team"], "{:?}", r.warnings); + assert_eq!(r.doc.variables.dataset("Away").and_then(|d| d.values.get("Team")), Some(&vectorcraft_doc::DataValue::Text("Away Team".into()))); + let r = read_ok(&format!("{}{square}", vectorcraft_testkit::ai::variable_library(&[], &[]))); + assert_eq!(*r.doc.variables, vectorcraft_doc::Variables::default()); + assert!(!r.warnings.iter().any(|w| w.contains("variable")), "{:?}", r.warnings); +} + +/// The data sets come in in order with their values: text of one paragraph per `p` element (`&`, +/// `<` and accented letters as they are), whether the objects show, and a linked file's path from +/// its URL, kept when it names no file. A variable of unknown kind has no value. +#[test] +fn ai_variable_library_dataset_values() { + use vectorcraft_doc::DataValue; + use vectorcraft_testkit::ai::{SAMPLE_MISSING_FILE, sample_variable_sets}; + let (vars, notes) = read_library(&sample_variable_sets("none", &[])); + assert!(notes.is_empty(), "{notes:?}"); + let names: Vec<&str> = vars.datasets.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["ds_one", "ds_two", "ds_three"]); + let two = &vars.datasets[1].values; + assert_eq!(two.get("v_text"), Some(&DataValue::Text("line one\nline two & < é".into()))); + assert_eq!(two.get("v_visible"), Some(&DataValue::Visible(false))); + assert_eq!(two.get("v_file"), Some(&DataValue::Image(SAMPLE_MISSING_FILE.into()))); + assert!(!std::path::Path::new(SAMPLE_MISSING_FILE).exists()); + assert_eq!(two.len(), 3, "{two:?}"); + let one = &vars.datasets[0].values; + assert_eq!(one.get("v_text"), Some(&DataValue::Text("one".into()))); + assert_eq!(one.get("v_visible"), Some(&DataValue::Visible(true))); + assert_eq!(one.get("v_file"), Some(&DataValue::Image("/Users/test/Art (one)/tile-a.eps".into()))); +} + +/// A data set's value element can carry a namespace prefix. +#[test] +fn ai_variable_library_dataset_values_with_a_prefix() { + use vectorcraft_doc::DataValue; + use vectorcraft_testkit::ai::sample_variable_sets; + let library = sample_variable_sets("none", &[]).replace("%_(v_visible) /String (xmlnode-nodename)", "%_(c:v_visible) /String (xmlnode-nodename)"); + assert!(library.contains("c:v_visible")); + let (vars, notes) = read_library(&library); + assert!(notes.is_empty(), "{notes:?}"); + assert_eq!(vars.datasets[0].values.get("v_visible"), Some(&DataValue::Visible(true))); + assert_eq!(vars.datasets[1].values.get("v_visible"), Some(&DataValue::Visible(false))); +} + +/// The data set shown comes in as the active one, and the lock as it was set. +#[test] +fn ai_variable_library_state() { + use vectorcraft_testkit::ai::sample_variable_sets; + let (vars, notes) = read_library(&sample_variable_sets("variableAndTypes", &[])); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); + assert!(vars.locked, "{notes:?}"); + assert!(notes.is_empty(), "{notes:?}"); + let (vars, _) = read_library(&sample_variable_sets("none", &[])); + assert!(!vars.locked); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); + // A lock VectorCraft doesn't know reads as unlocked, with a note. + let (vars, notes) = read_library(&sample_variable_sets("everything", &[])); + assert!(!vars.locked); + assert_eq!(notes, [partly_read(1)]); + // A data set shown that isn't one of the library's leaves none active. + let library = sample_variable_sets("none", &[]).replace("%_(ds_two) /UnicodeString (xmlnode-nodevalue) ,\n%_2 /Int (xmlnode-nodetype) ,\n%_/Array :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; (i:activeDataSet)", "%_(ds_nine) /UnicodeString (xmlnode-nodevalue) ,\n%_2 /Int (xmlnode-nodetype) ,\n%_/Array :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; (i:activeDataSet)"); + assert!(library.contains("ds_nine")); + let (vars, notes) = read_library(&library); + assert_eq!(vars.active_dataset, None); + assert_eq!(vars.datasets.len(), 3, "{notes:?}"); +} + +/// A value that can't be read is left out with a note, and the values and data sets after it still +/// come in. +#[test] +fn ai_variable_library_bad_sibling() { + use vectorcraft_doc::DataValue; + use vectorcraft_testkit::ai::{XmlValue, sample_variable_sets, xml_element, xml_text}; + // A value for a variable the library doesn't declare, and a second value for `v_visible`. + let more = [xml_element("v_nope", &[], &[xml_text(XmlValue::Str("x"))]), xml_element("v_visible", &[], &[xml_text(XmlValue::Bool(true))])]; + let (vars, notes) = read_library(&sample_variable_sets("none", &more)); + assert_eq!(notes, [partly_read(2)]); + assert_eq!(vars.datasets[1].values.get("v_visible"), Some(&DataValue::Visible(false))); + assert_eq!(vars.datasets[2].values.get("v_text"), Some(&DataValue::Text("three".into()))); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); +} + +/// A graph variable comes in, but its values aren't read: each is left out with a note. +#[test] +fn ai_variable_library_graph_partial() { + use vectorcraft_doc::VariableKind; + use vectorcraft_testkit::ai::{XmlValue, sample_variable_sets, xml_element, xml_text}; + let graph = xml_element("v_graph", &[], &[xml_text(XmlValue::Str("a,b\n1,2"))]); + let (vars, notes) = read_library(&sample_variable_sets("none", &[graph])); + assert_eq!(notes, [partly_read(1)]); + assert_eq!(vars.variable("v_graph").map(|v| v.kind), Some(VariableKind::Graph)); + assert!(vars.datasets.iter().all(|d| !d.values.contains_key("v_graph"))); + assert_eq!(vars.datasets[1].values.len(), 3); +} + +/// Declarations, data sets and values that are malformed, repeated or past the caps are left out +/// and counted; a value is never read as one of another kind. A library nothing can be read from +/// says so. +#[test] +fn ai_variable_library_edge_cases() { + use vectorcraft_doc::{DataValue, VariableKind}; + use vectorcraft_testkit::ai::{XmlValue, xml_element, xml_node, xml_text}; + let set = |locked: &str, declared: Vec, rows: Vec| { + let sets = xml_element("v:sampleDataSets", &[], &rows); + xml_element("variableSets", &[], &[xml_element("variableSet", &[("locked", locked)], &[xml_element("variables", &[], &declared), sets])]) + }; + let var = |name: &str, t: &str| xml_element("variable", &[("varName", name), ("trait", t)], &[]); + let declared = vec![ + var("A", "textcontent"), + // The same name again, no name, an empty one, one that is too long, and an attribute node + // whose name isn't text. + var("A", "visibility"), + xml_element("variable", &[("trait", "textcontent")], &[]), + var(" ", "textcontent"), + var(&"n".repeat(256), "textcontent"), + xml_node(1, "variable", XmlValue::Str(""), &[("varName", XmlValue::Bool(true))], &[]), + // A trait VectorCraft doesn't know: unknown kind. + var("Fancy", "sparkle"), + var("Shown", "visibility"), + var("File", "fileref"), + // Something that isn't a variable. + xml_element("note", &[], &[]), + ]; + let rows = vec![ + xml_element( + "v:sampleDataSet", + &[("dataSetName", "One")], + &[ + xml_element("A", &[], &[xml_element("p", &[], &[xml_text(XmlValue::Unicode(" spaced "))]), xml_element("p", &[], &[])]), + // A value for the unknown kind, a visibility that is text, a file that is a relative + // path. + xml_element("Fancy", &[], &[xml_text(XmlValue::Str("x"))]), + xml_element("Shown", &[], &[xml_text(XmlValue::Str("true"))]), + xml_element("File", &[], &[xml_text(XmlValue::Str("art/tile.eps"))]), + ], + ), + // The same data set again, and one without a name. + xml_element("v:sampleDataSet", &[("dataSetName", "One")], &[]), + xml_element("v:sampleDataSet", &[], &[]), + xml_element( + "v:sampleDataSet", + &[("dataSetName", "Two")], + &[ + // Text whose paragraph holds an element, a file on a drive, a visibility of two texts. + xml_element("A", &[], &[xml_element("p", &[], &[xml_element("b", &[], &[])])]), + xml_element("File", &[], &[xml_text(XmlValue::Str("file:///C:/My%20Art/50%off.eps"))]), + xml_element("Shown", &[], &[xml_text(XmlValue::Bool(true)), xml_text(XmlValue::Bool(false))]), + ], + ), + ]; + let (vars, notes) = read_library(&set("none", declared, rows)); + let kinds: Vec<(&str, VariableKind)> = vars.variables.iter().map(|v| (v.name.as_str(), v.kind)).collect(); + assert_eq!( + kinds, + [("A", VariableKind::Text), ("Fancy", VariableKind::Unknown), ("Shown", VariableKind::Visibility), ("File", VariableKind::Image)] + ); + let names: Vec<&str> = vars.datasets.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["One", "Two"]); + let one = &vars.datasets[0].values; + assert_eq!(one.get("A"), Some(&DataValue::Text(" spaced \n".into()))); + assert_eq!(one.len(), 1, "{one:?}"); + let two = &vars.datasets[1].values; + assert_eq!(two.get("File"), Some(&DataValue::Image("C:/My Art/50%off.eps".into()))); + assert_eq!(two.len(), 1, "{two:?}"); + // 6 declarations, 2 data sets and 5 values. + assert_eq!(notes, [partly_read(13)]); + + // Children that aren't an array: nothing can be read. + let broken = xml_element("variableSets", &[], &[xml_element("variableSet", &[], &[])]).replace( + "%_/Array :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; ,", + "%_/Dictionary :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; ,", + ); + assert!(broken.contains("%_/Dictionary :\n%_; (xmlnode-children)")); + let (vars, notes) = read_library(&broken); + assert_eq!(vars, vectorcraft_doc::Variables::default()); + assert_eq!(notes, ["the file's variable library couldn't be read: the Variables panel doesn't list it"]); + + // Past the caps: 256 variables and 1,000 data sets are kept. + let declared: Vec = (0..257).map(|i| var(&format!("v{i}"), "textcontent")).collect(); + let rows: Vec = (0..1001).map(|i| xml_element("v:sampleDataSet", &[("dataSetName", &format!("d{i}"))], &[])).collect(); + let (vars, notes) = read_library(&set("none", declared, rows)); + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1000)); + assert_eq!(notes, [partly_read(2)]); + // A text longer than the library's 8 MiB is left out, and so is text past what is left of them. + let long = |name: &str, len: usize| { + let text = xml_element("p", &[], &[xml_text(XmlValue::Str(&"x".repeat(len)))]); + xml_element("v:sampleDataSet", &[("dataSetName", name)], &[xml_element("A", &[], &[text])]) + }; + let rows = vec![long("Big", (8 << 20) + 1), long("Half", 5 << 20), long("More", 4 << 20)]; + let (vars, notes) = read_library(&set("none", vec![var("A", "textcontent")], rows)); + let held: Vec<(&str, usize)> = vars.datasets.iter().map(|d| (d.name.as_str(), d.values.len())).collect(); + assert_eq!(held, [("Big", 0), ("Half", 1), ("More", 0)]); + assert_eq!(notes, [partly_read(2)]); + // Linked files on a network share, and on `localhost`. + let file = |name: &str, url: &str| { + xml_element("v:sampleDataSet", &[("dataSetName", name)], &[xml_element("File", &[], &[xml_text(XmlValue::Str(url))])]) + }; + let rows = vec![file("Share", "file://server/My%20Share/a.eps"), file("Local", "file://localhost/Users/test/a.eps")]; + let (vars, notes) = read_library(&set("none", vec![var("File", "fileref")], rows)); + let paths: Vec> = vars.datasets.iter().map(|d| d.values.get("File")).collect(); + assert_eq!(paths, [Some(&DataValue::Image("//server/My Share/a.eps".into())), Some(&DataValue::Image("/Users/test/a.eps".into()))]); + assert!(notes.is_empty(), "{notes:?}"); + // A library whose only node isn't a variable set. + let (_, notes) = read_library(&xml_element("variableSets", &[], &[xml_element("note", &[], &[])])); + assert_eq!(notes, ["the file's variable library couldn't be read: the Variables panel doesn't list it"]); + // A second variable set is left out. + let two_sets = xml_element("variableSets", &[], &[xml_element("variableSet", &[], &[]), xml_element("variableSet", &[], &[])]); + let (_, notes) = read_library(&two_sets); + assert_eq!(notes, ["the file's variable library couldn't be read: the Variables panel doesn't list it"]); +} + +/// When the layers can't be read, or reading stops before the library, [`variables`] still reads +/// it, for the file read from its PDF part. +#[test] +fn ai_variable_library_without_the_layers() { + use vectorcraft_testkit::ai::{sample_variable_sets, variable_library_data}; + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let library = variable_library_data(&sample_variable_sets("variableAndTypes", &[])); + let unread = stream(&format!("{library}{}", layer("1 Zz\n"))); assert!(read(unread.as_bytes()).is_err()); - assert_eq!(variables(unread.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 }); - // The same when the reading stops before it reaches the library (at an `LB` without its layer). - let early = stream(&format!("LB\n{}{square}", variable_library(&["Team"], &["Home", "Away"]))); + let found = variables(unread.as_bytes()); + assert!(found.present && found.variables.locked && found.skipped == 0); + assert_eq!((found.variables.variables.len(), found.variables.datasets.len()), (5, 3)); + assert_eq!(found.variables.active_dataset.as_deref(), Some("ds_two")); + // The reading stops before it reaches the library (at an `LB` without its layer). + let early = stream(&format!("LB\n{library}{square}")); assert!(read(early.as_bytes()).is_err()); - assert_eq!(variables(early.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 }); - assert_eq!(variables(stream(&square).as_bytes()), read::VariableLibrary::default()); + assert_eq!(variables(early.as_bytes()), found); + assert_eq!(variables(stream(&square).as_bytes()), read::VariableRead::default()); } #[test] @@ -869,6 +1916,254 @@ fn a_placed_file_on_commented_lines_is_read() { assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview"); } +/// [`placed`]'s file whose relative folder section holds `folder` (written as it is, parentheses +/// included) instead of `()`. +fn placed_in(folder: &str, path: &str, preview: &[u8]) -> Vec { + let data = placed(true, "[5 0 0 -5 0 200]", path, Some(preview), ""); + let (at, len) = (data.windows(4).position(|w| w == b"\r()\r").unwrap(), 4); + let mut out = data[..at].to_vec(); + out.extend_from_slice(format!("\r{folder}\r").as_bytes()); + out.extend_from_slice(&data[at + len..]); + out +} + +/// The relative path of the one linked image of `data`. +fn relative_of(data: &[u8]) -> Option { + let r = read(data).unwrap(); + let a = art(&r.doc); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some("/old/place/src/photo.png")); + im.link.as_ref().and_then(|l| l.relative.clone()) +} + +#[test] +fn a_placed_file_keeps_its_folder_relative_to_the_document() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let path = "/old/place/src/photo.png"; + assert_eq!(relative_of(&placed_in("(src/)", path, &preview)).as_deref(), Some("src/photo.png")); + assert_eq!(relative_of(&placed_in("(../art)", path, &preview)).as_deref(), Some("../art/photo.png")); + assert_eq!(relative_of(&placed_in(r"(..\\art\\)", path, &preview)).as_deref(), Some("../art/photo.png")); + assert_eq!(relative_of(&placed_in("( art /)", path, &preview)).as_deref(), Some(" art /photo.png"), "spaces are part of names"); + // On `%_` lines too. + assert_eq!(relative_of(&hide_placed(&placed_in("(src/)", path, &preview))).as_deref(), Some("src/photo.png")); + // No folder, an absolute one, one too long or not a string: the path alone, and the file still + // opens. + let long = format!("({})", "a/".repeat(3000)); + for folder in ["()", "(/elsewhere/)", "(C:\\\\art\\\\)", long.as_str(), "12", "/src"] { + assert_eq!(relative_of(&placed_in(folder, path, &preview)), None, "{folder}"); + } + // A section that doesn't end leaves the folder unread. + let data = placed_in("(src/)", path, &preview); + let end = data.windows(27).position(|w| w == b"%AI28_EndPlacedRelativePath").unwrap(); + let mut cut = data[..end].to_vec(); + cut.extend_from_slice(b"%AI28_Other"); + cut.extend_from_slice(&data[end + 27..]); + assert_eq!(relative_of(&cut), None); +} + +#[test] +fn hidden_placed_file_ended_by_comment_stays_hidden() { + let mut data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", None, "")); + let end = data.windows(4).position(|w| w == b"%_~\r").unwrap(); + data.drain(end..end + 4); + let r = read(&data).unwrap(); + assert!(art(&r.doc).is_empty()); + assert!(r.unread.is_empty(), "hidden placement has no shown placeholder"); + assert!(r.hidden_unread.contains("placed files")); +} + +#[test] +fn previewless_placement_records_box_corner_as_alternative() { + let mut data = placed(true, "[1 0 0 -1 -7970 8070]", "missing.png", None, ""); + let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap(); + data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n"); + let r = read(&data).unwrap(); + assert_unread(&r, "placed files"); + assert_eq!(r.unread_elsewhere.len(), 1); + assert_eq!(r.unread_elsewhere[0].0, r.unread[0].0); + assert_ne!(r.unread_elsewhere[0].1, r.doc.node(r.unread[0].0).unwrap().visual_bounds().unwrap()); +} + +/// Editing data in the version 7 format (§2): art size 200 × 100 around the template box's centre +/// (0, 0), then `body`. +fn placed_stream7(body: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%%BoundingBox: 0 0 200 100\n%AI5_FileFormat 2.0\n%AI5_ArtSize: 200 100\n\ + %AI3_TemplateBox: 0 0 0 0\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n" + ) +} + +/// One layer of the version 7 format (ten operands before `Lb`), showing or not, holding `art`. +fn placed_layer7(visible: bool, art: &str) -> String { + format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 79 128 255 Lb\n(L) Ln\n0 A\n{art}\nLB\n%AI5_EndLayer--\n", u8::from(visible)) +} + +fn placed_read7(visible: bool, art: &str) -> Result { + read(placed_stream7(&placed_layer7(visible, art)).as_bytes()) +} + +/// The placed file's box (`llx lly urx ury`) taken by `matrix` into the document: where the +/// version 7 format puts it (§16.1). +fn placed_box(r: &Structure, matrix: [f64; 6], b: [f64; 4]) -> Rect { + (r.to_doc * kurbo::Affine::new(matrix)).transform_rect_bbox(Rect::new(b[0], b[1], b[2], b[3])) +} + +/// The placeholder's bounds and the other bounds [`Structure::unread_elsewhere`] keeps for it. +fn placeholder_boxes(r: &Structure) -> Vec { + let id = r.unread[0].0; + let mut boxes = vec![r.doc.node(id).unwrap().visual_bounds().unwrap()]; + boxes.extend(r.unread_elsewhere.iter().filter(|(o, _)| *o == id).map(|(_, b)| *b)); + boxes +} + +fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6) +} + +#[test] +fn version7_placed_files_are_read_without_a_begin_place() { + // `~` was an operator the reader didn't know, so the file's layers weren't read. + let matrices = [ + [1.0, 0.0, 0.0, 1.0, 10.0, 20.0], + [2.0, 0.0, 0.0, 0.5, -5.0, 3.0], + [-1.29904, 0.75, 0.375, 0.649519, 125.8461, 159.7668], + [-1.0, 0.0, 0.0, 1.0, 100.0, 0.0], + ]; + for m in matrices { + for extra in ["", " 4 4 0"] { + let [a, b, c, d, e, f] = m; + let body = format!( + "[{a} {b} {c} {d} {e} {f}] 10 20 50 50{extra} (art.eps) `\n%%IncludeFile: art.eps\n~\n{RED}{}f", + rect_path(0.0, 0.0, 5.0, 5.0) + ); + let r = placed_read7(true, &body).unwrap_or_else(|e| panic!("{m:?}{extra}: {e}")); + assert_eq!(art_kinds(&r.doc), ["path", "path"], "{m:?}{extra}: a placeholder, then the path after it"); + assert_unread(&r, "placed files"); + let want = placed_box(&r, m, [10.0, 20.0, 50.0, 50.0]); + assert!(placeholder_boxes(&r).iter().any(|b| near(*b, want)), "{m:?}{extra}: {:?} {want:?}", placeholder_boxes(&r)); + } + } +} + +/// The kinds of the first layer's objects. +fn art_kinds(doc: &Document) -> Vec<&'static str> { + art(doc) + .iter() + .map(|n| match n.kind { + NodeKind::Group { .. } => "group", + NodeKind::Path { .. } => "path", + NodeKind::Image(_) => "image", + _ => "other", + }) + .collect() +} + +#[test] +fn version7_placed_file_included_whole_is_read() { + // The included document's operators, a `~`, a nested document and counted data in it aren't + // the editing data's. + let eps = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 50 50\n%%EndComments\n1 0 0 setrgbcolor frobnicate ~\n\ + %%BeginDocument: inner.eps\n~\n%%EndDocument\n%%BeginData: 14 Binary Bytes\n%%EndDocument\n%%EOF\n"; + let body = format!("[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%BeginDocument: art.eps\n{eps}%%EndDocument\n~\n"); + let r = placed_read7(true, &body).unwrap(); + assert_eq!(art_kinds(&r.doc), ["path"]); + assert_unread(&r, "placed files"); + // One that doesn't end leaves the layer without its end. + let cut = format!("[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%BeginDocument: art.eps\n{eps}~\n"); + assert!(placed_read7(true, &cut).is_err()); +} + +#[test] +fn version7_placed_file_without_a_file_is_read() { + // `` ` `` followed by `~`: a placed file whose file isn't there. + let r = placed_read7(true, "[1 0 0 1 10 20] 10 20 50 50 (gone.eps) `\n~\n").unwrap(); + assert_unread(&r, "placed files"); + // In a hidden layer it is left out, with a note. + let r = placed_read7(false, "[1 0 0 1 10 20] 10 20 50 50 (gone.eps) `\n%%IncludeFile: gone.eps\n~\n").unwrap(); + assert!(r.unread.is_empty() && art(&r.doc).is_empty()); + assert!(r.hidden_unread.contains("placed files"), "{:?}", r.hidden_unread); +} + +#[test] +fn version7_placed_file_in_a_group_keeps_its_place() { + // In a group, the group is what isn't read. + let body = format!("u\n{RED}{}f\n[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%IncludeFile: art.eps\n~\nU\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let r = placed_read7(true, &body).unwrap(); + assert_eq!(art_kinds(&r.doc), ["group"]); + assert_eq!(r.unread.iter().map(|(id, kinds)| (*id, kinds.clone())).collect::>(), [(art(&r.doc)[0].id, vec!["placed files".to_string()])]); + // A locked one keeps its lock. + let r = placed_read7(true, "1 A\n[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%IncludeFile: art.eps\n~\n").unwrap(); + assert_unread(&r, "placed files"); + assert!(r.doc.node(r.unread[0].0).unwrap().locked); +} + +#[test] +fn placed_file_operands_of_another_shape_arent_read() { + for operands in [ + // Five numbers in the matrix, three box numbers, two extra numbers, no path, a name for the + // path, a number that isn't finite. + "[1 0 0 1 10] 10 20 50 50 (a.eps)", + "[1 0 0 1 10 20] 10 20 50 (a.eps)", + "[1 0 0 1 10 20] 10 20 50 50 4 4 (a.eps)", + "[1 0 0 1 10 20] 10 20 50 50", + "[1 0 0 1 10 20] 10 20 50 50 /a.eps", + "[1 0 0 1 10 20] 10 20 50 1e999 (a.eps)", + ] { + // Without a box to put a placeholder in, the file isn't read. + let e = placed_read7(true, &format!("{operands} `\n~\n")).err().unwrap_or_default(); + assert!(e.contains("placed files"), "{operands}: {e}"); + } +} + +#[test] +fn a_placed_file_with_a_zero_area_box_or_a_second_tick_isnt_read() { + for body in [ + "[1 0 0 1 10 20] 10 20 10 50 (a.eps) `\n~\n", + // The operands of a second `` ` `` before `~` don't take the first one's place. + "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n[1 0 0 1 0 0] 0 0 5 5 (b.eps) `\n~\n", + ] { + let e = placed_read7(true, body).err().unwrap_or_default(); + assert!(e.contains("placed files"), "{body}: {e}"); + } + // A `%AI5_BeginPlace` while one is open: the open one isn't read either. + let body = "%AI5_BeginPlace\n[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n%AI5_BeginPlace\n[1 0 0 1 0 0] 0 0 5 5 (b.eps) `\n~\n%AI5_EndPlace\n"; + assert!(placed_read7(true, body).err().unwrap_or_default().contains("placed files")); + let r = placed_read7(false, body).unwrap(); + assert!(r.hidden_unread.contains("placed files") && art(&r.doc).is_empty()); + // One that no `~` ends isn't read; a hidden one, or one on a layer that doesn't show, is left + // out with a note. + let open = "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n"; + assert!(placed_read7(true, &format!("%AI5_BeginPlace\n{open}")).err().unwrap_or_default().contains("placed files")); + assert!(placed_read7(true, open).err().unwrap_or_default().contains("placed files")); + for (visible, art) in [(true, format!("%_%AI5_BeginPlace\n%_{open}")), (true, format!("%_{open}")), (false, open.to_string())] { + let r = placed_read7(visible, &art).unwrap_or_else(|e| panic!("{art}: {e}")); + assert!(r.hidden_unread.contains("placed files"), "{art}: {:?}", r.hidden_unread); + } +} + +#[test] +fn a_tilde_without_a_placed_file_is_an_unknown_operator() { + let e = placed_read7(true, "~\n").err().unwrap_or_default(); + assert!(e.contains('~'), "{e}"); + // An apostrophe isn't the placed file's operator. + let e = placed_read7(true, "[1 0 0 1 10 20] 10 20 50 50 (a.eps) '\n~\n").err().unwrap_or_default(); + assert!(e.contains('\''), "{e}"); +} + +/// A previewless locked placement keeps its edit lock on the page-art placeholder. +#[test] +fn previewless_placement_placeholder_inherits_lock() { + let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, ""); + let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap(); + data.splice(at..at, *b"1 A\n"); + let result = read(&data).unwrap(); + assert_unread(&result, "placed files"); + let node = result.doc.node(result.unread[0].0).unwrap(); + assert!(node.locked); + assert!(node.visible); +} + #[test] fn placed_files_are_linked_images_showing_their_preview() { // A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels. @@ -898,8 +2193,7 @@ fn placed_files_are_linked_images_showing_their_preview() { let xf = pixels(&r.doc).1; assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0))); - // Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or - // a matrix, the file isn't read from its editing data. + // A placed file with a box but no usable preview is marked for page-art replacement. let cut = &preview[..preview.len() - 6]; let huge = b"70000 70000 280000 32 17 \0\0\0\0"; let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0"; @@ -914,8 +2208,13 @@ fn placed_files_are_linked_images_showing_their_preview() { ("[0 0 0 0 0 0]", "a.png", Some(&preview[..])), ("[1 2 3]", "a.png", Some(&preview[..])), ] { - let e = read(&placed(true, matrix, path, preview, "")).unwrap_err(); - assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}"); + let result = read(&placed(true, matrix, path, preview, "")); + if matrix == "[0 0 0 0 0 0]" || matrix == "[1 2 3]" { + let e = result.err().unwrap_or_else(|| panic!("expected missing bounds for {matrix} {path}")); + assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}"); + } else { + assert_unread(&result.unwrap(), "placed files"); + } } // On a layer that doesn't show, such a file is left out of it. let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap(); @@ -953,12 +2252,119 @@ fn text_objects_are_slots_named_after_their_story() { assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7)))); } +/// A file in the legacy format (version 7) whose layer holds `art`, crop marks 0 0 200 100. +fn type_stream7(art: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n", + layer(art) + ) +} + +/// The text objects of a legacy-format file whose layer holds `art`. +fn legacy_texts(art: &str) -> Vec { + let doc = match read(type_stream7(art).as_bytes()) { + Ok(r) => r.doc, + Err(e) => panic!("{e}"), + }; + art_texts(&doc) +} + +fn art_texts(doc: &Document) -> Vec { + art(doc).iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect() +} + +/// Point type at (10, 50): `attrs`, then the characters `text`. +fn point_type(attrs: &str, text: &str) -> String { + format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n{attrs}\n({text}) Tx\nTO\n") +} + +/// `TC` and `TW` (§15.4.7) set the paragraph's letter and word spacing, `Tq` its hanging +/// punctuation and `Th` (§22) its hyphenation; each paragraph keeps the values in force at its +/// first character, and they last into the next text object. +#[test] +fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() { + let two_paragraphs = + point_type("10 15 20 TC\n70 110 140 TW\n1 Tq\n1 1 2 3 4 Th", "One\\r").replace("TO\n", "0 0 5 TC\n0 Tq\n0 0 4 5 6 Th\n(Two) Tx\nTO\n"); + let texts = legacy_texts(&format!("{two_paragraphs}{}", point_type("", "Three"))); + assert_eq!(texts.len(), 2); + let one = texts[0].para_at(0); + assert_eq!(one.justification.letter_spacing, [10.0, 15.0, 20.0]); + assert_eq!(one.justification.word_spacing, [70.0, 110.0, 140.0]); + assert!(one.roman_hanging && one.hyphenate); + let h = &one.hyphenation; + assert_eq!((h.after_first, h.before_last, h.hyphen_limit), (2, 3, 4)); + assert_eq!(h.words_longer_than, vectorcraft_doc::text::HyphenationOptions::default().words_longer_than, "no operand sets it"); + let two = texts[0].para_at(1); + assert_eq!(two.justification.letter_spacing, [0.0, 0.0, 5.0]); + assert_eq!(two.justification.word_spacing, [70.0, 110.0, 140.0], "word spacing stays"); + assert!(!two.roman_hanging && !two.hyphenate); + // Without the limit flag, any number of hyphens in a row. + assert_eq!((two.hyphenation.after_first, two.hyphenation.before_last, two.hyphenation.hyphen_limit), (4, 5, 0)); + // The next text object keeps them. + assert_eq!(texts[1].para_at(0), two); +} + +/// A legacy attribute whose operands are missing, are not numbers, or are flags other than 0 and +/// 1 leaves the attribute as it was, and operands are read by their place. +#[test] +fn legacy_type_attributes_with_bad_operands_stay_as_they_were() { + let set = "10 15 20 TC\n70 110 140 TW\n1 Tq\n1 1 2 3 4 Th\n2 Ta\n0 TA\n5 6 7 Ti"; + let bad = "/x 15 20 TC\n110 140 TW\n2 Tq\n1 2 2 3 4 Th\n1 1 (2) 3 4 Th\n/x Ta\n2 TA\n5 /x 7 Ti"; + let texts = legacy_texts(&point_type(&format!("{set}\n{bad}"), "Hi")); + let p = texts[0].para_at(0); + assert_eq!(p.justification.letter_spacing, [10.0, 15.0, 20.0]); + assert_eq!(p.justification.word_spacing, [70.0, 110.0, 140.0]); + assert!(p.roman_hanging && p.hyphenate); + assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (2, 3, 4)); + assert_eq!(p.justify, vectorcraft_doc::Justify::Right); + assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0)); + assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off"); + // A scale or leading pair with a bad operand leaves both as they were; one operand is the + // horizontal scale (or the leading) of the format before version 7. + let texts = legacy_texts(&point_type("50 80 Tz\n14 3 Tl\n/x 120 Tz\n/x 20 Tl", "Hi")); + let st = &texts[0].runs[0].style; + assert_eq!((st.h_scale, st.v_scale, st.leading), (50.0, 80.0, Some(14.0))); + assert_eq!(texts[0].para_at(0).space_before, 3.0); + let st = &legacy_texts(&point_type("50 80 Tz\n120 Tz", "Hi"))[0].runs[0].style; + assert_eq!((st.h_scale, st.v_scale), (120.0, 100.0)); + // A start point that isn't a number leaves the matrix. + let good = &legacy_texts(&point_type("", "Hi"))[0]; + let bad = &legacy_texts(&point_type("", "Hi").replace("10 50 0 Tp", "10 50 /x Tp"))[0]; + assert_eq!(bad.xf, good.xf); +} + +/// Spacing is kept to the ranges the Justification options take, a Minimum, Desired and Maximum +/// out of order go back to their defaults, and hyphenation's letter counts and limit to the ranges +/// `text.setFormat` takes. +#[test] +fn legacy_type_spacing_and_hyphenation_are_kept_in_range() { + let texts = legacy_texts(&point_type("-500 0 900 TC\n0 2000 5000 TW\n1 1 0 40 99 Th", "Hi")); + let p = texts[0].para_at(0); + assert_eq!(p.justification.letter_spacing, [-100.0, 0.0, 500.0]); + assert_eq!(p.justification.word_spacing, [0.0, 1000.0, 1000.0]); + assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (1, 15, 25)); + let texts = legacy_texts(&point_type("5 0 0 TC\n200 100 80 TW", "Hi")); + let j = &texts[0].para_at(0).justification; + let d = vectorcraft_doc::text::JustificationOptions::default(); + assert_eq!((j.letter_spacing, j.word_spacing), (d.letter_spacing, d.word_spacing)); +} + +/// A file's legacy type is read up to its allowance of characters, a single long run included. +#[test] +fn one_long_run_of_legacy_type_stops_at_the_allowance() { + // The reader's allowance, `MAX_CHARS`. + const ALLOWANCE: usize = 1 << 22; + let long = "a".repeat(ALLOWANCE + 10); + let texts = legacy_texts(&point_type("", &long)); + assert_eq!(texts[0].plain_text().chars().count(), ALLOWANCE); +} + #[test] fn what_it_doesnt_read_is_an_error_where_it_shows() { let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err(); assert!(e.contains("`frobnicate`"), "{e}"); - let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err(); - assert!(e.contains("pattern fills"), "{e}"); + let r = read(stream(&layer(&format!("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))).as_bytes()).unwrap(); + assert_unread(&r, "pattern fills"); let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err(); assert!(e.contains("symbols"), "{e}"); // A mask with no owner must still be reported (#2004). @@ -980,6 +2386,220 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() { read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer(""))); } +#[test] +fn a_group_still_open_at_the_end_is_an_error() { + let square = format!("{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + for open in ["u", "q", "*u", "%_u"] { + let e = read(stream(&format!("{}{open}\n{square}", layer(""))).as_bytes()).unwrap_err(); + assert_eq!(e, "it ends inside a group or compound path", "{open}"); + } + // A group open inside a layer that doesn't end is the layer's error. + let unended = format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\nu\n{square}"); + assert_eq!(read(stream(&unended).as_bytes()).unwrap_err(), "it ends inside a layer"); + // Patterns read with a group of their own still read. + assert!(read_ok(&format!("{DOTS}{}", layer(""))).doc.pattern("Dots").is_some()); +} + +#[test] +fn unresolved_pattern_reset_before_group_path_keeps_live_art() { + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!("u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{reset}\n{}{draw}\nU\n", rect_path(0.0, 0.0, 4.0, 4.0),); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread); + let children = art(&result.doc); + assert_eq!(children.len(), 1, "{operator}: group count"); + let group_art = children[0].children().expect("group children"); + assert_eq!(group_art.len(), 1, "{operator}: live path count"); + assert!(matches!(group_art[0].kind, NodeKind::Path { .. })); + } +} + +#[test] +fn unresolved_pattern_in_dictionary_does_not_mark_following_path_unread() { + for (operator, draw) in [("p", "f"), ("P", "S")] { + let body = format!("/Unused :\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread); + let children = art(&result.doc); + assert_eq!(children.len(), 1, "{operator}: live path count"); + assert!(matches!(children[0].kind, NodeKind::Path { .. })); + } +} + +#[test] +fn color_in_dictionary_does_not_clear_outer_unresolved_pattern() { + for (operator, draw, color) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n/Unused :\n{color}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),); + let result = read_ok(&layer(&body)); + assert_unread(&result, "pattern fills"); + assert_eq!(art(&result.doc).len(), 1, "{operator}: unread path placeholder"); + } +} + +#[test] +fn gradient_instance_after_unresolved_pattern_is_read() { + let defs = + "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + for (operator, flag, draw) in [("p", "", "f"), ("P", "1 ", "S")] { + let body = format!( + "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\n{}{flag}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\n{draw}\n0 BB\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read(stream(&format!("{defs}{}", layer(&body))).as_bytes()).unwrap(); + assert_unread(&result, "pattern fills"); + let a = art(&result.doc); + assert_eq!(a.len(), 2, "{operator}"); + assert_eq!(result.unread[0].0, a[0].id, "{operator}: only the pattern's path is unread"); + } +} + +#[test] +fn unresolved_pattern_in_mask_art_stays_unread() { + let mask_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::(); + let patterned = format!("%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{mask_path}%_f\n"); + // A placed file with no preview, and a symbol whose definition the file doesn't have. + let placed = "%_%AI5_BeginPlace\n%_%%FileType: 1\n%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\n%_(missing.png)`\n%_%%IncludeFile:missing.png\n%_~\n%_%AI5_EndPlace\n"; + let symbol = "%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n"; + for (art_, kind) in [ + (patterned.as_str(), "pattern fills"), + ("%_/Mesh X!\n%_/End X!\n", "gradient meshes it can't read"), + (placed, "placed files"), + (symbol, "symbols"), + ("%_1 2 frobnicate\n", "`frobnicate`"), + ] { + let mask = format!( + "%_/ArtDictionary :\n%_/Mask :\n%_X=\n{art_}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n" + ); + let masked = format!("{RED}{}f\n{mask}", rect_path(0.0, 0.0, 4.0, 4.0)); + let e = read(stream(&layer(&masked)).as_bytes()).unwrap_err(); + assert!(e.contains(kind), "{e}"); + // In a group, the group is the unread object. + let result = read_ok(&layer(&format!("u\n{masked}U\n"))); + assert_unread(&result, kind); + assert!(result.hidden_unread.is_empty(), "{kind}: {:?}", result.hidden_unread); + // A disabled mask doesn't change how its object looks: what it holds is left out. + let disabled = masked.replace("%_1 /Bool (Linked) ,\n", "%_1 /Bool (Linked) ,\n%_1 /Bool (Disabled) ,\n"); + for body in [disabled.clone(), format!("u\n{disabled}U\n")] { + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{kind}: {:?}", result.unread); + assert!(result.hidden_unread.contains(kind), "{kind}: {:?}", result.hidden_unread); + } + } + // Operators a mask's art doesn't know are kept up to 16. + let ops = (0..40).map(|i| format!("%_op{i}\n")).collect::(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{ops}%_X+\n%_(Art) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let result = read_ok(&layer(&format!("u\n{RED}{}f\n{mask}U\n", rect_path(0.0, 0.0, 4.0, 4.0)))); + assert_eq!(result.unread.len(), 1); + assert_eq!(result.unread[0].1.len(), 16); +} + +#[test] +fn hidden_unread_art_beside_drawn_look_needs_no_page_replacement() { + let hidden_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::(); + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!( + "u\n%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{hidden_path}%_{draw}\n{reset}\n{}{draw}\nU\n", + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread); + assert!(result.hidden_unread.contains("pattern fills")); + let children = art(&result.doc); + let group_art = children[0].children().expect("group children"); + assert_eq!(group_art.len(), 2); + assert!(matches!(group_art[1].kind, NodeKind::Path { .. })); + } +} + +#[test] +fn unknown_operator_on_hidden_line_does_not_mark_drawn_group_unread() { + let body = format!("u\n%_1 2 frobnicate\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{:?}", result.unread); + assert!(result.hidden_unread.contains("`frobnicate`")); + assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1); +} + +/// A commented closing delimiter does not hide a visible group's unread content. +#[test] +fn visible_group_closed_on_hidden_line_stays_unread() { + let body = format!("u\n1 2 frobnicate\n{RED}{}f\n%_U\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 1); + assert!(result.unread[0].1.contains(&"`frobnicate`".to_string())); + assert!(!result.hidden_unread.contains("`frobnicate`")); +} + +/// `Xw` hides unread objects even when their operators are on ordinary lines. +#[test] +fn xw_hidden_unread_keeps_visible_group_sibling() { + for unread in + ["1 2 frobnicate\n", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n", "/SymbolInstance :\n(Absent) /SymbolRef ,\n;\n"] + { + let body = format!("u\n1 Xw\n{unread}0 Xw\n0 0 0 1 k\n{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{unread}: {:?}", result.unread); + assert!(!result.hidden_unread.is_empty(), "{unread}"); + let children = art(&result.doc); + let group_art = children[0].children().expect("group children"); + assert_eq!(group_art.iter().filter(|node| node.visible).count(), 1, "{unread}"); + } + let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "0 Xw\n"); + let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap(); + data.splice(at..at, *b"1 Xw\n"); + let result = read(&data).unwrap(); + assert!(result.unread.is_empty()); + assert!(result.hidden_unread.contains("placed files")); +} + +#[test] +fn hidden_unread_symbol_does_not_mark_drawn_group_unread() { + let body = format!("u\n%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{:?}", result.unread); + assert!(result.hidden_unread.contains("symbols")); + assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1); +} + +#[test] +fn unresolved_pattern_paint_persists_past_plain_group_until_replaced() { + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!( + "u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nU\n{}{draw}\n{reset}\n{}{draw}\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + rect_path(20.0, 0.0, 24.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 2, "{operator}: {:?}", result.unread); + assert_eq!(art(&result.doc).len(), 3); + } +} + +#[test] +fn unresolved_pattern_paint_inside_clip_restores_at_q() { + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!( + "q\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nQ\n{}{draw}\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread); + assert_eq!(art(&result.doc).len(), 2); + + let body = format!( + "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\nq\n{reset}\n{}{draw}\nQ\n{}{draw}\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread); + assert_eq!(result.unread[0].0, art(&result.doc)[1].id); + } +} + #[test] fn sections_it_skips() { let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\ @@ -1359,6 +2979,19 @@ fn many_zstandard_frames_are_read_in_one_pass_and_capped() { assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed()); } +/// A Zstandard frame that ends in a zero byte keeps it: only the zeros after the last frame are +/// padding. +#[test] +fn a_zstandard_frame_ending_in_a_zero_byte_is_read_whole() { + // Magic number, a single-segment header with a 1-byte content size of 3, and one raw block + // (last, 3 bytes) holding `ab` and a zero byte. + let frame = [0x28, 0xB5, 0x2F, 0xFD, 0x20, 3, 0x19, 0, 0, b'a', b'b', 0]; + for padding in [0, 64] { + let raw = [&b"%AI24_ZStandard_Data"[..], &frame, &frame, &vec![0; padding]].concat(); + assert_eq!(decode_private(&raw).unwrap(), b"ab\0ab\0"); + } +} + #[test] fn closing_brackets_without_their_opening_dont_rescan_the_stack() { let n = 200_000; @@ -1427,12 +3060,14 @@ const ART_STYLES: &str = "%AI9_BeginArtStyles\n\ /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n\ + 0 /Int (csrc) ,\n/FillStyle : 0 O\n0 0 0 1 0 0 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n\ /KnownStyle :\n(Lit) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\ - /BasicFilter :\n(Adobe Outer Glow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\n; /Dict ;\n /Part ,\n;\n\ + /BasicFilter :\n(Adobe Outer Glow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n2 /Int (blnd) ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\n\ + /FillStyle : 0 O\n0 0 0 0 1 1 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,\n; /Dict ;\n /Part ,\n;\n\ %AI9_EndArtStyles\n"; #[test] @@ -1454,6 +3089,154 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). +fn fill_style(name: &str, xa: &str) -> String { + fill_style_with(name, xa, "") +} + +/// [`fill_style`] with the filters `extra` after its fills. +fn fill_style_with(name: &str, xa: &str, extra: &str) -> String { + format!( + "/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n{extra};\n" + ) +} + +/// An art style named `name` with a drop shadow (every setting, Darkness) and no fills or strokes. +fn shadow_style(name: &str) -> String { + format!( + "/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n2 /Int (blnd) ,\n0.75 /Real (opac) ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n; /Dict ;\n /Part ,\n;\n" + ) +} + +/// The art styles `defs` and the style list of `listed` (each written between parentheses as given). +fn styles_and_list(defs: &[String], listed: &[&str]) -> String { + let list: String = listed.iter().map(|n| format!("({n})\n")).collect(); + format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n{list}%AI9_EndArtStyleList\n", defs.concat()) +} + +/// A red square with its drawn look, read as itself with the art style `name` (`1 (name) XW`). +fn styled_square(name: &str) -> String { + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 ({name}) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)) +} + +fn style_names(doc: &Document) -> Vec<(String, u32)> { + doc.graphic_styles.iter().map(|g| (g.name.clone(), g.id)).collect() +} + +#[test] +fn listed_styles_replace_the_default_graphic_styles_and_link_their_objects() { + let defs = [fill_style("Red", "0 0 0 0 1 0 0 Xa\n"), fill_style("Blue", "0 0 0 0 0 0 1 Xa\n"), shadow_style("Shadow")]; + let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Red", "Blue", "Shadow"]), layer(&styled_square("Blue")))); + assert_eq!(style_names(&r.doc), [("Red".to_string(), 1), ("Blue".to_string(), 2), ("Shadow".to_string(), 3)]); + let blue = &r.doc.graphic_styles[1]; + assert_eq!(blue.appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.0, 0.0, 1.0))); + let shadow = &r.doc.graphic_styles[2]; + assert_eq!(shadow.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + assert_eq!(a[0].extras().graphic_style, Some(2)); +} + +#[test] +fn only_listed_styles_that_are_defined_and_read_come_in_once() { + // `Lit` paints with a gradient placed on each object; `Missing` has no definition. + let defs = [fill_style("Blue", "0 0 0 0 0 0 1 Xa\n"), ART_STYLES.lines().filter(|l| !l.starts_with('%')).map(|l| format!("{l}\n")).collect()]; + let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Blue", "Missing", "", "Lit", "Blue"]), layer(""))); + assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]); +} + +#[test] +fn a_style_the_list_does_not_have_is_no_link() { + let defs = [fill_style("Blue", "0 0 0 0 0 0 1 Xa\n"), fill_style("Anon", "0 0 0 0 0 1 0 Xa\n")]; + let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Blue"]), layer(&styled_square("Anon")))); + assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 1.0, 0.0)), "the object still takes its appearance"); + assert_eq!(a[0].extras().graphic_style, None); +} + +#[test] +fn a_group_with_a_listed_style_is_linked() { + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let group = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{hidden_rect}f\n%_U\n1 (Shadow) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{}{}", styles_and_list(&[shadow_style("Shadow")], &["Shadow"]), layer(&group))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(a[0].extras().graphic_style, Some(1)); +} + +#[test] +fn an_object_whose_transparency_differs_from_its_listed_style_is_not_linked() { + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let square = + format!("0 Ae\nu\n{RED}{}f\nU\n1 0.6 0 2 0 Xy\n6 () XW\n%_0 1 0 0 0 Xy\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square))); + assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(a[0].opacity, 0.6); + assert_eq!(a[0].extras().graphic_style, None, "the style has full opacity"); +} + +#[test] +fn a_listed_style_keeps_its_transparency_and_links_objects_that_have_it() { + let blend = "/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /BlendStyle : 1 0.6 1 1 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n; /Dict ;\n /Part ,\n"; + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let square = + format!("0 Ae\nu\n{RED}{}f\nU\n1 0.6 1 1 0 Xy\n6 () XW\n%_0 1 0 0 0 Xy\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style_with("Blue", "0 0 0 0 0 0 1 Xa\n", blend)], &["Blue"]), layer(&square))); + let g = &r.doc.graphic_styles[0]; + assert_eq!((g.name.as_str(), g.opacity, g.blend, g.isolate, g.knockout), ("Blue", 0.6, BlendMode::Multiply, true, Knockout::On)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(a[0].extras().graphic_style, Some(1), "{a:#?}"); +} + +#[test] +fn a_path_with_a_listed_style_that_fills_below_the_contents_is_linked() { + let below = "/KnownStyle :\n(Under) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n"; + let r = read_ok(&format!("{}{}", styles_and_list(&[below.to_string()], &["Under"]), layer(&styled_square("Under")))); + assert_eq!(r.doc.graphic_styles[0].appearance.contents_index, Some(1)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(a[0].extras().graphic_style, Some(1), "{a:#?}"); +} + +#[test] +fn a_style_name_outside_ascii_links_its_objects() { + // The list writes the name with escapes; the object's marker writes its UTF-8 bytes. + let defs = [fill_style("Caf\u{e9} \u{3a9}", "0 0 0 0 0 0 1 Xa\n")]; + let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Caf\\303\\251 \\316\\251"]), layer(&styled_square("Caf\u{e9} \u{3a9}")))); + assert_eq!(style_names(&r.doc), [("Caf\u{e9} \u{3a9}".to_string(), 1)]); + assert_eq!(art(&r.doc)[0].extras().graphic_style, Some(1)); +} + +#[test] +fn without_a_style_list_the_default_graphic_styles_stay() { + let defaults = style_names(&Document::new(1.0, 1.0)); + let r = read_ok(&format!("{ART_STYLES}{}", layer(&styled_square("Blue")))); + assert_eq!(style_names(&r.doc), defaults); + assert_eq!(art(&r.doc)[0].extras().graphic_style, None); + let r = read_ok(&format!("{}{}", styles_and_list(&[shadow_style("Shadow")], &["Missing"]), layer(""))); + assert_eq!(style_names(&r.doc), defaults, "a list naming no style it can read"); +} + #[test] fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { let feather_style = "%AI9_BeginArtStyles\n\ @@ -1461,7 +3244,7 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n8 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n\ + /BasicFilter :\n(Adobe Fuzzy Mask) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n8 /Real (Radius) ,\n; /Dict ;\n /Part ,\n;\n\ %AI9_EndArtStyles\n"; let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Soft) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); @@ -1473,6 +3256,163 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style section with style `PF`: an Effect › Pathfinder filter with `Command` `command` +/// and `Mix` `mix` in the object's chain, over a fill and stroke that paint nothing. +fn pathfinder_styles(command: u32, mix: f64) -> String { + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(PF) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Pathfinder) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + 0 /Bool (RemovePoints) ,\n{mix} /Real (Mix) ,\n1 /Bool (ConvertCustom) ,\n{command} /Int (Command) ,\n1 /Real (TrapMaxTint) ,\n\ + 1 /Real (TrapAspect) ,\n0.4 /Real (TrapTint) ,\n0.25 /Real (TrapThickness) ,\n0 /Bool (TrapReverse) ,\n10 /Real (Precision) ,\n\ + (Op) /UnicodeString (DisplayString) ,\n0 /Bool (TrapConvertCustom) ,\n0.05 /Real (TrapTintTolerance) ,\n1 /Bool (ExtractUnpainted) ,\n\ + ; /Dict ;\n /Part ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// A group of a red and a blue square with style `PF`: its drawn look (one path of their area), +/// then the group on `%_` lines. +fn pathfinder_group() -> String { + let hidden = |color: &str, x: f64| format!("{color}{}f\n", rect_path(x, 0.0, x + 10.0, 10.0)).replace('\n', "\n%_"); + format!( + "0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{}{}U\n1 (PF) XW\n", + rect_path(0.0, 0.0, 15.0, 10.0), + hidden(RED, 0.0), + hidden("0 0 1 0 0 0 1 Xa\n", 5.0) + ) +} + +#[test] +fn a_group_with_a_pathfinder_effect_keeps_its_members_and_the_effect() { + // GUARDS: the style was not read, and the group came in as its drawn look: one path, with no effect. + let r = read_ok(&format!("{}{}", pathfinder_styles(0, 0.5), layer(&pathfinder_group()))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let members = a[0].children().cloned().unwrap_or_default(); + assert_eq!(members.len(), 2, "the squares, not the look: {a:#?}"); + assert!(members.iter().all(|m| matches!(m.kind, NodeKind::Path { .. })), "{members:#?}"); + assert_eq!((fill(&members[0]).color(), fill(&members[1]).color()), (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0)))); + let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.visible)).collect(); + assert_eq!(effects, [("pathfinder.add", true)]); + assert!(a[0].appearance.items.is_empty(), "{:?}", a[0].appearance); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + +#[test] +fn a_pathfinder_style_this_does_not_read_keeps_the_drawn_look() { + // Another command (Intersect, Trap), and a Precision other than the one read. + let other = pathfinder_styles(0, 0.5).replace("10 /Real (Precision)", "5 /Real (Precision)"); + for styles in [pathfinder_styles(1, 0.5), pathfinder_styles(12, 0.5), other] { + let r = read_ok(&format!("{styles}{}", layer(&pathfinder_group()))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let look = a[0].children().cloned().unwrap_or_default(); + assert_eq!(look.len(), 1, "the look's one path: {a:#?}"); + assert!(a[0].appearance.effects.is_empty(), "{:?}", a[0].appearance); + } +} + +#[test] +fn a_path_with_a_pathfinder_effect_keeps_its_drawn_look() { + // Effect › Pathfinder doesn't apply to a lone path: its style stays the drawn look's. + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (PF) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{}{}", pathfinder_styles(0, 0.5), layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the look, not the path: {a:#?}"); + let mut effects = 0; + r.doc.walk(|n| effects += n.appearance.effects.len()); + assert_eq!(effects, 0); +} + +#[test] +fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() { + // GUARDS: `Adobe Shape Effects` wasn't read, so the object came in as its drawn look. + let styles = |keys: &str| { + format!( + "%AI9_BeginArtStyles\n\ + /KnownStyle :\n(Boxed) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Shape Effects) 1 0 /Filter ,\n1 /Visible ,\n(ShapeEffect.aip) /PluginFileName ,\n(Shape) /Title ,\n\ + /Dictionary : /NotRecorded ,\n(Rectangle) /UnicodeString (DisplayString) ,\n{keys}; /Dict ;\n /Part ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n" + ) + }; + let hidden_rect = rect_path(20.0, 20.0, 90.0, 60.0).replace('\n', "\n%_"); + // The drawn look is the shape: 20 pt wider on each side and 6 pt taller. + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Boxed) XW\n", rect_path(0.0, 14.0, 110.0, 66.0)); + let keys = "0 /Int (Shape) ,\n150 /Real (AbsWidth) ,\n0 /Real (Absolute) ,\n40 /Real (AbsHeight) ,\n20 /Real (RelWidth) ,\n15 /Real (CornerRadius) ,\n6 /Real (RelHeight) ,\n"; + let r = read_ok(&format!("{}{}", styles(keys), layer(&art_))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + let b = a[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (70.0, 40.0), "the object keeps its own path"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) }; + assert_eq!( + (e.id.as_str(), e.params["relative"].as_bool(), e.params["extraW"].as_f64(), e.params["extraH"].as_f64()), + ("convertToShape.rectangle", Some(true), Some(40.0), Some(12.0)) + ); + // A shape that isn't read: the drawn look. + let r = read_ok(&format!("{}{}", styles(&keys.replace("0 /Int (Shape)", "7 /Int (Shape)")), layer(&art_))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + +#[test] +fn an_object_with_path_effects_comes_in_as_itself_with_them_live() { + // GUARDS: an object with Offset Path, Outline Object or Outline Stroke came in as its drawn look. + let styles = |offset: &str| { + format!( + "%AI9_BeginArtStyles\n\ + /KnownStyle :\n(Rim) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Offset Path) 1 0 /Filter ,\n1 /Visible ,\n(OffsetPath.aip) /PluginFileName ,\n(Offset Path) /Title ,\n\ + /Dictionary : /NotRecorded ,\n{offset}; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Adobe Outline Type) 1 0 /Filter ,\n0 /Visible ,\n(ArtConverters.aip) /PluginFileName ,\n(Outline Object) /Title ,\n\ + /Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Adobe Outline Stroke) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n" + ) + }; + let hidden_rect = rect_path(20.0, 20.0, 60.0, 50.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Rim) XW\n", rect_path(16.0, 16.0, 64.0, 54.0)); + let offset = "4 /Real (ofst) ,\n6 /Real (mlim) ,\n1 /Int (jntp) ,\n"; + let r = read_ok(&format!("{}{}", styles(offset), layer(&art_))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + let b = a[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (40.0, 30.0), "the object keeps its own path"); + let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.params.clone(), e.visible)).collect(); + assert_eq!( + effects, + [ + ("path.offsetPath", serde_json::json!({"offset": 4.0, "joins": "bevel", "miterLimit": 6.0}), true), + ("path.outlineObject", serde_json::json!({}), false), + ("path.outlineStroke", serde_json::json!({}), true), + ] + ); + // An Offset Path without its Miter Limit: the drawn look. + let r = read_ok(&format!("{}{}", styles("4 /Real (ofst) ,\n1 /Int (jntp) ,\n"), layer(&art_))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + #[test] fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() { // GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none. @@ -1503,7 +3443,7 @@ fn a_styled_object_inside_a_drawn_look_survives_it() { /// that shadow, not as its drawn look. #[test] fn an_object_with_a_darkness_shadow_comes_in_as_itself() { - let styles = ART_STYLES.replace("3 /Real (horz) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n3 /Real (horz) ,\n"); + let styles = ART_STYLES.replace("0 /Int (csrc) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n"); let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); let r = read_ok(&format!("{styles}{}", layer(&art_))); @@ -1574,6 +3514,515 @@ fn an_envelope_mesh_is_not_read_as_art() { assert!(art(&doc).iter().any(|n| has_mesh(n)), "{:#?}", art(&doc)); } +/// An envelope whose contents are being edited is written the other way round: its contents, a +/// group on `%_` lines, then its dictionary, which holds the mesh that shapes them. The contents +/// aren't drawn as they are, only the distorted art on the plain lines after them. +#[test] +fn an_envelope_s_contents_being_edited_are_not_read_as_art() { + let corner = |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { + format!("%_[0.82 0.83 0.83 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1) + }; + let mesh = format!( + "%_/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n%_/End X!\n%_0 0 Xd\n%_6 () XW\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + let contents: String = rect_path(0.0, 0.0, 80.0, 20.0).lines().map(|l| format!("%_{l}\n")).collect(); + let body = format!( + "%_u\n%_u\n%_0 0 1 0 0 0 1 Xa\n{contents}%_f\n%_U\n%_9 () XW\n%_U\n%_/ArtDictionary :\n%_1 /Bool (AIDeformEditModeKey) ,\n\ + %_3 /Int (AIDeformStyle) ,\n%_X=\n{mesh}%_X+\n%_ (AIDeformArtKey) ,\n%_;\n%_\n%_9 () XW\n{RED}{}f\n", + rect_path(0.0, 0.0, 40.0, 40.0) + ); + let doc = read_ok(&layer(&body)).doc; + let a = art(&doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); +} + +/// `art` on `%_` lines. +fn commented(art: &str) -> String { + art.lines().map(|l| format!("%_{l}\n")).collect() +} + +/// Envelope Options as new envelopes have them (Distort Appearance, Anti-Alias, Clipping Mask), +/// Fidelity 50, the contents not being edited. +const ENVELOPE_KEYS: &str = "131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n"; + +/// An envelope as the editing data writes it: on `%_` lines a group holding `before`, then the +/// dictionary with `keys`, the art `block` in its `AIDeformArtKey` block and the kind `kind`; then +/// `drawn` in a group on plain lines, and the plugin's `XP`. +fn envelope_parts(before: &str, keys: &str, block: &str, kind: u32, drawn: &str) -> String { + format!( + "{}%_/ArtDictionary :\n{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + commented(&format!("u\n{before}U\n")), + commented(keys), + commented(block), + ) +} + +/// An envelope of `content` (in the plain group the editing data puts round it) shaped by the mesh +/// `shape`. +fn envelope_of(shape: &str, keys: &str, content: &str, kind: u32, drawn: &str) -> String { + envelope_parts(shape, keys, &format!("u\n{content}U\n9 () XW\n"), kind, drawn) +} + +/// A grid point's place in art space and its handle towards a neighbour. +type MeshAt<'a> = &'a dyn Fn((usize, usize)) -> (f64, f64); +type MeshHandle<'a> = &'a dyn Fn((usize, usize), (usize, usize)) -> (f64, f64); + +/// A grey `/Mesh` of `cols`×`rows` patches (`/P [i j]`, `i` along x, column by column), each +/// corner of patch `(i, j)` at `at`, from the one nearest the origin up, across and down, with its +/// handles towards the next corner and the previous one at `handle`; `anchor`, written after the +/// second corner of the first patch, puts an anchor on its second edge. +fn mesh_text(cols: usize, rows: usize, at: MeshAt<'_>, handle: MeshHandle<'_>, anchor: Option<[f64; 6]>) -> String { + let mut s = format!("/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#\n"); + for i in 0..cols { + for j in 0..rows { + s += &format!("[{i} {j}] /P X#\n"); + let (a, b, c, d) = ((i, j), (i, j + 1), (i + 1, j + 1), (i + 1, j)); + for (k, (p, next, prev)) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)].into_iter().enumerate() { + let ((x, y), (nx, ny), (px, py)) = (at(p), handle(p, next), handle(p, prev)); + s += &format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + if let (1, Some([x, y, ax, ay, bx, by])) = (k, anchor.filter(|_| (i, j) == (0, 0))) { + s += &format!("[{x} {y} {ax} {ay} {bx} {by} 1] /A X#\n"); + } + } + s += "/E X#\n"; + } + } + s + "/End X!\n" +} + +/// A grid in art space, its points `step` apart from (40, 20), handles a third of the way. +fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String { + let at = move |(i, j): (usize, usize)| (40.0 + i as f64 * step.0, 20.0 + j as f64 * step.1); + let third = move |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + mesh_text(cols, rows, &at, &third, None) +} + +/// The red rectangle 40 20 160 80 in art space, which is the same box in the document's. +fn envelope_content() -> String { + format!("{RED}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)) +} + +/// What envelope `kind` draws of [`envelope_content`]: the rectangle's edges, many points to a +/// side, mapped as VectorCraft maps them, as a red path in art space. +fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String { + let rect = Rect::new(40.0, 20.0, 160.0, 80.0); + let map = vectorcraft_doc::live::envelope_mapper(kind, rect); + let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)]; + let mut s = RED.to_string(); + for (k, a) in corners.iter().enumerate() { + let b = corners[(k + 1) % 4]; + for t in 0..64 { + let q = map(a.lerp(b, f64::from(t) / 64.0)); + s += &format!("{} {} {}\n", q.x, 100.0 - q.y, if k == 0 && t == 0 { "m" } else { "L" }); + } + } + s + "f\n" +} + +/// The live envelope `n` is: its content, kind, fidelity, editing and options. +fn live_envelope(n: &Node) -> (&[Arc], &vectorcraft_doc::live::EnvelopeKind, f64, bool, vectorcraft_doc::live::EnvelopeOptions) { + match &n.kind { + NodeKind::Envelope { content, kind, fidelity, editing, options, frame } => { + assert_eq!(**frame, kurbo::Affine::IDENTITY); + (content, kind, *fidelity, *editing, *options) + } + other => panic!("not a live envelope: {other:#?}"), + } +} + +/// A warp's dictionary keys: style number `style`, bend, horizontal and vertical distortion as +/// fractions, and the orientation (vertical with `rotate` 1). +fn warp_keys(style: u32, bend: f64, h: f64, v: f64, rotate: u32) -> String { + format!( + "{ENVELOPE_KEYS}{style} /Int (DeformStyle) ,\n{h} /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n{v} /Real (DeformVert) ,\n{rotate} /Bool (Rotate) ,\n(Warp) /UnicodeString (DisplayString) ,\n" + ) +} + +/// An envelope made with a warp opens as that live envelope of its contents, its style, bend, +/// distortions (fractions in the file) and orientation read; the warp's drawn art goes. +#[test] +fn a_warp_envelope_opens_live() { + use vectorcraft_doc::live::EnvelopeKind; + for (style, bend, h, v, rotate, id) in + [(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")] + { + let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 }; + let body = + envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, fidelity, editing, options) = live_envelope(&a[0]); + let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") }; + assert_eq!((s.as_str(), *horizontal), (id, rotate == 0)); + assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}"); + assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW)); + assert_eq!(content.len(), 1, "{content:#?}"); + assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}"); + assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } +} + +/// Every warp style number reads as its style, in Effect › Warp's order. +#[test] +fn warp_envelopes_read_every_style() { + use vectorcraft_doc::live::EnvelopeKind; + let ids = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + for (n, id) in ids.iter().enumerate() { + let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{id}: {a:#?}"); + assert_eq!(live_envelope(&a[0]).1, &want, "{id}"); + } +} + +/// An envelope made with a mesh opens as a live mesh envelope: its rows and columns, its points +/// row by row from the top left, and each point's four handles, a moved point and an uneven handle +/// included. +#[test] +fn a_mesh_envelope_opens_live_with_its_points_and_handles() { + use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP}; + // 3 columns 40 apart and 2 rows 30 apart from (40, 20); the inner point (1, 1) moved, and its + // handle towards (2, 1) longer and off the line. + let at = |(i, j): (usize, usize)| if (i, j) == (1, 1) { (90.0, 55.0) } else { (40.0 + i as f64 * 40.0, 20.0 + j as f64 * 30.0) }; + let handle = |p: (usize, usize), q: (usize, usize)| { + if (p, q) == ((1, 1), (2, 1)) { + return (108.0, 62.0); + } + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + let shape = mesh_text(3, 2, &at, &handle, None); + // Row r of the envelope is row 2 - r of the file (art space has y up). + let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y); + let mut points = vec![]; + let mut handles = vec![]; + for r in 0..=2usize { + for c in 0..=3usize { + let g = (c, 2 - r); + let p = doc_pt(at(g)); + points.push(p); + let mut h = [kurbo::Vec2::ZERO; 4]; + let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 3 && q.1 <= 2).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p); + h[H_RIGHT] = towards(Some((g.0 + 1, g.1))); + h[H_LEFT] = towards(g.0.checked_sub(1).map(|i| (i, g.1))); + h[H_UP] = towards(Some((g.0, g.1 + 1))); + h[H_DOWN] = towards(g.1.checked_sub(1).map(|j| (g.0, j))); + handles.push(h); + } + } + let want = EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles: handles.clone() }; + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let EnvelopeKind::Mesh { rows, cols, points: got, handles: got_h } = live_envelope(&a[0]).1 else { panic!("{a:#?}") }; + assert_eq!((*rows, *cols, got.len(), got_h.len()), (2, 3, 12, 12)); + for (k, (p, q)) in got.iter().zip(&points).enumerate() { + assert!((*p - *q).hypot() < 1e-6, "point {k}: {p:?}, not {q:?}"); + } + // The moved inner point (row 1, column 1): each of its handles, the uneven one towards the right. + let inner = 4 + 1; + assert!((got[inner] - Point::new(90.0, 45.0)).hypot() < 1e-6, "{:?}", got[inner]); + for slot in [H_RIGHT, H_LEFT, H_DOWN, H_UP] { + assert!((got_h[inner][slot] - handles[inner][slot]).hypot() < 1e-6, "slot {slot}: {:?} vs {:?}", got_h[inner], handles[inner]); + } + assert!((got_h[inner][H_RIGHT] - kurbo::Vec2::new(18.0, -7.0)).hypot() < 1e-6, "{:?}", got_h[inner]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// An envelope made with a top object opens as a live envelope whose top path is the edge of its +/// one-patch mesh, an anchor on the edge included; its contents stay the rectangle. +#[test] +fn a_top_object_envelope_opens_live_with_its_outline() { + use vectorcraft_doc::live::EnvelopeKind; + // Corners (40, 20) (40, 80) (160, 80) (160, 20); the top edge bows up through an anchor at (100, 95). + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (60.0, 88.0), + ((1, 1), (0, 1)) => (140.0, 88.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let shape = mesh_text(1, 1, &at, &handle, Some([100.0, 95.0, 70.0, 95.0, 130.0, 95.0])); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((60.0, 12.0), (70.0, 5.0), (100.0, 5.0)); + bp.curve_to((130.0, 5.0), (140.0, 12.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) }; + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 3, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, ..) = live_envelope(&a[0]); + let EnvelopeKind::TopObject { path } = kind else { panic!("{kind:?}") }; + let anchors: Vec = path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect(); + for p in [(40.0, 20.0), (100.0, 5.0), (160.0, 20.0), (160.0, 80.0), (40.0, 80.0)] { + assert!(anchors.iter().any(|q| (*q - Point::from(p)).hypot() < 1e-6), "{p:?} not in {anchors:?}"); + } + // The curve through the anchor: its handles there run level. + let mid = path.subpaths.iter().flat_map(|s| s.anchors.iter()).find(|a| (a.p - Point::new(100.0, 5.0)).hypot() < 1e-6).unwrap(); + assert!((mid.h_in.y - 5.0).abs() < 1e-6 && (mid.h_out.y - 5.0).abs() < 1e-6, "{mid:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// Envelope Options come from the options word, Fidelity from its own key. +#[test] +fn an_envelope_reads_its_options_and_fidelity() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape}; + let shape = straight_mesh(1, 1, (120.0, 60.0)); + // A flat 1 × 1 mesh over the box draws the box as it is. + let drawn = drawn_content(&EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }); + for (word, fidelity, options) in [ + ( + 65536, + 80, + EnvelopeOptions { + anti_alias: false, + preserve_shape: PreserveShape::Transparency, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: false, + }, + ), + ( + 0x20007, + 100, + EnvelopeOptions { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: true, + distort_linear_gradients: true, + distort_pattern_fills: true, + }, + ), + ( + 0x20002, + 0, + EnvelopeOptions { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: true, + }, + ), + ] { + let keys = format!("{word} /Int (AIDeformOptionsKey) ,\n{fidelity} /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n"); + let r = read_ok(&layer(&envelope_of(&shape, &keys, &envelope_content(), 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{word}: {a:#?}"); + let (_, _, got_fidelity, _, got) = live_envelope(&a[0]); + assert_eq!((got_fidelity, got), (f64::from(fidelity), options), "{word}"); + } +} + +/// An envelope's contents keep their groups: a group of two rectangles stays that group. +#[test] +fn an_envelope_s_contents_keep_their_groups() { + use vectorcraft_doc::live::EnvelopeKind; + let group = format!( + "u\n{RED}{}f\n{}f\nU\n/ArtDictionary :\n(Pair) /String (AIArtName) ,\n;\n", + rect_path(40.0, 20.0, 90.0, 80.0), + rect_path(110.0, 20.0, 160.0, 80.0) + ); + let mut drawn = RED.to_string(); + drawn += &format!("{}f\n{}f\n", rect_path(40.0, 20.0, 90.0, 80.0), rect_path(110.0, 20.0, 160.0, 80.0)); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &group, 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, ..) = live_envelope(&a[0]); + assert!(matches!(kind, EnvelopeKind::Mesh { rows: 1, cols: 1, .. }), "{kind:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(content[0].name.as_deref(), Some("Pair")); + let kids = content[0].children().unwrap(); + assert_eq!(kids.len(), 2, "{kids:#?}"); + assert!(kids.iter().all(|k| matches!(k.kind, NodeKind::Path { .. })), "{kids:#?}"); +} + +/// While an envelope's contents are edited, the contents come first and the mesh is in the +/// dictionary: the envelope opens live all the same, in Edit Contents. +#[test] +fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() { + use vectorcraft_doc::live::EnvelopeKind; + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (70.0, 95.0), + ((1, 1), (0, 1)) => (130.0, 95.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let mesh = mesh_text(1, 1, &at, &handle, None); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) }; + let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"); + let body = envelope_parts(&envelope_content(), &keys, &format!("0 g\n{mesh}"), 3, &drawn_content(&want)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, _, editing, _) = live_envelope(&a[0]); + assert!(editing); + assert!(matches!(kind, EnvelopeKind::TopObject { .. }), "{kind:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}"); + assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); +} + +/// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape +/// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art +/// round it is read as ever. +#[test] +fn an_envelope_that_doesnt_read_keeps_its_drawn_art() { + let blue = "0 0 1 1 0 0 1 Xa\n"; + let drawn = format!("{blue}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)); + let mesh = straight_mesh(1, 1, (120.0, 60.0)); + let cases = [ + envelope_of(&mesh, &warp_keys(99, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn), + envelope_of(&format!("{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn), + envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 7, &drawn), + envelope_of(&mesh, &ENVELOPE_KEYS.replace("131073", "131081"), &envelope_content(), 2, &drawn), + envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 2, &drawn).replace("(Adobe Deform Plugin) 1 0 0 XP\n", ""), + ]; + for case in cases { + let body = format!("{RED}{}f\n{case}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(190.0, 0.0, 200.0, 10.0)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 3, "{case}\n{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }) && matches!(a[2].kind, NodeKind::Path { .. }), "{a:#?}"); + let NodeKind::Group { children, .. } = &a[1].kind else { panic!("{a:#?}") }; + assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)), "{case}"); + assert_eq!(r.warnings, [super::envelope::DRAWN], "{case}"); + } +} + +/// An envelope whose contents don't come out as its drawn art (its warp bends them another way) +/// keeps the drawn art. +#[test] +fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { + use vectorcraft_doc::live::EnvelopeKind; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + +/// An envelope that comes out close to its drawn art, but with more of the page differing than the +/// page check can take from the live envelopes (its drawn art 2 pt to the side on a 200 × 100 +/// page), keeps the drawn art, so the file still opens from its layers. +#[test] +fn an_envelope_that_differs_from_its_drawn_art_over_the_page_keeps_it() { + let shifted = format!("{RED}{}f\n", rect_path(42.0, 20.0, 162.0, 80.0)); + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &shifted); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + +/// An envelope whose drawn art holds an object the reader can't read keeps that drawn art, which +/// the page's art replaces. +#[test] +fn an_envelope_whose_drawn_art_holds_an_unread_object_keeps_it() { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let drawn = format!("{}frobnicate\n", drawn_content(&flat)); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.unread.iter().map(|(id, _)| *id).collect::>(), [a[0].id]); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + +/// A live envelope takes its drawn art's own properties: its name here. +#[test] +fn a_live_envelope_keeps_its_drawn_art_s_name() { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&flat)) + .replace("U\n9 () XW\n(Adobe Deform Plugin)", "U\n/ArtDictionary :\n(Bent) /String (AIArtName) ,\n;\n9 () XW\n(Adobe Deform Plugin)"); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + live_envelope(&a[0]); + assert_eq!(a[0].name.as_deref(), Some("Bent")); +} + +/// The same envelope beside the page opens live: what differs off the page isn't counted. +#[test] +fn an_envelope_off_the_page_opens_live_when_it_draws_close_to_its_drawn_art() { + let at = |(i, j): (usize, usize)| (300.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let third = |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + let content = format!("{RED}{}f\n", rect_path(300.0, 20.0, 420.0, 80.0)); + let shifted = format!("{RED}{}f\n", rect_path(302.0, 20.0, 422.0, 80.0)); + let r = read_ok(&layer(&envelope_of(&mesh_text(1, 1, &at, &third, None), ENVELOPE_KEYS, &content, 2, &shifted))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + live_envelope(&a[0]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + #[test] fn a_gradient_mesh_reads_as_a_patch() { let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { @@ -1710,6 +4159,122 @@ fn stroked_path_guides_from_the_star_operator() { assert!(matches!(n.appearance.items[0], AppearanceItem::Stroke(_))); } +/// [`stream`] with a template box centred on (306.5, 395.5): the canvas runs 8,191.5 points each +/// way from there. +fn canvas_stream(body: &str) -> String { + stream(body).replacen("%%EndComments", "%AI3_TemplateBox: 306.5 395.5 306.5 395.5\n%%EndComments", 1) +} + +/// The straight guide path from (`x0`, `y0`) to (`x1`, `y1`), `(op) *`. +fn guide_line(x0: f64, y0: f64, x1: f64, y1: f64, op: &str) -> String { + format!("{x0} {y0} m\n{x1} {y1} L\n({op}) *\n") +} + +/// #779: a straight `(N) *` guide path across the whole canvas, directly on a layer or sublayer, is +/// a ruler guide on that layer, at its place in the document; it leaves no path behind. +#[test] +fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { + let body = format!( + "{}{}{RED}{}f\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Sub) Ln\n{}LB\n%AI5_EndLayer--\n", + guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"), + guide_line(25.0, 8587.0, 25.0, -7796.0, "N"), + rect_path(0.0, 0.0, 10.0, 10.0), + guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"), + ); + let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; + let top = &doc.layers[0]; + let kids = top.children().unwrap(); + assert_eq!(kids.len(), 2, "the square and the sublayer"); + assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. })); + let sub = &kids[1]; + assert!(sub.children().unwrap().is_empty()); + let want = [ + Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) }, + Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) }, + Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) }, + ]; + assert_eq!(doc.guides, want); +} + +/// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that +/// artboard. +#[test] +fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { + // The crop marks' artboard, 0 0 200 100. + let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc; + assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]); + assert!(art(&doc).is_empty()); + // The second of two artboards: art space x 400 to 600, y -150 to 0. + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc; + assert_eq!(doc.artboards[1].id, 2); + assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]); +} + +/// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects), +/// even across the whole canvas or an artboard, short ones, slanted ones, and ones in a group. +#[test] +fn other_guide_paths_stay_guide_paths() { + let cases = [ + guide_line(-7885.0, 40.0, 8498.0, 40.0, "S"), + guide_line(20.0, 40.0, 80.0, 40.0, "S"), + guide_line(20.0, 40.0, 80.0, 40.0, "N"), + guide_line(0.0, 30.0, 200.0, 30.0, "S"), + guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"), + format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")), + format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")), + "-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(), + ]; + for body in cases { + let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; + assert!(doc.guides.is_empty(), "{body}"); + assert_eq!(art(&doc).len(), 1, "{body}"); + } +} + +/// The layer whose `Lb` has `ops` as its options (the first one in the file). +fn layer_with(ops: &str) -> Node { + let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc; + Node::clone(&doc.layers[0]) +} + +/// #779: the template flag four places after the colour index makes a template layer; locking, +/// printing and dimming keep their own flags. In thirteen and fourteen operands the flag before +/// the colour index and the last one change nothing. +#[test] +fn the_template_flag_of_a_layer() { + let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0"); + let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; + assert!(template && !printable && l.locked); + assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4))); + for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] { + let l = layer_with(ops); + let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; + assert!(template && printable && !l.locked, "{ops}"); + assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}"); + } + let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0"); + assert!(matches!(l.kind, NodeKind::Layer { template: false, .. })); +} + +/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth +/// is the dimming percentage; with nine it is one place earlier still. +#[test] +fn layer_options_of_nine_to_twelve_operands() { + let options = |l: Node| match l.kind { + NodeKind::Layer { template, dim_images, color, .. } => (template, dim_images, color), + _ => panic!(), + }; + assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 1")), (true, Some(50), LayerColor::Preset(3))); + assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 0 25")), (false, Some(25), LayerColor::Preset(3))); + assert_eq!(options(layer_with("1 1 1 1 1 0 -1 10 20 30 1 25")), (true, Some(25), LayerColor::Custom([10, 20, 30]))); + assert_eq!(options(layer_with("1 1 1 1 0 0 3 1 2 3")), (false, None, LayerColor::Preset(3))); + assert_eq!(options(layer_with("1 1 1 1 0 2 1 2 3")), (false, None, LayerColor::Preset(2))); +} + /// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`, /// version 1, then (as far as the reader reads it) the number of keys at word 10 and a record of /// six words per key starting with its id. Hand-written; the other words are zero. @@ -1871,6 +4436,33 @@ fn a_blend_it_cant_read_keeps_its_drawn_steps() { } } +/// A blend's transparency (here knockout on, as blends are written) is the state block after its +/// record, as a group's follows its end: the live blend's, or its drawn steps' where they stay. It +/// is the blend's own, not the next object's. +#[test] +fn a_blends_transparency_follows_its_record() { + let state = "0 0.6 0 1 0 Xy\n0 0 Xd\n6 () XW\n"; + let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(150.0, 40.0, 170.0, 60.0)); + let straight = "%_10 50 m\n%_110 50 L\n"; + let garbled = "(Adobe Path Blends) 1 0 16 XP\n%00112233445566778899AABBCCDDEEFF\n".to_string(); + for (record, live) in [(blend_record(&['A', 'B']), true), (garbled, false)] { + let body = format!("{}{state}{next}", blend_file(straight, [Some("A"), Some("B")], &record)); + let a = art(&read_ok(&layer(&body)).doc); + assert_eq!(a.len(), 2, "{a:?}"); + assert_eq!(matches!(a[0].kind, NodeKind::Blend { .. }), live, "{:?}", a[0].kind); + assert_eq!((a[0].opacity, a[0].knockout), (0.6, Knockout::On), "live: {live}"); + assert_eq!(a[1].opacity, 1.0, "the next object's own state"); + } + // A layer whose last object is a blend: a state block that ends `7 () XW` before `LB` is the + // layer's, and the blend keeps the state it had. + let body = format!("{}1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", blend_file(straight, [Some("A"), Some("B")], &blend_record(&['A', 'B']))); + let doc = read_ok(&layer(&body)).doc; + assert_eq!((doc.layers[0].opacity, doc.layers[0].blend), (0.35, BlendMode::Multiply), "the layer's state"); + let a = art(&doc); + assert!(matches!(a[0].kind, NodeKind::Blend { .. }), "{:?}", a[0].kind); + assert_eq!((a[0].opacity, a[0].blend), (1.0, BlendMode::Normal), "the blend's own state"); +} + /// The blend is live: moving a key moves the steps between it and the other key with it. #[test] fn moving_a_blends_key_moves_its_steps() { @@ -1900,3 +4492,693 @@ fn a_blend_record_is_read_to_its_length() { let (blend, warnings) = read_blend(&blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &longer)); assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}"); } + +/// An object's dictionary with the text wrap `entries` (each a `values (key) ,` line). +fn wrap_dictionary(entries: &[&str]) -> String { + let lines: String = entries.iter().map(|e| format!("%_{e}\n")).collect(); + format!("%_/ArtDictionary :\n{lines}%_;\n%_\n") +} + +/// The wrap outline a file may keep beside the options: art, then its key. +const WRAP_PATH: &str = "X=\n%_4 As\n%_0 0 m\n%_0 20 L\n%_20 20 L\n%_20 0 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,"; + +fn wrap_of(n: &Node) -> Option { + n.extras().wrap +} + +#[test] +fn wrap_dictionary_path() { + let dict = wrap_dictionary(&[WRAP_PATH, "0 /Bool (AI11TextWrapInvert) ,", "12.5 /Real (AI11TextWrapOffset) ,"]); + let r = read_ok(&layer(&format!("{RED}{}f\n{dict}", rect_path(10.0, 10.0, 50.0, 50.0)))); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1, "the kept outline isn't art: {:?}", r.warnings); + assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false })); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn wrap_dictionary_invert_and_default() { + let inverted = wrap_dictionary(&["12.5 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]); + let default = wrap_dictionary(&["0 /Bool (AI11TextWrapInvert) ,"]); + let plain = wrap_dictionary(&["(Plain) /String (AIArtName) ,"]); + let r = read_ok(&layer(&format!( + "{RED}{}f\n{inverted}{}f\n{default}{}f\n{plain}", + rect_path(0.0, 0.0, 10.0, 10.0), + rect_path(20.0, 0.0, 30.0, 10.0), + rect_path(40.0, 0.0, 50.0, 10.0) + ))); + let kids = art(&r.doc); + assert_eq!(kids.len(), 3); + assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: true })); + assert_eq!(wrap_of(&kids[1]), Some(vectorcraft_doc::TextWrap::default()), "6 pt when the offset isn't given"); + assert_eq!(wrap_of(&kids[2]), None, "an object without wrap options isn't a wrap object"); + assert_eq!(kids[2].name.as_deref(), Some("Plain")); +} + +#[test] +fn wrap_dictionary_bad_offset() { + let offsets = ["nan /Real", "1e308 /Real", "(far) /String", "2000 /Real", "-2000 /Real"]; + let mut body = RED.to_string(); + for (i, offset) in offsets.iter().enumerate() { + let x = 20.0 * i as f64; + body += &format!("{}f\n", rect_path(x, 0.0, x + 10.0, 10.0)); + body += &wrap_dictionary(&[&format!("{offset} (AI11TextWrapOffset) ,"), "1 /Bool (AI11TextWrapInvert) ,"]); + } + // An offset that isn't a number, with nothing else of the options, isn't a wrap object. + body += &format!("{}f\n", rect_path(200.0, 0.0, 210.0, 10.0)); + body += &wrap_dictionary(&["(far) /String (AI11TextWrapOffset) ,", "7 /Bool (AI11TextWrapInvert) ,"]); + let r = read_ok(&layer(&body)); + let offsets: Vec> = art(&r.doc).iter().map(|n| wrap_of(n).map(|w| w.offset)).collect(); + assert_eq!(offsets, [Some(6.0), Some(1000.0), Some(6.0), Some(1000.0), Some(-1000.0), None]); + assert!(art(&r.doc).iter().take(5).all(|n| wrap_of(n).is_some_and(|w| w.invert))); +} + +#[test] +fn wrap_dictionary_group() { + let dict = wrap_dictionary(&["6 /Real (AI11TextWrapOffset) ,", "0 /Bool (AI11TextWrapInvert) ,"]); + let r = read_ok(&layer(&format!("u\n{RED}{}f\n{}f\nU\n{dict}", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0)))); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1); + assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap::default())); + let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) }; + assert_eq!(children.len(), 2); + assert!(children.iter().all(|c| wrap_of(c).is_none() && matches!(c.kind, NodeKind::Path { .. }))); +} + +/// Wraparound text in the version 7 format (§15.4.2): `*w`, objects, the area text, the objects it +/// wraps around, `*W`. +#[test] +fn legacy_wrap_group() { + let text = format!("1 To\n1 0 0 1 0 0 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Around) Tx\nTO\n", rect_path(0.0, 0.0, 100.0, 80.0)); + // The last object's own dictionary gives its options. + let own = wrap_dictionary(&["3 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]); + let body = format!( + "*w\n{RED}{}f\n{text}{}f\n{}f\n{own}*W\n", + rect_path(0.0, 0.0, 5.0, 5.0), + rect_path(30.0, 30.0, 50.0, 50.0), + rect_path(60.0, 30.0, 70.0, 50.0) + ); + let r = read_ok(&layer(&body)); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1, "{:?}", r.warnings); + let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) }; + let wraps: Vec<(bool, bool)> = children.iter().map(|c| (matches!(c.kind, NodeKind::Text(_)), wrap_of(c).is_some())).collect(); + assert_eq!(wraps, [(false, true), (true, false), (false, true), (false, true)], "objects before the text are wrap objects too: {:?}", r.warnings); + let none = Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false }); + assert_eq!(wrap_of(&children[0]), none, "no offset"); + assert_eq!(wrap_of(&children[2]), none, "no offset"); + assert_eq!(wrap_of(&children[3]), Some(vectorcraft_doc::TextWrap { offset: 3.0, invert: true })); + assert!(!r.warnings.iter().any(|w| w.contains("*w") || w.contains("*W")), "{:?}", r.warnings); +} + +/// A wraparound group with no type in it: each of its objects is a wrap object, with no offset. +#[test] +fn legacy_wrap_group_without_text() { + let body = format!("*w\n{RED}{}f\n{}f\n*W\n", rect_path(0.0, 0.0, 5.0, 5.0), rect_path(30.0, 30.0, 50.0, 50.0)); + let r = read_ok(&layer(&body)); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1, "{:?}", r.warnings); + let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) }; + let wraps: Vec<_> = children.iter().map(|c| wrap_of(c)).collect(); + assert_eq!(wraps, [Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false }); 2]); +} + +/// `*w` and `*W` take no operands, and a wraparound group can't be an element of a compound path. +#[test] +fn legacy_wrap_group_errors() { + let square = format!("{RED}{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + for (body, error) in [ + (format!("1 *w\n{square}*W\n"), "`*w` takes no operands"), + (format!("*w\n{square}0 *W\n"), "`*W` takes no operands"), + (format!("*u\n*w\n{square}*W\n*U\n"), "a wraparound group is inside a compound path"), + (format!("*u\nu\n*w\n{square}*W\nU\n*U\n"), "a wraparound group is inside a compound path"), + ] { + let e = read(stream(&layer(&body)).as_bytes()).unwrap_err(); + assert!(e.contains(error), "{body:?}: {e}"); + } +} + +/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside +/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads +/// from its layers, without them (the crop area is `%AI3_Cropmarks`). +#[test] +fn crop_marks_after_the_layers_are_not_art() { + let crops = "%AI3_BeginCrops\nuserdict /AI3_noCropMarks known not {\n0 A\nu\n0 R\n1 1 1 1 ([Registration]) 0 XS\n\ + 0 J 0 j 0.5 w 4 M []0 d\n0 XR\n-9 -400 m\n-36 -400 L\nS\nU\n} if\n%AI3_EndCrops\n"; + let r = read_ok(&format!("{}{crops}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert_eq!(art(&r.doc).len(), 1); + assert_eq!(r.doc.layers.len(), 1); +} + +/// The name of an Objects on Path record. +const ALIGN_ON_PATH: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files +/// The ids of an Objects on Path group's path and its four objects. +const ON_PATH_IDS: [&str; 5] = [ + "0a6d1e52-0000-4000-8000-000000000001", + "0a6d1e52-0000-4000-8000-000000000002", + "0a6d1e52-0000-4000-8000-000000000003", + "0a6d1e52-0000-4000-8000-000000000004", + "0a6d1e52-0000-4000-8000-000000000005", +]; +const OPEN_PATH: &str = "20 20 m\n100 80 L\n180 20 L\nS\n"; +const CLOSED_PATH: &str = + "40 50 m\n40 66.57 66.86 80 100 80 c\n133.14 80 160 66.57 160 50 c\n160 33.43 133.14 20 100 20 c\n66.86 20 40 33.43 40 50 c\ns\n"; + +/// An Objects on Path group: the stroked `path` with the id `ids[0]`, then a 10 × 6 red square for +/// each other id, 50 pt apart from x 20, whose frame is turned `angle` radians. +fn on_path_group(path: &str, ids: &[&str], angle: f64) -> String { + use vectorcraft_testkit::ai::on_path_member; + let (first, rest) = ids.split_first().unwrap(); + let mut group = format!("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}", on_path_member(first, None, 0.0)); + for (i, id) in rest.iter().enumerate() { + let x = 20.0 + 50.0 * i as f64; + group += &format!("{RED}{}f\n{}", rect_path(x, 40.0, x + 10.0, 46.0), on_path_member(id, Some(i), angle)); + } + group + "U\n" +} + +/// The group the layer of `body` holds first, and its Objects on Path record. +fn on_path(body: &str) -> (Arc, Option) { + let doc = read_ok(body).doc; + let g = art(&doc).first().cloned().unwrap(); + let rec = g.extras().objects_on_path.as_deref().cloned(); + (g, rec) +} + +/// An Objects on Path group opens as one, with its path, its objects in their order along the path, +/// Pivot and Rotate, and its art where the file draws it; the same art without the record opens as a +/// plain group. +#[test] +fn an_objects_on_path_group_opens_with_its_path_objects_and_options() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.5); + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 0, std::f64::consts::FRAC_PI_6, "")); + let (g, rec) = on_path(&format!("{record}{}", layer(&group))); + let rec = rec.expect("an Objects on Path group"); + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 5); + let NodeKind::Path { path, .. } = &kids.iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") }; + assert!(path.subpaths.first().is_some_and(|s| !s.closed), "the open path"); + let child = |id| kids.iter().find(|c| c.id == id).unwrap(); + let xs: Vec = rec.members.iter().map(|m| child(m.id).geometric_bounds().unwrap().x0).collect(); + assert_eq!(xs, [20.0, 70.0, 120.0, 170.0], "the objects in the file's order"); + for m in &rec.members { + let center = child(m.id).geometric_bounds().unwrap().center(); + assert_eq!((m.angle, m.frame), (-0.5, kurbo::Affine::rotate_about(-0.5, center)), "the frame turned counterclockwise"); + } + assert!((rec.rotate - 30.0).abs() < 1e-9, "{}", rec.rotate); + assert_eq!(rec.pivot, 0); + let (plain, none) = on_path(&layer(&group)); + assert!(none.is_none()); + let kinds = |g: &Node| g.children().unwrap().iter().map(|c| c.kind.clone()).collect::>(); + assert_eq!(kinds(&g), kinds(&plain), "the art stays where it is drawn"); +} + +/// A group on a closed path opens as an Objects on Path group too. +#[test] +fn an_objects_on_path_group_on_a_closed_path_opens_live() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let (g, rec) = on_path(&format!("{record}{}", layer(&on_path_group(CLOSED_PATH, &ON_PATH_IDS, 0.0)))); + let rec = rec.expect("an Objects on Path group"); + let NodeKind::Path { path, .. } = &g.children().unwrap().iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") }; + assert!(path.subpaths.first().is_some_and(|s| s.closed), "the closed path"); + assert_eq!((rec.members.len(), rec.pivot, rec.rotate), (4, 4, 0.0)); +} + +/// A record the group can't be read from, or with options an Objects on Path group doesn't hold, +/// leaves the group a plain group with its art. +#[test] +fn an_objects_on_path_record_that_doesnt_fit_leaves_a_plain_group() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let [p, a, b, c, d] = ON_PATH_IDS; + let options = |more: &str| on_path_options(4, 4, 0.0, more); + let good = options(""); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + assert!(on_path(&format!("{}{}", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), layer(&group))).1.is_some()); + let cases: Vec<(&str, String, String)> = vec![ + ("the path's id is missing", on_path_record(ALIGN_ON_PATH, &["0a6d1e52-ffff", a, b, c, d], &good), group.clone()), + ("an object's id is missing", on_path_record(ALIGN_ON_PATH, &[p, a, b, c], &good), group.clone()), + ("an id twice", on_path_record(ALIGN_ON_PATH, &[p, a, b, b, d], &good), group.clone()), + ("objects out of order", on_path_record(ALIGN_ON_PATH, &[p, b, a, c, d], &good), group.clone()), + ("a spacing", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_56.5 /Real (Spacing) ,\n")), group.clone()), + ("an option this reader doesn't know", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_1 /Bool (Flip) ,\n")), group.clone()), + ( + "uneven spacing", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("1 /Bool (IsEquiSpaced)", "0 /Bool (IsEquiSpaced)")), + group.clone(), + ), + ( + "a later start", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("0 /Real (StartRangeFraction)", "0.25 /Real (StartRangeFraction)")), + group.clone(), + ), + ("a pivot past 8", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 9, 0.0, "")), group.clone()), + ("another constraint", on_path_record("Some Other Constraint", &ON_PATH_IDS, &good), group.clone()), + ( + "another version", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good).replace("%_1 /Int (Constraint Verison)", "%_2 /Int (Constraint Verison)"), + group.clone(), + ), + ("an object without its angle", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_(0) /String (BBAccumRotation) ,\n", "")), + ( + "another child in the group", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), + group.replace("U\n", &format!("{}f\nU\n", rect_path(0.0, 0.0, 5.0, 5.0))), + ), + ("a path without its key", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_1 /Bool (KeyPath) ,\n", "")), + ]; + for (why, record, group) in cases { + let (g, rec) = on_path(&format!("{record}{}", layer(&group))); + assert!(rec.is_none(), "{why}"); + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{why}"); + assert!(g.children().unwrap().len() >= 5, "{why}: the art is kept"); + } +} + +/// The path and objects of a group are measured once however many records name it: a thousand +/// copies of a record open about as fast as one copy followed by 999 records whose path id is +/// missing, when the group opens live, when its long path has no length, and when its first object +/// is a group of many paths and its last object has no bounds. +#[test] +fn repeated_objects_on_path_records_dont_measure_the_objects_again() { + use vectorcraft_testkit::ai::{on_path_member, on_path_options, on_path_record}; + let paths: String = (0..20_000).map(|i| format!("{}f\n", rect_path(f64::from(i % 200), 40.0, f64::from(i % 200) + 1.0, 46.0))).collect(); + let big = format!("u\n{RED}{paths}U\n{}", on_path_member(ON_PATH_IDS[1], Some(0), 0.0)); + let empty = format!("u\nU\n{}", on_path_member(ON_PATH_IDS[2], Some(1), 0.0)); + let still = format!("20 20 m\n{}S\n", "20 20 L\n".repeat(20_000)); + let cases = [ + ("a live group", OPEN_PATH.to_string(), big.clone(), 2, true), + ("a path without length", still, big.clone(), 2, false), + ("an object without bounds", OPEN_PATH.to_string(), format!("{big}{empty}"), 3, false), + ]; + for (why, path, objects, n, live) in cases { + let ids = &ON_PATH_IDS[..n]; + let group = format!( + "0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}{objects}U\n", + on_path_member(ids[0], None, 0.0) + ); + let record = on_path_record(ALIGN_ON_PATH, ids, &on_path_options(n - 1, 4, 0.0, "")); + let (start, end) = (record.find("%_/Dictionary :\n%_1 /Int").unwrap(), record.find("%_; (Constraint Data)").unwrap()); + let copy = &record[start..end]; + let unresolved = copy.replace(ids[0], "0a6d1e52-ffff-4000-8000-000000000000"); + let one = format!("{}{copy}{}{}", &record[..start], unresolved.repeat(999), &record[end..]); + let many = format!("{}{}{}", &record[..start], copy.repeat(1000), &record[end..]); + let time = |data: &str| { + let started = std::time::Instant::now(); + let doc = read_ok(&format!("{data}{}", layer(&group))).doc; + let elapsed = started.elapsed(); + let g = art(&doc)[0].clone(); + assert_eq!(g.children().unwrap().len(), n, "{why}"); + assert_eq!(g.extras().objects_on_path.is_some(), live, "{why}"); + elapsed + }; + // The fastest of three runs each, taken in turn, so that other tests running at the same + // time weigh on both. + let (mut fastest_one, mut fastest_many) = (std::time::Duration::MAX, std::time::Duration::MAX); + for _ in 0..3 { + fastest_one = fastest_one.min(time(&one)); + fastest_many = fastest_many.min(time(&many)); + } + assert!(fastest_many < fastest_one * 5, "{why}: one copy: {fastest_one:?}, 1000 copies: {fastest_many:?}"); + } +} + +/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once, +/// live. +#[test] +fn an_objects_on_path_group_in_versioned_content_opens_once() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", ""); + let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n"); + let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc; + let kids = art(&doc); + assert_eq!(kids.len(), 1); + assert_eq!(kids[0].children().unwrap().len(), 5); + assert!(kids[0].extras().objects_on_path.is_some()); +} + +use read::{GRAPH_PLAIN, GRAPH_UNENDED}; + +/// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`, +/// the art size and the template box in the header, then `body`. +fn stream7(body: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 2.0\n%AI5_ArtSize: 612 792\n%AI3_TemplateBox: 306 396 306 396\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n" + ) +} + +/// One layer named `name` in the legacy format (ten operands before `Lb`) holding `art`. +fn layer7(name: &str, art: &str) -> String { + format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n({name}) Ln\n0 A\n{art}\nLB\n%AI5_EndLayer--\n") +} + +/// A graph's data on `%_` lines, in the order the version 7 specification's chapter 17 gives: +/// bounds, style and dialog settings, a subscription, three axes, a column width and the table +/// (two rows of two cells, one of them empty). +const GRAPH_DATA: &str = "%_Gs\n%_10 10 110 90 Gb\n%_3 0 0 12 1 1 0 0 2 30 0 Gy\n%_6 1 1 0 80 70 0\n%_ 50 60 80 70 Gd\n%_0 1 Gj\n\ + %_1 () Ga 0 12 0 0 0 0 0 () 0 0 GA\n%_2 () Ga 0 12 0 1 0.5 0 1 () 0 0 GA\n%_3 () Ga 0 12 0 4 1 0 0 () 0 0 GA\n%_40 Gw\n\ + %_2 2 1 1 Gz\n%_() Gc (East) Gc\n%_(Q1) Gc 7 Gc GC\n"; + +/// A graph's settings (`Gt` … `GT`): a design's name and paint operators on `%_` lines. +const GRAPH_SETTINGS: &str = "%_Gt\n%_(Bars) GD\n%_0.5 G\n%_0 g\n%_1 0 Gv\n%_GT\n"; + +/// A graph whose drawn art is `art` (in its group, then `%_GS`). +fn graph(settings: &str, art: &str) -> String { + format!("{GRAPH_DATA}{settings}u\n{art}U\n%_GS\n") +} + +/// A filled bar of a graph, with its `Go` tag. +fn bar(x: f64) -> String { + format!("0 0 0 1 k\n{}f\n%_2 0 0 0 Go\n", rect_path(x, 10.0, x + 20.0, 50.0)) +} + +/// The gray (`k`) of `n`'s stroke. +fn stroke_k(n: &Node) -> Option { + match n.appearance.stroke().and_then(|s| s.paint.color()) { + Some(Color::Gray { k }) => Some(k), + _ => None, + } +} + +/// A graph opens as its drawn art: the bar comes in, in its group, with one note. +#[test] +fn a_graph_opens_as_its_drawn_art() { + let r = read(stream7(&layer7("Graph", &graph(GRAPH_SETTINGS, &bar(10.0)))).as_bytes()).unwrap(); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + let NodeKind::Group { children, clip: false } = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert_eq!(children.len(), 1); + assert!(matches!(children[0].kind, NodeKind::Path { .. })); + assert_eq!(fill(&children[0]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!(r.warnings, [GRAPH_PLAIN]); + assert!(r.hidden_unread.is_empty(), "the records aren't unread art: {:?}", r.hidden_unread); + // Two graphs, the second without settings: one note still. + let two = format!("{}{}", graph(GRAPH_SETTINGS, &bar(10.0)), graph("", &bar(40.0))); + let r = read(stream7(&layer7("Graph", &two)).as_bytes()).unwrap(); + assert_eq!(art(&r.doc).len(), 2); + assert_eq!(r.warnings, [GRAPH_PLAIN]); + // A graph with only the records a minimal one needs (bounds and table), and the newer header. + let minimal = "%_Gs\n%_0 0 50 50 Gb\n%_1 1 1 1 Gz\n%_3 Gc GC\n"; + let r = read_ok(&layer(&format!("{minimal}u\n{}U\n%_GS\n", bar(0.0)))); + assert_eq!(art(&r.doc).len(), 1); + assert_eq!(r.warnings, [GRAPH_PLAIN]); +} + +/// Paint operators on `%_` lines in a graph's settings set nothing; the same operators in its drawn +/// art, and after it, paint as usual. +#[test] +fn a_graphs_settings_paint_nothing_and_its_drawn_art_paints_as_usual() { + let stroked = |x: f64| format!("{}S\n", rect_path(x, 60.0, x + 5.0, 70.0)); + let drawn = format!("{}0.25 G\n{}", stroked(0.0), stroked(10.0)); + let body = format!("0.75 G\n{}0.5 G\n{}", graph(GRAPH_SETTINGS, &drawn), stroked(20.0)); + let r = read(stream7(&layer7("Graph", &body)).as_bytes()).unwrap(); + let a = art(&r.doc); + assert_eq!(a.len(), 2); + let kids = a[0].children().unwrap(); + assert_eq!(stroke_k(&kids[0]), Some(0.25), "the gray set before the graph, not its settings' `%_0.5 G`"); + assert_eq!(stroke_k(&kids[1]), Some(0.75), "a gray set in the drawn art"); + assert_eq!(stroke_k(&a[1]), Some(0.5), "a gray set after the graph"); +} + +/// Graph operators outside a graph stay operators the reader doesn't know: an error on a layer that +/// shows, noted as unread on `%_` lines (as any operator there), and no graph note. +#[test] +fn graph_operators_outside_a_graph_are_unknown() { + for stray in ["Gs\n", "Go\n", "1 2 Gc\n"] { + let err = read(stream7(&layer7("L", &format!("{}{stray}", rect_path(0.0, 0.0, 5.0, 5.0)))).as_bytes()).unwrap_err(); + assert!(err.contains("operators VectorCraft doesn't know"), "{stray}: {err}"); + } + for (stray, op) in [("%_2 0 0 0 Go\n", "`Go`"), ("%_GS\n", "`GS`"), ("%_1 2 Gc\n", "`Gc`")] { + let r = read(stream7(&layer7("L", &format!("{}f\n{stray}", rect_path(0.0, 0.0, 5.0, 5.0)))).as_bytes()).unwrap(); + assert!(r.hidden_unread.contains(op), "{stray}: {:?}", r.hidden_unread); + assert!(r.warnings.is_empty(), "{stray}: {:?}", r.warnings); + } +} + +/// A graph that starts inside another is noted and the first goes on; one that doesn't end stops at +/// its layer's end, with the art read so far, and the next layer reads as usual. +#[test] +fn malformed_graphs_keep_their_art() { + let nested = format!("{GRAPH_DATA}u\n{}%_Gs\n{}U\n%_GS\n", bar(0.0), bar(30.0)); + let r = read(stream7(&layer7("Graph", &nested)).as_bytes()).unwrap(); + assert_eq!(art(&r.doc)[0].children().unwrap().len(), 2); + assert_eq!(r.warnings, [GRAPH_PLAIN, GRAPH_UNENDED]); + + let unended = format!("{GRAPH_DATA}%_Gt\nu\n{}U\n", bar(0.0)); + let next = format!("%_0.5 G\n{}S\n", rect_path(0.0, 60.0, 5.0, 70.0)); + let r = read(stream7(&format!("{}{}", layer7("Graph", &unended), layer7("Next", &next))).as_bytes()).unwrap(); + assert_eq!(art(&r.doc).len(), 1, "the drawn art read before the layer ended"); + let after = r.doc.layers[1].children().unwrap(); + assert_eq!(stroke_k(&after[0]), Some(0.5), "the next layer isn't in the graph's settings"); + assert_eq!(r.warnings, [GRAPH_PLAIN, GRAPH_UNENDED]); + + // One that doesn't end before the file does. + let cut = stream7(&layer7("Graph", &format!("{GRAPH_DATA}u\n{}U\n", bar(0.0)))).replace("LB\n%AI5_EndLayer--\n", ""); + assert!(read(cut.as_bytes()).is_err(), "a layer that doesn't end is still an error"); +} + +/// A graph's records in the shapes the specification's appendix A gives: several cells on one line, +/// strings, arrays and names as operands, and empty groups among its drawn art. +#[test] +fn a_graphs_records_take_their_operands() { + let settings = "%_Gt\n%_(Bars) GD [1 2 3] 0 Ge\n%_/Name 1 Gi (a) (b) Gl\n%_1 2 3 4 Gm 0 Gp 1 Gr 0 G+ 2 G1 0 Gf 0 Gg 0 Gx 1 GX\n%_GT\n"; + let drawn = format!("u\nU\nu\nu\nU\nU\n{}", bar(10.0)); + let r = read(stream7(&layer7("Graph", &graph(settings, &drawn))).as_bytes()).unwrap(); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + let kids = a[0].children().unwrap(); + fn paths(n: &Node) -> usize { + match n.children() { + Some(c) => c.iter().map(|c| paths(c)).sum(), + None => usize::from(matches!(n.kind, NodeKind::Path { .. })), + } + } + assert!(matches!(kids.last().map(|k| &k.kind), Some(NodeKind::Path { .. })), "the bar is kept after the empty groups: {kids:?}"); + assert_eq!(paths(&a[0]), 1, "the empty groups hold no art"); + assert_eq!(r.warnings, [GRAPH_PLAIN]); +} + +/// Editing data in the legacy format (`%AI5_FileFormat 2.0`, ten `Lb` operands), then `body`. +fn mask_stream7(body: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%%BoundingBox: 0 0 200 200\n%AI5_FileFormat 2.0\n%AI5_ArtSize: 200 200\n\ + %AI3_TemplateBox: 100 100 100 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n" + ) +} + +/// The start of a legacy layer named `name`, `masks` its multi-layer mask flag (`Lb`'s sixth operand). +fn begin7(name: &str, masks: u8) -> String { + format!("%AI5_BeginLayer\n1 1 1 1 0 {masks} 1 79 128 255 Lb\n({name}) Ln\n") +} + +const END7: &str = "LB\n%AI5_EndLayer--\n"; + +/// `Mb`, an 80 by 80 point mask path painted `W n`, `Md` (the version 7 specification's §10.4 order). +fn mask_start() -> String { + format!("Mb\n{}h\nW\nn\nMd\n", rect_path(40.0, 40.0, 120.0, 120.0)) +} + +/// A filled square at `x`. +fn masked(x: f64) -> String { + format!("0.5 0 0 0 k\n{}f\n", rect_path(x, x, x + 140.0, x + 140.0)) +} + +/// A layer's objects as a tree: `p` a path, `c` a clipping path, `o` a compound path, `g[…]` a +/// group, `k[…]` a clipping group, `l[…]` a sublayer. +fn mask_tree(nodes: &[Arc]) -> String { + let one = |n: &Arc| match &n.kind { + NodeKind::Layer { children, .. } => format!("l[{}]", mask_tree(children)), + NodeKind::Group { children, clip: false } => format!("g[{}]", mask_tree(children)), + NodeKind::Group { children, clip: true } => format!("k[{}]", mask_tree(children)), + NodeKind::Path { clipping: true, .. } => "c".into(), + NodeKind::Path { .. } => "p".into(), + NodeKind::Compound { .. } => "o".into(), + k => format!("{k:?}"), + }; + nodes.iter().map(one).collect::>().join(" ") +} + +fn read7(body: &str) -> Structure { + match read(mask_stream7(body).as_bytes()) { + Ok(r) => r, + Err(e) => panic!("{e}"), + } +} + +/// Each layer's tree, and whether the layer itself clips. +fn layer_trees(doc: &Document) -> Vec<(String, String, bool)> { + doc.layers + .iter() + .map(|l| { + let NodeKind::Layer { children, clip, .. } = &l.kind else { panic!("a layer") }; + (l.name.clone().unwrap_or_default(), mask_tree(children), *clip) + }) + .collect() +} + +/// A multi-layer mask (version 7 specification §10.4: `Mb`, the mask path, `Md`, objects in two +/// layers, `MB`) comes in as a clipping group in each layer it covers, each clipped by a copy of +/// the mask's path; what follows `MB` isn't clipped. +#[test] +fn a_multi_layer_mask_clips_each_layer_it_covers() { + let body = format!("{}{}{}{END7}{}{}MB\n{}{END7}", begin7("A", 1), mask_start(), masked(15.0), begin7("B", 1), masked(30.0), masked(150.0)); + let r = read7(&body); + assert_eq!(layer_trees(&r.doc), [("A".to_string(), "k[c p]".to_string(), false), ("B".to_string(), "k[c p] p".to_string(), false)]); + let mask = |l: usize| r.doc.layers[l].children().unwrap()[0].children().unwrap()[0].clone(); + let (a, b) = (mask(0), mask(1)); + assert_ne!(a.id, b.id, "each layer has a copy of its own"); + assert_eq!(a.geometric_bounds(), b.geometric_bounds()); + let bounds = a.geometric_bounds().unwrap(); + assert_eq!((bounds.width(), bounds.height()), (80.0, 80.0)); + assert!(r.warnings.iter().any(|w| w.contains("clipping group in each layer")), "{:?}", r.warnings); + // The layers' flag saying they have multi-layer masks changes nothing. + let unflagged = read7(&body.replace(&begin7("A", 1), &begin7("A", 0)).replace(&begin7("B", 1), &begin7("B", 0))); + assert_eq!(format!("{:?}", unflagged.doc.layers), format!("{:?}", r.doc.layers)); +} + +#[test] +fn a_multi_layer_mask_in_one_layer_or_over_three() { + let one = format!("{}{}{}MB\n{}{END7}", begin7("A", 1), mask_start(), masked(15.0), masked(150.0)); + assert_eq!(layer_trees(&read7(&one).doc), [("A".to_string(), "k[c p] p".to_string(), false)]); + let three = format!( + "{}{}{}{END7}{}{}{END7}{}{}MB\n{END7}", + begin7("A", 1), + mask_start(), + masked(15.0), + begin7("B", 1), + masked(30.0), + begin7("C", 1), + masked(45.0) + ); + let trees: Vec<_> = layer_trees(&read7(&three).doc).into_iter().map(|(_, t, clip)| (t, clip)).collect(); + assert_eq!(trees, vec![("k[c p]".to_string(), false); 3]); + // Objects before the mask's path are masked too; a layer between with nothing in it stays empty. + let before = format!( + "{}Mb\n{}{}h\nW\nn\nMd\n{END7}{}{END7}{}{}MB\n{END7}", + begin7("A", 1), + masked(15.0), + rect_path(40.0, 40.0, 120.0, 120.0), + begin7("B", 1), + begin7("C", 1), + masked(45.0) + ); + let trees: Vec<_> = layer_trees(&read7(&before).doc).into_iter().map(|(_, t, _)| t).collect(); + assert_eq!(trees, ["k[c p]", "", "k[c p]"]); +} + +#[test] +fn a_multi_layer_mask_in_a_group_or_sublayer() { + // Begun and ended in a group of one layer. + let grouped = format!("{}u\n{}{}MB\n{}U\n{END7}", begin7("A", 1), mask_start(), masked(15.0), masked(150.0)); + assert_eq!(layer_trees(&read7(&grouped).doc)[0].1, "g[k[c p] p]"); + // A sublayer in the mask: what the mask covers in the layer around it and in the sublayer. + let nested = format!("{}{}{}{}{}{END7}{}MB\n{END7}", begin7("A", 1), mask_start(), masked(15.0), begin7("S", 1), masked(30.0), masked(45.0)); + assert_eq!(layer_trees(&read7(&nested).doc)[0].1, "k[c p] l[k[c p]] k[c p]"); +} + +#[test] +fn a_multi_layer_masks_path_can_be_painted_or_compound() { + // `W f`: the path also paints, under what the mask covers. + let painted = format!("{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}MB\n{END7}", begin7("A", 1), rect_path(40.0, 40.0, 120.0, 120.0), masked(15.0)); + let doc = read7(&painted).doc; + assert_eq!(layer_trees(&doc)[0].1, "k[c p p]"); + let group = doc.layers[0].children().unwrap()[0].clone(); + let kids = group.children().unwrap(); + assert_eq!(fill(&kids[1]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the painted path first"); + // A compound path of clipping paths. + let compound = format!( + "{}Mb\n*u\n{}h\nW\nn\n{}h\nW\nn\n*U\nMd\n{}MB\n{END7}", + begin7("A", 1), + rect_path(40.0, 40.0, 80.0, 80.0), + rect_path(90.0, 90.0, 120.0, 120.0), + masked(15.0) + ); + assert_eq!(layer_trees(&read7(&compound).doc)[0].1, "k[o p]"); +} + +/// `Mv` starts a mask as `Mb` does, `ME` defines one as `Md` does, and `Mi` changes nothing. +#[test] +fn other_multi_layer_mask_operators() { + let body = format!("{}{}{}{END7}{}{}MB\n{}{END7}", begin7("A", 1), mask_start(), masked(15.0), begin7("B", 1), masked(30.0), masked(150.0)); + let plain = format!("{:?}", read7(&body).doc.layers); + let aliases = body.replace("Mb\n", "Mv\n").replace("Md\n", "ME\n"); + assert_eq!(format!("{:?}", read7(&aliases).doc.layers), plain); + let with_mi = body.replace("Md\n", "Md\nMi\n").replace("MB\n", "Mi\nMB\n"); + assert_eq!(format!("{:?}", read7(&with_mi).doc.layers), plain); +} + +#[test] +fn a_malformed_multi_layer_mask_is_an_error() { + let path = format!("{}h\nW\nn\n", rect_path(40.0, 40.0, 120.0, 120.0)); + let sq = masked(15.0); + let cases = [ + ("an end without a start", format!("{sq}MB\n")), + ("a definition without a start", format!("{path}Md\n{sq}")), + ("no mask path", format!("Mb\n{sq}Md\n{sq}MB\n")), + ("a start inside another", format!("Mb\nMb\n{path}Md\n{sq}MB\n")), + ("an end before the definition", format!("Mb\n{path}MB\n")), + ("a definition in another group", format!("Mb\nu\n{path}Md\n{sq}MB\nU\n")), + ("a group crossing the end", format!("{}u\n{sq}MB\nU\n", mask_start())), + ("a group crossing the start", format!("u\n{}{sq}U\nMB\n", mask_start())), + ]; + for (why, art) in cases { + let body = format!("{}{art}{END7}", begin7("A", 1)); + let e = read(mask_stream7(&body).as_bytes()).unwrap_err(); + assert!(e.contains("mask"), "{why}: {e}"); + } + let layered = [ + ("a layer ends before the definition", format!("{}Mb\n{sq}{END7}{}{path}Md\nMB\n{END7}", begin7("A", 1), begin7("B", 1))), + ("a group open at the layer's end", format!("{}{}u\n{sq}{END7}{}U\nMB\n{END7}", begin7("A", 1), mask_start(), begin7("B", 1))), + ("the end of the file inside the mask", format!("{}{}{sq}{END7}", begin7("A", 1), mask_start())), + ]; + for (why, body) in layered { + let e = read(mask_stream7(&body).as_bytes()).unwrap_err(); + assert!(e.contains("mask"), "{why}: {e}"); + } + // Outside the layers, `Md` has nowhere to take its path from. + let outside = format!("Mb\nMd\n{}{path}{sq}MB\n{END7}", begin7("A", 1)); + let e = read(mask_stream7(&outside).as_bytes()).unwrap_err(); + assert!(e.contains("mask"), "{e}"); +} + +/// A clipping path painted among the masked objects clips its whole layer, as outside a mask. +#[test] +fn a_layers_clipping_path_in_a_multi_layer_mask_clips_the_layer() { + let path = format!("{}h\nW\nn\n", rect_path(10.0, 10.0, 190.0, 190.0)); + let body = format!("{}{}{}{path}{}MB\n{END7}", begin7("A", 1), mask_start(), masked(15.0), masked(30.0)); + assert_eq!(layer_trees(&read7(&body).doc), [("A".to_string(), "c k[c p p]".to_string(), true)]); +} + +/// `Mb` before the first layer it covers and `MB` after the last one's `LB`. +#[test] +fn a_multi_layer_mask_begun_or_ended_between_layers() { + let body = format!( + "Mb\n{}{}h\nW\nn\nMd\n{}{END7}{}{}{END7}MB\n{}{}{END7}", + begin7("A", 1), + rect_path(40.0, 40.0, 120.0, 120.0), + masked(15.0), + begin7("B", 1), + masked(30.0), + begin7("C", 1), + masked(150.0) + ); + let trees: Vec<_> = layer_trees(&read7(&body).doc).into_iter().map(|(_, t, _)| t).collect(); + assert_eq!(trees, ["k[c p]", "k[c p]", "p"]); +} + +/// Each copy of the mask counts toward the most objects read, with its points. +#[test] +fn copies_of_a_multi_layer_mask_count_toward_the_limit() { + let points: String = (0..2_000_001).map(|i| format!("{} 40 L\n", 40 + i % 80)).collect(); + let mask = format!("Mb\n40 40 m\n{points}h\nW\nn\nMd\n"); + let one = format!("{}{mask}{}MB\n{END7}", begin7("A", 1), masked(15.0)); + assert_eq!(layer_trees(&read7(&one).doc)[0].1, "k[c p]"); + let two = format!("{}{mask}{}{END7}{}{}MB\n{END7}", begin7("A", 1), masked(15.0), begin7("B", 1), masked(30.0)); + let e = read(mask_stream7(&two).as_bytes()).unwrap_err(); + assert!(e.contains("objects"), "{e}"); +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 7c90b15aa..cb4c2594b 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -8,9 +8,9 @@ //! The format isn't documented. What is read here was worked out from the files of the project's //! users, used locally and never committed, by comparing it with the type their pages draw: the //! meaning of each key below was checked against the page's own type in more than a thousand text -//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area -//! type, type on a path, anything with a frame that has more than a point and a matrix) is left -//! alone: it has no text, and the importer says so. +//! objects, and it is only used for the keys that matched. Point type, area type and type on a path +//! are read; a story in a frame of any other kind is left alone: it has no text, and the importer +//! says so. //! //! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of //! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an @@ -235,22 +235,101 @@ fn read(bytes: &[u8]) -> Option { } } +/// Most character styles read of a text document, and most styles a run's style is followed +/// through to the ones they are based on. +const MAX_SHEETS: usize = 4096; +const MAX_SHEETS_BASED_ON: usize = 16; + /// Most frames read of one story, and most path segments of a frame. const MAX_FRAMES: usize = 4096; const MAX_SEGMENTS: usize = 1 << 16; /// Where a frame puts its story's text. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] enum FrameKind { /// Point type: the matrix carries the layout (its anchor at the origin of its lines) onto the /// canvas. Point([f64; 6]), - /// Area type flowed inside a path: its cubic segments on the canvas, four points each. - Area(Vec<[f64; 8]>), + /// Area type flowed inside a path: its cubic segments on the canvas, four points each, and the + /// Area Type Options the frame keeps. + Area(Vec<[f64; 8]>, AreaFrame), /// Type on a path, from `start` to `end` (segment index and the place in that segment). OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64 }, } +/// The Area Type Options an area frame keeps, in the units of the text document: each `None` +/// where the frame leaves the key out or keeps a value that can't be read, so the option stays as +/// it was. +#[derive(Debug, Clone, Default, PartialEq)] +struct AreaFrame { + /// Key 3, the rows. + rows: Option, + /// Key 4, the columns. + columns: Option, + /// Key 7, the gutter between rows. + row_gutter: Option, + /// Key 8, the gutter between columns. + column_gutter: Option, + /// Key 10, the first baseline (`0` its kind, `1` its minimum). Without it the first baseline + /// is Ascent. + first_baseline: Option<(vectorcraft_doc::FirstBaseline, f64)>, +} + +/// Longest gutter or first-baseline minimum, as `text.areaOptions` caps them: read in the units of +/// the text document, and again in document points once scaled. +const MAX_AREA_LENGTH: f64 = 10_000.0; + +impl AreaFrame { + /// The options frame data `carries` keeps. + fn read(carries: &Val) -> AreaFrame { + // A whole number, at most `max`. + let whole = |v: &Val, max: f64| v.num().filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(max) as usize); + let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS; + let count = |k: &str| carries.get(k).and_then(|v| whole(v, max as f64)).filter(|n| *n >= 1); + let length = |v: Option<&Val>| v.and_then(Val::num).filter(|n| *n >= 0.0).map(|n| n.min(MAX_AREA_LENGTH)); + let first_baseline = carries.get("10").and_then(|b| { + use vectorcraft_doc::FirstBaseline as F; + let kind = match whole(b.get("0")?, 99.0)? { + 0 => F::Fixed, + 2 => F::CapHeight, + 3 => F::Leading, + 4 => F::XHeight, + // Em Box Height (5) and Legacy (6) have no first baseline of their own here: the + // first baseline stays as it was. + _ => return None, + }; + Some((kind, length(b.get("1")).unwrap_or(0.0))) + }); + AreaFrame { + rows: count("3"), + columns: count("4"), + row_gutter: length(carries.get("7")), + column_gutter: length(carries.get("8")), + first_baseline, + } + } + + /// Sets the options it keeps on `area`, its lengths at `scale` (document points to a unit). + fn apply(&self, area: &mut vectorcraft_doc::AreaOptions, scale: f64) { + if let Some(n) = self.rows { + area.rows = n; + } + if let Some(n) = self.columns { + area.columns = n; + } + // A gutter the frame leaves out stays as it was. + let (row, column) = (area.row_gutter.unwrap_or(area.gutter), area.gutter); + let scaled = |g: f64| (g * scale).min(MAX_AREA_LENGTH); + let row = self.row_gutter.map_or(row, scaled); + area.gutter = self.column_gutter.map_or(column, scaled); + area.row_gutter = (row != area.gutter).then_some(row); + if let Some((kind, min)) = self.first_baseline { + area.first_baseline = kind; + area.first_baseline_min = scaled(min); + } + } +} + /// A paragraph's attributes, as the text document keeps them. #[derive(Clone, Debug, Default, PartialEq)] struct Para { @@ -281,6 +360,11 @@ pub(super) struct Story { paras: Vec<(usize, Para)>, /// `(characters, style)` in order. runs: Vec<(usize, Style)>, + /// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]). + path_spacing: f64, + /// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to + /// left. Its layout's lines run down from the anchor, each column further left. + vertical: bool, } #[derive(Clone, Debug)] @@ -296,6 +380,10 @@ struct Style { fill: Option, stroke: Option, stroke_width: f64, + /// Key 12, the case: 1 Small Caps, 2 All Caps. + caps: u8, + /// Key 35 at 3: Tate-chu-yoko, the characters set across the column as one block. + tate_chu_yoko: bool, } /// The text document of an editing copy. @@ -305,6 +393,9 @@ pub(super) struct Texts { /// The character and paragraph styles a story starts from. defaults: Val, para_defaults: Val, + /// The document's character styles (`[Normal Character Style]` first), each a name, the + /// character style it is based on (key 5, an index into these) and its attributes (key 6). + char_sheets: Vec, frames: Vec, } @@ -348,6 +439,16 @@ impl Texts { fonts, defaults: main.get("2")?.clone(), para_defaults: main.get("3").cloned().unwrap_or(Val::Dict(BTreeMap::new())), + char_sheets: doc + .get("0")? + .get("5") + .and_then(|s| s.get("0")) + .map(Val::list) + .unwrap_or_default() + .iter() + .take(MAX_SHEETS) + .map(|s| s.get("0").cloned().unwrap_or(Val::Dict(BTreeMap::new()))) + .collect(), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), }) } @@ -368,7 +469,7 @@ impl Texts { None => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], Some(m) => m.nums()?.try_into().ok()?, })), - 1 => Some(FrameKind::Area(segments()?)), + 1 => Some(FrameKind::Area(segments()?, AreaFrame::read(carries))), 2 => { let range = carries.get("6").and_then(Val::nums).unwrap_or_default(); let segments = segments()?; @@ -390,24 +491,37 @@ impl Texts { } } + /// Is frame `i` vertical type? Key 1 of what it carries: 2 vertical, 0 or none horizontal. Any + /// other value is ignored: horizontal. + fn frame_vertical(&self, i: usize) -> bool { + let orientation = self.frames.get(i).and_then(|f| f.get("0")).and_then(|f| f.get("2")).and_then(|c| c.get("1")); + orientation.and_then(Val::num) == Some(2.0) + } + /// Story `i`, if its frames are of kinds this reads. pub(super) fn story(&self, i: usize) -> Option { let s = self.stories.get(i)?; let text = s.get("0")?.get("0")?.str().filter(|t| t.len() <= MAX_STORY)?.to_string(); let view = s.get("1")?; - let frames: Vec = view - .get("0")? - .list() - .iter() - .take(MAX_FRAMES) - .map(|f| f.get("0").and_then(Val::num).and_then(|n| self.frame(n as usize))) - .collect::>()?; + let indices: Vec = + view.get("0")?.list().iter().take(MAX_FRAMES).map(|f| f.get("0").and_then(Val::num).map(|n| n as usize)).collect::>()?; + let frames: Vec = indices.iter().map(|n| self.frame(*n)).collect::>()?; if frames.is_empty() { return None; } + // Threaded frames share one orientation: frames that differ aren't read. + let vertical = indices.first().is_some_and(|n| self.frame_vertical(*n)); + if indices.iter().any(|n| self.frame_vertical(*n) != vertical) { + return None; + } // The first frame's layout, when the file keeps one: where its lines start. - let (mut anchor, mut lines) = ((0.0, 0.0), vec![]); + let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0); if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) { + // Vertical type on a path turns its glyphs across the path: their matrices don't give + // the spacing. + if matches!(frames.first(), Some(FrameKind::OnPath { .. })) && !vertical { + path_spacing = self::path_spacing(f); + } if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums) && let [x, y, ..] = a.as_slice() { @@ -433,13 +547,12 @@ impl Texts { let mut runs = vec![]; for run in s.get("0")?.get("6").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() { let n = run.get("1").and_then(Val::num)? as usize; - let props = run.get("0").and_then(|r| r.get("0")).and_then(|r| r.get("6")); - runs.push((n, self.style(props))); + runs.push((n, self.style(run.get("0").and_then(|r| r.get("0"))))); } if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } - Some(Story { text, anchor, lines, frames, paras, runs }) + Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical }) } /// A paragraph's attributes: its own keys, else the document's. @@ -458,9 +571,19 @@ impl Texts { } } - /// A character style: its own keys, else the document's. - fn style(&self, s: Option<&Val>) -> Style { - let pick = |k: &str| s.and_then(|s| s.get(k)).or_else(|| self.defaults.get(k)); + /// A run's character style (`run`: its attributes, key 6, and the character style it is based + /// on, key 5): its own keys, else those of its character style and the ones that is based on, + /// else the document's. + fn style(&self, run: Option<&Val>) -> Style { + // The run's attributes, then its character style's, then the styles each is based on. + let mut chain = vec![]; + let mut at = run; + for _ in 0..=MAX_SHEETS_BASED_ON { + let Some(style) = at else { break }; + chain.extend(style.get("6")); + at = style.get("5").and_then(Val::num).filter(|i| i.is_finite() && *i >= 0.0).and_then(|i| self.char_sheets.get(i as usize)); + } + let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num); let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); @@ -476,6 +599,8 @@ impl Texts { fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), + caps: num("12").filter(|v| *v == 1.0 || *v == 2.0).map_or(0, |v| v as u8), + tate_chu_yoko: num("35") == Some(3.0), } } } @@ -569,17 +694,56 @@ fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 { ((before + within) / total).clamp(0.0, 1.0) } +/// The spacing (Type on a Path Options › Spacing) that the layout `f` of type on a path implies. +/// The file closes its glyphs up round the outside of a curve and opens them up round the inside, +/// even where its frame keeps no spacing; the text engine spaces glyphs that way by the spacing +/// (`vectorcraft_text`'s `path_steps`): between two glyphs' centres, the path's length `s`, their +/// advances' mean `a` and how far it turns `t` (away from their tops) make `s + t × spacing = a`. +/// Fitted over every pair of the layout's glyphs; none where the path doesn't turn under them. +fn path_spacing(f: &Val) -> f64 { + let mut found = vec![]; + find_nodes(f, "G", &mut found); + let (mut sum, mut turns) = (0.0, 0.0); + for g in found { + // Each glyph's origin, its matrix (along its baseline, then towards its foot) and where it + // ends along the line; the lists end with where the line ends. + let Some(at) = g.get("21") else { continue }; + let list = |k: &str| at.get(k).and_then(Val::nums).unwrap_or_default(); + let (xs, ys, matrices, ends) = (list("1"), list("2"), list("3"), list("4")); + let glyphs: Vec<(Point, f64, kurbo::Vec2, kurbo::Vec2)> = xs + .iter() + .zip(&ys) + .zip(matrices.as_chunks::<6>().0) + .zip(ends.windows(2)) + .map(|(((x, y), [a, b, c, d, ..]), e)| { + let advance = e.get(1).zip(e.first()).map_or(0.0, |(e1, e0)| e1 - e0); + let along = kurbo::Vec2::new(*a, *b); + (Point::new(*x, *y) + along * (advance * 0.5), advance, along, kurbo::Vec2::new(*c, *d)) + }) + .collect(); + for w in glyphs.windows(2) { + let [(p, a0, along, foot), (q, a1, next, _)] = w else { continue }; + let turn = next.dot(*foot).atan2(next.dot(*along)); + // The length of the arc between the centres (the path is taken to be round there). A + // corner (a turn of more than a radian between two glyphs) isn't round: left out. + let half = turn.abs() * 0.5; + let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() }; + if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { + sum += ((a0 + a1) * 0.5 - length) * turn; + turns += turn * turn; + } + } + } + let spacing = sum / turns; + if turns > 1e-6 && spacing.is_finite() { spacing.clamp(-1000.0, 1000.0) } else { 0.0 } +} + impl Story { /// How many bytes of text the story has. pub(super) fn len(&self) -> usize { self.text.len() } - /// The story's characters, with `\r` ending paragraphs and `\x03` ending lines. - pub(super) fn text(&self) -> &str { - &self.text - } - /// The point frame's matrix (the first frame's, when it is one), else the identity. fn matrix(&self) -> [f64; 6] { match self.frames.first() { @@ -604,7 +768,8 @@ impl Story { .iter() .enumerate() .map(|(i, (lx, ly))| { - let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly); + // A vertical layout's lines run down the canvas, and each next one is further left. + let (x, y) = if self.vertical { (self.anchor.0 - ly, self.anchor.1 + lx) } else { (self.anchor.0 + lx, self.anchor.1 + ly) }; (text.get(i).copied().unwrap_or_default().to_string(), to_art((a * x + c * y + e, b * x + d * y + f), template)) }) .collect() @@ -616,7 +781,7 @@ impl Story { let size = self.runs.iter().map(|(_, s)| s.size).fold(0.0, f64::max) * art_scale(to_doc); let bounds = |segments: &[[f64; 8]]| path_of(segments, template, to_doc).bounds(); let r = match self.frames.first()? { - FrameKind::Area(s) => bounds(s)?, + FrameKind::Area(s, _) => bounds(s)?, FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5), FrameKind::Point(m) => { let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64; @@ -632,6 +797,8 @@ impl Story { let along = to_doc * Point::new(m[0], -m[1]) - to_doc * Point::ZERO; let along = if along.hypot() > 1e-9 && along.is_finite() { along / along.hypot() } else { kurbo::Vec2::new(1.0, 0.0) }; let across = kurbo::Vec2::new(-along.y, along.x); + // Vertical type: its lines run down the page, a quarter turn from the baseline. + let (along, across) = if self.vertical { (across, along) } else { (along, across) }; let mut r = Rect::from_points(first, first); for p in points.iter().chain([&first]) { for (t, n) in [(-reach, -1.5), (-reach, 1.5), (reach, -1.5), (reach, 1.5)] { @@ -661,7 +828,7 @@ impl Story { let at = to_doc * Point::new(x, y); (scale, Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]), TextKind::Point) } - FrameKind::Area(s) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }), + FrameKind::Area(s, _) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }), FrameKind::OnPath { segments, start, end } => { let n = segments.len() as f64; // A closed path keeps its end bracket wherever it is (the same place as the start: a @@ -670,10 +837,20 @@ impl Story { (1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end }) } }; + // A `\x03` ends a line inside its paragraph: a forced line break (U+2028), which the + // paragraph's style runs over. let text: String = if threaded { String::new() } else { - self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect() + self.text + .trim_end_matches('\r') + .chars() + .map(|c| match c { + '\r' => '\n', + '\x03' => '\u{2028}', + c => c, + }) + .collect() }; if text.trim().is_empty() && !threaded { return None; @@ -708,6 +885,14 @@ impl Story { if paras.len() > 1 && paras.iter().any(|p| *p != t.para) { t.paras = paras; } + if matches!(kind, TextKind::OnPath { .. }) && !threaded { + t.path_spacing = self.path_spacing * scale; + } + // Each frame of a thread keeps its own options. + if let Some(FrameKind::Area(_, options)) = self.frames.get(frame) { + options.apply(&mut t.area, scale); + } + t.vertical = self.vertical; t.kind = kind; t.xf = xf; Some(Node::new(id, NodeKind::Text(Box::new(t)))) @@ -721,6 +906,44 @@ impl Story { !self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t) } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and + /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its + /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the + /// frame's top, the first baseline here, isn't where the app that wrote the file puts it for + /// every font. A first baseline its frame `frame` keeps (other than Ascent) stays as it is. + /// Whether it was. + pub(super) fn place_first_line(&self, node: &mut Node, frame: usize, template: (f64, f64), to_doc: Affine) -> bool { + // Closer than this (points of the document) and the line is where the file has it. + const SAME: f64 = 0.5; + if matches!(self.frames.get(frame), Some(FrameKind::Area(_, AreaFrame { first_baseline: Some(_), .. }))) { + return false; + } + let NodeKind::Text(t) = &mut node.kind else { return false }; + if !matches!(t.kind, TextKind::Area { .. }) || t.area.vertical_align != vectorcraft_doc::VerticalAlign::Top { + return false; + } + let Some((_, (x, y))) = self.line_starts(template).into_iter().next() else { return false }; + let at = to_doc * Point::new(x, y); + let db = vectorcraft_text::FontDb::global(); + // A frame too short for the line lays nothing out: as the line needs it (see `fit_first_line`). + let mut probe = TextObject::clone(t); + vectorcraft_text::fit_first_line(db, &mut probe); + let laid = vectorcraft_text::layout(db, &probe); + let Some(first) = laid.lines.first().filter(|l| l.region == 0) else { return false }; + // Where the first baseline goes in the layout's line space. Vertical type's line space is a + // quarter turn from the document (a line's height is how far left of the frame's right edge + // its column is), and the file's line is the column's centre line, the middle of the line + // (as `TextLayout::baselines` has it): the baseline is that far from it. + let want = if t.vertical { -at.x - (first.descent - first.ascent) / 2.0 } else { at.y }; + let min = first.first_baseline + (want - first.baseline); + if (want - first.baseline).abs() <= SAME || !min.is_finite() || min <= 0.0 { + return false; + } + t.area.first_baseline = vectorcraft_doc::FirstBaseline::Fixed; + t.area.first_baseline_min = min; + true + } + /// The leading the file's own line positions imply: the distance from one line to the next, less /// the space between paragraphs that is part of it (a paragraph's space after and the next one's /// space before; a layout's lines are where that space has put them, and the text object adds it @@ -824,6 +1047,10 @@ fn char_style(s: &Style, scale: f64, leading: Option) -> CharStyle { fill: s.fill.map_or(Paint::None, Paint::solid), stroke: s.stroke.map_or(Paint::None, Paint::solid), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, + all_caps: s.caps == 2, + // At the default Small Caps size of Document Setup: the file's own isn't read. + small_caps: (s.caps == 1).then(|| vectorcraft_doc::DocSetup::default().small_caps_size), + tate_chu_yoko: s.tate_chu_yoko, ..CharStyle::default() } } @@ -841,8 +1068,14 @@ mod tests { #[test] fn right_to_left_paragraphs_swap_left_and_right_alignment() { - let texts = - Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] }; + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/33 1").unwrap(), + char_sheets: vec![], + frames: vec![], + }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. assert_eq!(justify(b"/0 0"), Justify::Right); @@ -857,9 +1090,15 @@ mod tests { /// dimension's is) lies in a tall box, not a wide one. #[test] fn point_type_lies_along_its_baseline() { - let para = - Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] } - .para(None); + let para = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + frames: vec![], + } + .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap(); let (w, h) = size(&s); @@ -868,6 +1107,66 @@ mod tests { s.frames = vec![FrameKind::Point([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])]; let (w, h) = size(&s); assert!(h > 3.0 * w, "turned: {w} × {h}"); + // Vertical type lies down the page without a turn. + s.frames = vec![FrameKind::Point([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; + s.vertical = true; + let (w, h) = size(&s); + assert!(h > 3.0 * w, "vertical: {w} × {h}"); + } + + /// A vertical layout's lines run down the canvas from the anchor, each next one further left: + /// the second column starts one line pitch left of the first, and a centred column (its start + /// before the anchor) starts higher up. + #[test] + fn vertical_lines_start_down_the_canvas_each_further_left() { + let mut s = story("ab\rc\rde\r", &[0.0, 14.4, 28.8], &Para::default()); + s.vertical = true; + s.lines[2].0 = -12.0; + s.anchor = (CANVAS_CENTRE + 100.0, CANVAS_CENTRE - 50.0); + let starts: Vec<(f64, f64)> = s.line_starts((0.0, 0.0)).into_iter().map(|(_, p)| p).collect(); + let [a, b, c] = starts.as_slice() else { panic!("{starts:?}") }; + assert!((a.0 - 100.0).abs() < 1e-9 && (a.1 - 50.0).abs() < 1e-9, "the first column at the anchor: {a:?}"); + assert!((b.0 - 85.6).abs() < 1e-9 && (b.1 - 50.0).abs() < 1e-9, "the next one to the left: {b:?}"); + assert!((c.0 - 71.2).abs() < 1e-9 && (c.1 - 62.0).abs() < 1e-9, "a centred one higher (the art is y up): {c:?}"); + } + + /// Key 1 of what a frame carries is its orientation: 2 vertical; 0, none or any other value + /// horizontal. + #[test] + fn a_frame_is_vertical_only_at_orientation_two() { + let frame = |carries: &str| read(format!("/0 << /2 << /0 0 {carries} >> >>").as_bytes()).unwrap(); + let cases = ["/1 2", "/1 0", "", "/1 1", "/1 2.5", "/1 -2", "/1 (2)", "/1 1e300", "/1 true", "/1 [ 2 ]"]; + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + frames: cases.iter().map(|c| frame(c)).collect(), + }; + let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); + assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); + } + + /// Key 35 of a character style at 3 is Tate-chu-yoko; its other values (1 and 2, how half-width + /// characters stand in a column) and values that aren't that number aren't. + #[test] + fn baseline_direction_three_is_tate_chu_yoko() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: read(b"/35 1").unwrap(), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![read(b"/0 (Block) /6 << /35 3 >>").unwrap()], + frames: vec![], + }; + let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; + assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); + assert!(tcy(b"/5 0 /6 << >>"), "its character style's"); + assert!(!tcy(b"/5 0 /6 << /35 1 >>"), "the run's own wins"); + for other in [&b"/6 << >>"[..], b"/6 << /35 2 >>", b"/6 << /35 3.5 >>", b"/6 << /35 (3) >>", b"/6 << /35 -3 >>", b"/6 << /35 1e308 >>"] { + assert!(!tcy(other), "{}", String::from_utf8_lossy(other)); + } } /// A right-to-left paragraph is laid out right to left even when it has no right-to-left @@ -875,8 +1174,14 @@ mod tests { /// that doesn't say, goes by its letters. #[test] fn right_to_left_paragraphs_set_their_direction() { - let texts = - Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] }; + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/33 1").unwrap(), + char_sheets: vec![], + frames: vec![], + }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own"); @@ -915,6 +1220,7 @@ mod tests { fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], }; texts.frame(0) @@ -1006,11 +1312,66 @@ mod tests { fill: Some(Color::BLACK), stroke: None, stroke_width: 1.0, + caps: 0, + tate_chu_yoko: false, }, )], + path_spacing: 0.0, + vertical: false, } } + /// A layout of glyphs `advances` wide round a circle of radius `radius` (y down), from its left, + /// outside it (their feet towards its centre) or inside it, the length of the circle between + /// their centres `fraction` of their advances' mean. + fn round_layout(advances: &[f64], radius: f64, outside: bool, fraction: f64) -> Val { + let sign = if outside { 1.0 } else { -1.0 }; + let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]); + let mut angle = std::f64::consts::PI; + for (i, a) in advances.iter().enumerate() { + if i > 0 { + angle += sign * (advances[i - 1] + a) * 0.5 * fraction / radius; + } + // Outside, clockwise on the page (y down); inside, the other way round. + let along = (-angle.sin() * sign, angle.cos() * sign); + let centre = (radius * angle.cos(), radius * angle.sin()); + xs.push(centre.0 - along.0 * a * 0.5); + ys.push(centre.1 - along.1 * a * 0.5); + matrices.extend([along.0, along.1, -along.1, along.0, 0.0, 0.0]); + ends.push(ends[i] + a); + } + let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::>().join(" "); + let doc = format!( + "/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>", + nums(&xs), + nums(&ys), + nums(&matrices), + nums(&ends) + ); + read(doc.as_bytes()).unwrap().get("0").unwrap().clone() + } + + /// The file lays type on a path out closer round the outside of a curve and further apart round + /// the inside: the spacing that does that is read from its layout, as the text engine's model + /// has it (length + turn × spacing = advance). + #[test] + fn the_layout_of_type_on_a_path_gives_its_spacing() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + // Outside a circle of radius 100, 10 above the path: the centres are 100/110 of the advances apart. + let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0)); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + // Inside it, 20 above the path: 100/80 of them. + let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0)); + assert!((spacing - 20.0).abs() < 1e-6, "{spacing}"); + // At their advances: none. + assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0)).abs() < 1e-6); + // Corners (here a turn of about four radians from glyph to glyph) aren't taken for a curve. + assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0)), 0.0); + // A straight line turns nowhere, and a layout without glyphs says nothing. + assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9)), 0.0); + assert_eq!(path_spacing(&read(b"/99 /F").unwrap()), 0.0); + } + /// The leading of the text object a story comes in as. fn leading_of(story: &Story) -> Option { let node = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY)?; @@ -1067,6 +1428,21 @@ mod tests { assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None); } + /// A `\x03` ends a line inside its paragraph: it comes in as a forced line break, and the + /// paragraph's style runs over it to the paragraph's end. + #[test] + fn a_soft_return_is_a_line_break_in_its_paragraph() { + let para = Para { space_before: 4.0, space_after: 2.0, first_indent: 6.0, ..Para::default() }; + let mut s = story("A\x03B\rC\r", &[0.0, 15.6, 31.2], ¶); + s.paras[1].1 = Para { left_indent: 3.0, ..para.clone() }; + let node = s.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap(); + let NodeKind::Text(t) = &node.kind else { panic!("not text") }; + assert_eq!(t.plain_text(), "A\u{2028}B\nC"); + assert_eq!(t.paras.len(), 2, "{:?}", t.paras); + assert_eq!((t.paras[0].first_line_indent, t.paras[0].left_indent, t.paras[0].space_before), (6.0, 0.0, 4.0)); + assert_eq!((t.paras[1].first_line_indent, t.paras[1].left_indent), (6.0, 3.0)); + } + #[test] fn a_frame_too_short_for_its_first_line_is_made_taller() { // A heading in a frame as tall as its size: a line of the font it shows in here is taller. @@ -1092,4 +1468,228 @@ mod tests { let mut t = area(13.0); assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0); } + + #[test] + fn area_type_s_first_line_goes_where_the_file_lays_it_out() { + // GUARDS: area type's first baseline was the font's ascent below the frame's top, where the + // file's own layout (and its page) can have it higher: a heading came in lower than the page's. + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + let frame = vec![ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ]; + // The canvas is the document here: the art's y up, the document's down. + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + let first = |s: &Story| { + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = s.place_first_line(&mut node, 0, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + (placed, l.lines.first().map(|l| l.baseline), t.area.first_baseline) + }; + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = vec![FrameKind::Area(frame, AreaFrame::default())]; + // Where this lays the line out on its own: the file agrees, and nothing changes. + s.lines = vec![(10.0, 20.0)]; + let (_, Some(own), _) = first(&s) else { panic!("a line") }; + s.lines = vec![(10.0, own)]; + assert_eq!(first(&s), (false, Some(own), vectorcraft_doc::FirstBaseline::Ascent)); + // Higher in the file: there. + s.lines = vec![(10.0, own - 3.0)]; + let (placed, at, fb) = first(&s); + assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}"); + assert!(at.is_some_and(|y| (y - (own - 3.0)).abs() < 1e-6), "{at:?} for {}", own - 3.0); + } + + /// A text document's area frame, the rectangle 10, 20 to 210, 120 of the canvas, whose frame + /// data has `options` besides its kind, written by hand. + fn area_frame(options: &str) -> FrameKind { + let line = |a: (f64, f64), b: (f64, f64)| format!("{} {} {} {} {} {} {} {}", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1); + let segments = [ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ]; + let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 1 {options} >> >>", segments.join(" ")); + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + frames: vec![read(doc.as_bytes()).unwrap()], + }; + texts.frame(0).unwrap() + } + + /// The area options of the text object a story of `frames` comes in as at frame `frame`, the + /// art's units `scale` document points each. + fn area_of(frames: Vec, frame: usize, scale: f64) -> vectorcraft_doc::AreaOptions { + let mut s = story("Some area type\r", &[], &Para::default()); + s.frames = frames; + let to_doc = Affine::scale(scale) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + let NodeKind::Text(t) = s.node(NodeId(0), frame, (CANVAS_CENTRE, -CANVAS_CENTRE), to_doc).unwrap().kind else { panic!("area type") }; + t.area + } + + #[test] + fn area_frame_options_are_read() { + // GUARDS: area type came in with one row, one column and the 18 pt gutter whatever its frame kept. + let a = area_of(vec![area_frame("/3 2 /4 3 /7 20 /8 24")], 0, 1.0); + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 3, 24.0, Some(20.0))); + // The same gutter between rows and columns is one gutter. + let a = area_of(vec![area_frame("/3 2 /4 3 /7 20 /8 20")], 0, 1.0); + assert_eq!((a.gutter, a.row_gutter), (20.0, None)); + // Lengths scale with the art's units (a Large Canvas copy measures in tenths of the page). + let a = area_of(vec![area_frame("/7 2 /8 2.4 /10 << /0 3 /1 3 >>")], 0, 10.0); + assert!((a.gutter - 24.0).abs() < 1e-9 && a.row_gutter == Some(20.0), "{a:?}"); + assert!((a.first_baseline_min - 30.0).abs() < 1e-9, "{a:?}"); + } + + #[test] + fn area_frame_first_baseline_kinds_and_minimum_are_read() { + use vectorcraft_doc::FirstBaseline as F; + for (code, kind) in [(0, F::Fixed), (2, F::CapHeight), (3, F::Leading), (4, F::XHeight)] { + let a = area_of(vec![area_frame(&format!("/10 << /0 {code} /1 30 >>"))], 0, 2.0); + assert_eq!((a.first_baseline, a.first_baseline_min), (kind, 60.0), "code {code}"); + } + // A kind without its own first baseline here, or none known, leaves Ascent. + for code in ["1", "5", "6", "7", "-1", "2.5", "(x)"] { + let a = area_of(vec![area_frame(&format!("/10 << /0 {code} /1 30 >>"))], 0, 1.0); + assert_eq!((a.first_baseline, a.first_baseline_min), (F::Ascent, 0.0), "code {code}"); + } + // A minimum that can't be read is none. + let a = area_of(vec![area_frame("/10 << /0 3 /1 -4 >>")], 0, 1.0); + assert_eq!((a.first_baseline, a.first_baseline_min), (F::Leading, 0.0)); + } + + /// A first baseline the frame keeps stays as it is: the first line isn't moved to where the + /// file lays it out. + #[test] + fn a_first_baseline_the_frame_keeps_is_not_moved() { + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + // Where this lays the first line out on its own. + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = vec![area_frame("")]; + let NodeKind::Text(t) = s.node(NodeId(0), 0, template, to_doc).unwrap().kind else { panic!("area type") }; + let own = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.first().map(|l| l.baseline).unwrap(); + let placed_in = |frames: &[&str]| { + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = frames.iter().map(|o| area_frame(o)).collect(); + // Higher in the file. + s.lines = vec![(10.0, own - 3.0)]; + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = s.place_first_line(&mut node, 0, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + (placed, t.area.first_baseline) + }; + let placed = |options: &str| placed_in(&[options]); + assert_eq!(placed("/10 << /0 2 /1 0 >>"), (false, vectorcraft_doc::FirstBaseline::CapHeight)); + // Without one (Ascent), or with one that isn't read, the line goes where the file has it. + assert_eq!(placed(""), (true, vectorcraft_doc::FirstBaseline::Fixed)); + assert_eq!(placed("/10 << /0 5 /1 0 >>"), (true, vectorcraft_doc::FirstBaseline::Fixed)); + // Another frame of the thread keeping one doesn't count for the first. + assert_eq!(placed_in(&["", "/10 << /0 2 /1 0 >>"]), (true, vectorcraft_doc::FirstBaseline::Fixed)); + } + + #[test] + fn area_frame_options_that_cannot_be_read_keep_their_defaults() { + let defaults = vectorcraft_doc::AreaOptions::default(); + let bad = [ + "/3 0 /4 2.5 /7 -1 /8 (x)", + "/3 NaN /4 inf /7 NaN /8 -inf /10 [ 1 2 ]", + "/3 (2) /4 [ 3 ] /7 << /0 1 >> /8 true /10 3", + "/10 << /1 30 >>", + ]; + for options in bad { + assert_eq!(area_of(vec![area_frame(options)], 0, 1.0), defaults, "{options}"); + } + // Too many rows and columns, and too long gutters, are capped. + let a = area_of(vec![area_frame("/3 1e9 /4 101 /7 1e300 /8 5e4")], 0, 1.0); + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (100, 100, MAX_AREA_LENGTH, None)); + // The cap holds after the lengths scale with the art's units. + let a = area_of(vec![area_frame("/7 1e4 /8 2e3 /10 << /0 0 /1 1e4 >>")], 0, 10.0); + assert_eq!((a.gutter, a.row_gutter, a.first_baseline_min), (MAX_AREA_LENGTH, None, MAX_AREA_LENGTH)); + // One that can be read is read beside one that can't. + let a = area_of(vec![area_frame("/3 2 /4 -3 /8 12 /7 NaN")], 0, 1.0); + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 1, 12.0, Some(18.0))); + } + + #[test] + fn area_frame_without_options_comes_in_as_before() { + assert_eq!(area_of(vec![area_frame("")], 0, 1.0), vectorcraft_doc::AreaOptions::default()); + assert_eq!(area_frame(""), FrameKind::Area(area_segments(), AreaFrame::default())); + } + + /// The segments [`area_frame`] writes. + fn area_segments() -> Vec<[f64; 8]> { + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + vec![ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ] + } + + #[test] + fn each_frame_of_a_thread_keeps_its_own_options() { + let frames = vec![area_frame("/3 2 /7 10 /8 10 /10 << /0 0 /1 12 >>"), area_frame("/4 3 /7 6 /8 8")]; + let (first, second) = (area_of(frames.clone(), 0, 1.0), area_of(frames, 1, 1.0)); + assert_eq!((first.rows, first.columns, first.gutter, first.row_gutter), (2, 1, 10.0, None)); + assert_eq!((first.first_baseline, first.first_baseline_min), (vectorcraft_doc::FirstBaseline::Fixed, 12.0)); + assert_eq!((second.rows, second.columns, second.gutter, second.row_gutter), (1, 3, 8.0, Some(6.0))); + assert_eq!(second.first_baseline, vectorcraft_doc::FirstBaseline::Ascent); + } + + /// Vertical area type: the file's first line is its first column's centre line, so far left of + /// the frame's right edge; the column goes there. + #[test] + fn vertical_area_type_s_first_column_goes_where_the_file_lays_it_out() { + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + let frame = vec![ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ]; + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + // Whether the first line was placed, the first column's centre line (its x on the document) + // and the first baseline setting. + let first = |s: &Story, place: bool| { + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = place && s.place_first_line(&mut node, 0, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + assert!(t.vertical); + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + (placed, l.baselines().first().map(|b| b.0.x), t.area.first_baseline) + }; + let mut s = story("§§\r", &[], &Para::default()); + s.frames = vec![FrameKind::Area(frame, AreaFrame::default())]; + s.vertical = true; + s.anchor = (210.0, 20.0); + s.lines = vec![(0.0, 0.0)]; + let (_, Some(own), _) = first(&s, false) else { panic!("a column") }; + assert!(own < 210.0 && own > 190.0, "near the right edge: {own}"); + // The file's column where this lays it out on its own (so far left of the anchor, the + // frame's top right): nothing changes. + s.lines = vec![(0.0, 210.0 - own)]; + assert_eq!(first(&s, true), (false, Some(own), vectorcraft_doc::FirstBaseline::Ascent)); + // Further right in the file: there. + s.lines = vec![(0.0, 210.0 - own - 3.0)]; + let (placed, at, fb) = first(&s, true); + assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}"); + assert!(at.is_some_and(|x| (x - (own + 3.0)).abs() < 1e-6), "{at:?} for {}", own + 3.0); + // Scaled characters (a column of another width) go there too. + s.runs[0].1.v_scale = 2.0; + s.runs[0].1.h_scale = 2.0; + s.lines = vec![(0.0, 0.0)]; + let (_, Some(own), _) = first(&s, false) else { panic!("a column") }; + s.lines = vec![(0.0, 210.0 - own - 3.0)]; + let (placed, at, _) = first(&s, true); + assert!(placed && at.is_some_and(|x| (x - (own + 3.0)).abs() < 1e-6), "{at:?} for {}", own + 3.0); + } } diff --git a/crates/eps/src/import/data/filters.rs b/crates/eps/src/import/data/filters.rs index d049e39a9..e3268b240 100644 --- a/crates/eps/src/import/data/filters.rs +++ b/crates/eps/src/import/data/filters.rs @@ -1,15 +1,19 @@ use super::ops::MAX_DATA; use super::{ - DecodeSettings, Decoder, DecoderContext, DictRef, EncodingMode, ImageBlob, Key, Obj, PsError, Rc, Res, find, hex_decode, jpeg_components, ps, - ps_err, + DecodeSettings, Decoder, DecoderContext, DictRef, EncodingMode, ImageBlob, Key, Obj, PsError, Rc, RefCell, Res, find, hex_decode, + jpeg_components, ps, ps_err, }; /// A file object: the program itself, or data a filter decoded. #[derive(Debug)] pub(crate) enum Stream { Current, - /// Filters over the program's own data, decoded when first read. - Pending(Vec), + /// Filters over the data the program reads, decoded when first read, from the file they were + /// made over: a file being run (`from`), or else the program's own file. + Pending { + chain: Vec, + from: Option>>, + }, Buf { data: Vec, pos: usize, diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index 3b4046de9..c5c588557 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -29,7 +29,10 @@ impl Interp<'_> { pub fn data_op(&mut self, op: Op) -> Res { use Op::*; match op { - CurrentFile => self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Current)), exec: false })?, + CurrentFile => { + let stream = self.running.last().cloned().unwrap_or_else(|| Rc::new(RefCell::new(Stream::Current))); + self.push(Obj::File { stream, exec: false })?; + } Filter => self.filter()?, ReadHexString | ReadString => { let s = self.pop_str()?; @@ -94,7 +97,7 @@ impl Interp<'_> { let n = match &*f.borrow() { Stream::Current => self.lex.rest().len(), Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), - Stream::Pending(_) => 0, + Stream::Pending { .. } => 0, }; self.push(Obj::Int(n as i64))?; } @@ -163,18 +166,7 @@ impl Interp<'_> { self.push(o)?; } Obj::File { stream: f, .. } => { - self.materialize(&f)?; - let (o, immediate) = match &mut *f.borrow_mut() { - Stream::Current => (self.lex.next()?, self.lex.immediate), - Stream::Buf { data, pos, .. } => { - let rest = data.get(*pos..).unwrap_or_default(); - let mut lex = Lexer::new(rest); - let o = lex.next()?; - *pos += rest.len() - lex.data().len(); - (o, lex.immediate) - } - Stream::Pending(_) => (None, false), - }; + let (o, immediate) = self.next_in(&f)?; let Some(o) = o else { return self.push(Obj::Bool(false)) }; let o = self.scanned(o, immediate)?; self.push(o)?; @@ -184,6 +176,23 @@ impl Interp<'_> { self.push(Obj::Bool(true)) } + /// The next object in file `f` (`token`, and a file being run), read with the whitespace + /// after it, and whether it has `//name`s in it. + pub(in super::super) fn next_in(&mut self, f: &Rc>) -> Res<(Option, bool)> { + self.materialize(f)?; + Ok(match &mut *f.borrow_mut() { + Stream::Current => (self.lex.next()?, self.lex.immediate), + Stream::Buf { data, pos, .. } => { + let rest = data.get(*pos..).unwrap_or_default(); + let mut lex = Lexer::new(rest); + let o = lex.next()?; + *pos += rest.len() - lex.data().len(); + (o, lex.immediate) + } + Stream::Pending { .. } => (None, false), + }) + } + /// Up to `n` bytes of file `f`. fn read(&mut self, f: &Rc>, n: usize) -> Res> { self.materialize(f)?; @@ -201,7 +210,7 @@ impl Interp<'_> { *pos = end; data.get(start..end).unwrap_or_default().to_vec() } - Stream::Pending(_) => vec![], + Stream::Pending { .. } => vec![], }) } @@ -220,7 +229,7 @@ impl Interp<'_> { *pos += 1; } } - Stream::Pending(_) => {} + Stream::Pending { .. } => {} } } @@ -275,23 +284,28 @@ impl Interp<'_> { let spec = Filter { name, sub, predictor, fax }; let stream = match self.pop()? { Obj::File { stream: f, .. } => { - if let Stream::Buf { data, pos, .. } = &*f.borrow() { - self.alloc(data.len().saturating_sub(*pos))?; + // A filter over the program's own file or the file being run waits until it is + // first read, and keeps which of them it reads, wherever the program runs by then. + let running = self.running.last().is_some_and(|r| Rc::ptr_eq(r, &f)); + match &*f.borrow() { + Stream::Buf { data, pos, .. } if !running => self.alloc(data.len().saturating_sub(*pos))?, + _ => {} } - let chain = match &*f.borrow() { - Stream::Current => Some(vec![]), - Stream::Pending(c) => Some(c.clone()), + let pending = match &*f.borrow() { + Stream::Current => Some((vec![], None)), + Stream::Buf { .. } if running => Some((vec![], Some(f.clone()))), + Stream::Pending { chain, from } => Some((chain.clone(), from.clone())), Stream::Buf { .. } => None, }; - match chain { - Some(mut c) => { + match pending { + Some((mut chain, from)) => { // The new filter reads a pending one's data: it has none left of its own // (`flushfile` on it skips nothing more). - if !c.is_empty() { + if !chain.is_empty() { *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; } - c.push(spec); - Stream::Pending(c) + chain.push(spec); + Stream::Pending { chain, from } } None => { let mut b = f.borrow_mut(); @@ -325,15 +339,6 @@ impl Interp<'_> { self.push(Obj::File { stream, exec: false }) } - /// The data an executable file runs as a program: what is left of it. `None` for the - /// program's own file, which is being run already. - pub(in super::super) fn program_of(&mut self, f: &Rc>) -> Res>> { - if matches!(&*f.borrow(), Stream::Current) { - return Ok(None); - } - self.read(f, MAX_DATA).map(Some) - } - /// The decoded data left in file `f`, without reading it (a shading's or function's data). pub(in super::super) fn peek_all(&mut self, f: &Rc>) -> Res> { self.materialize(f)?; @@ -343,20 +348,32 @@ impl Interp<'_> { }) } - /// Decode the filters a file waits to run over the program's data, from where it is now. - fn materialize(&mut self, f: &Rc>) -> Res { - let chain = match &*f.borrow() { - Stream::Pending(c) => c.clone(), + /// Decode the filters a file waits to run over the data the program reads, from where it is + /// now in the file they were made over: a file being run, or the program's own file. + pub(in super::super) fn materialize(&mut self, f: &Rc>) -> Res { + let (chain, from) = match &*f.borrow() { + Stream::Pending { chain, from } => (chain.clone(), from.clone()), _ => return Ok(()), }; let mut buf: Option<(Vec, Option)> = None; for spec in &chain { buf = Some(match buf.take() { None => { - let raw = self.lex.data(); - let (data, used, rgb) = decode(spec, raw)?; + let (data, rgb) = match &from { + Some(r) => { + let mut b = r.borrow_mut(); + let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; + let (out, used, rgb) = decode(spec, data.get(*pos..).unwrap_or_default())?; + *pos = pos.saturating_add(used); + (out, rgb) + } + None => { + let (out, used, rgb) = decode(spec, self.lex.data())?; + self.lex.advance(used); + (out, rgb) + } + }; self.charge_decoded(data.len())?; - self.lex.advance(used); (data, rgb) } Some((prev, was)) => { @@ -418,10 +435,11 @@ impl Interp<'_> { Ok(out) } - /// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's - /// fonts, by name): the font dictionary it would complete is defined as it is. + /// `file eexec`: an encrypted font program follows in `file`. It is skipped there, up to the + /// `cleartomark` that ends it (type is drawn in the app's fonts, by name): the font + /// dictionary it would complete is defined as it is. fn eexec(&mut self) -> Res { - self.pop()?; + let src = self.pop()?; if let Some(Obj::Dict(d)) = self.stack.last().cloned() { let name = d.borrow().get(&Key::name("FontName")).cloned(); if let Some(k) = name.and_then(|n| n.key()) { @@ -429,9 +447,24 @@ impl Interp<'_> { self.fonts.borrow_mut().insert(k, Obj::Dict(d)); } } - let rest = self.lex.data(); - let at = find(rest, b"cleartomark").ok_or(PsError::Ps("ioerror", "eexec".into()))?; - self.lex.advance(at); + // `currentfile eexec`: the program's own file or the file being run. A string operand + // skips in the file being read. + let f = match src { + Obj::File { stream, .. } => stream, + _ => self.running.last().cloned().unwrap_or_else(|| Rc::new(RefCell::new(Stream::Current))), + }; + self.materialize(&f)?; + let missing = || PsError::Ps("ioerror", "eexec".into()); + match &mut *f.borrow_mut() { + Stream::Buf { data, pos, .. } => { + let at = find(data.get(*pos..).unwrap_or_default(), b"cleartomark").ok_or_else(missing)?; + *pos += at; + } + _ => { + let at = find(self.lex.data(), b"cleartomark").ok_or_else(missing)?; + self.lex.advance(at); + } + } self.push(Obj::Mark) } diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index bd6f25287..a21d6c9f6 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -31,7 +31,7 @@ const MAX_NODES: usize = 1 << 20; /// are left out. const LIMIT: f64 = 1e6; /// Width of a zero-width line (the thinnest a device draws), points. -const HAIRLINE: f64 = 0.25; +pub(super) const HAIRLINE: f64 = 0.25; /// Most inks a `DeviceN` space has (PostScript's own limit). const MAX_INKS: usize = 32; /// Most dashes `strokepath` makes of a path (more and it outlines the path undashed). diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 5e8d12661..f4c210b9c 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -4,10 +4,12 @@ //! Every operation counts against a budget and procedures nest only so deep, so a looping or //! recursing program ends with [`PsError::Limit`] instead of hanging. +use std::cell::RefCell; use std::rc::Rc; use vectorcraft_geom::Affine; +use super::data::Stream; use super::graphics::{GState, Out}; use super::lex::{IMMEDIATE, Lexer}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; @@ -32,6 +34,9 @@ pub(crate) struct Interp<'a> { /// The list of the arrays and dictionaries made since this started ([`super::obj::track`]). made: u64, pub lex: Lexer<'a>, + /// The files being run (`cvx exec`), innermost last: the program reads from the last one, and + /// it is `currentfile` while it runs. + pub running: Vec>>, pub stack: Vec, pub dicts: Vec, system: DictRef, @@ -105,13 +110,18 @@ impl<'a> Interp<'a> { pub fn new(src: &'a [u8], g: GState, out: Out) -> Self { let made = super::obj::track(); let system = new_dict(); + // The standard encodings are also instances of the Encoding category, the same arrays + // (PLRM 3rd ed., §3.9.2 and `findencoding`, ). + let encodings = new_dict(); { let mut d = system.borrow_mut(); for (op, name) in Op::ALL { d.insert(Key::name(name), Obj::Op(*op)); } for name in ["StandardEncoding", "ISOLatin1Encoding"] { - d.insert(Key::name(name), encoding()); + let e = encoding(); + d.insert(Key::name(name), e.clone()); + encodings.borrow_mut().insert(Key::name(name), e); } // An integer, as interpreters have it (`systemdict /languagelevel get 2 ge`): run, the // name pushes it all the same. @@ -122,6 +132,7 @@ impl<'a> Interp<'a> { CATEGORIES.split_whitespace().map(|c| (Key::name(c), Obj::dict(Dict::from([(Key::name("Category"), Obj::name(c))])))).collect(); let resources = new_dict(); resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories)); + resources.borrow_mut().insert(Key::name("Encoding"), Obj::Dict(encodings)); let fonts = new_dict(); system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone())); for name in ["GlobalFontDirectory", "SharedFontDirectory"] { @@ -145,6 +156,7 @@ impl<'a> Interp<'a> { Self { made, lex: Lexer::new(src), + running: vec![], stack: vec![], dicts: vec![system.clone(), global.clone(), user.clone()], system, @@ -333,6 +345,12 @@ impl<'a> Interp<'a> { self.spend(1) } + /// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search` + /// charges per 64 bytes, so a loop of big copies runs out of the budget. + fn spend_bytes(&mut self, n: usize) -> Res { + self.spend((n / 256) as u64) + } + /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs /// far more than one operation (laying out type) does in a loop has to end too. pub fn spend(&mut self, ops: u64) -> Res { @@ -376,10 +394,7 @@ impl<'a> Interp<'a> { pub fn call(&mut self, v: Obj) -> Res { match v { Obj::Array { items, exec: true } => self.run_proc(&items), - Obj::File { stream, exec: true } => match self.program_of(&stream)? { - Some(program) => self.run_program(&program), - None => Ok(()), - }, + Obj::File { stream, exec: true } => self.run_file(&stream), Obj::ExecStr(s) => self.run_program(&s.to_vec()), // Through the depth guard: `stopped` and the like call back into here. Obj::Op(op) => { @@ -416,7 +431,35 @@ impl<'a> Interp<'a> { r } - /// Run `program` (what is left of an executable file, an executable string), token by token. + /// Run file `f` (`cvx exec`), reading its objects from it as it goes. While it runs it is + /// `currentfile`, the topmost file being run (PLRM 3rd ed., `currentfile`, ), + /// so data the program reads from there (an inline image's samples) comes from `f`, after the + /// object that reads it. The program's own file is being run already. + fn run_file(&mut self, f: &Rc>) -> Res { + if matches!(&*f.borrow(), Stream::Current) { + return Ok(()); + } + // Its data, decoded from the program it was made over, before it becomes the program. + self.materialize(f)?; + self.enter()?; + self.running.push(f.clone()); + let r = loop { + match self.next_in(f) { + Ok((Some(o), immediate)) => { + if let Err(e) = self.scanned(o, immediate).and_then(|o| self.exec_token(o)) { + break Err(e); + } + } + Ok((None, _)) => break Ok(()), + Err(e) => break Err(e), + } + }; + self.running.pop(); + self.depth -= 1; + r + } + + /// Run `program` (an executable string), token by token. fn run_program(&mut self, program: &[u8]) -> Res { self.enter()?; let mut lex = Lexer::new(program); @@ -661,6 +704,7 @@ impl<'a> Interp<'a> { let n = self.pop_count()?; let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("rangecheck", String::new()))?; if n > 0 { + self.spend_bytes(n)?; let k = j.rem_euclid(n as i64) as usize; if let Some(s) = self.stack.get_mut(at..) { s.rotate_right(k); @@ -687,6 +731,7 @@ impl<'a> Interp<'a> { self.pop_to_mark()?; } CountToMark => { + self.spend_bytes(self.stack.len())?; let n = self.stack.iter().rev().position(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; self.push(Obj::Int(n as i64))?; } @@ -860,6 +905,7 @@ impl<'a> Interp<'a> { } Cvn => { let s = self.pop_str()?; + self.spend_bytes(s.len())?; // The name is a copy of the string (a bigger one when bytes are replaced). self.alloc(s.len().saturating_mul(3))?; let n: Rc = Rc::from(String::from_utf8_lossy(&s.borrow()).as_ref()); @@ -966,8 +1012,14 @@ impl<'a> Interp<'a> { let src = self.pop()?; let at = self.pop_count()?; let written = match (self.pop()?, src) { - (Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()), - (Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()), + (Obj::Array { items, .. }, Obj::Array { items: from, .. }) => { + self.spend_bytes(from.len())?; + items.write(at, &from.to_vec()) + } + (Obj::Str(s), Obj::Str(from)) => { + self.spend_bytes(from.len())?; + s.write(at, &from.to_vec()) + } _ => return ps_err("typecheck", ""), }; if !written { @@ -1029,10 +1081,10 @@ impl<'a> Interp<'a> { self.insert(&d, k, Some(v))?; } Known => { - let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; + let k = self.pop()?; let d = self.pop_dict()?; - let known = d.borrow().contains_key(&k); - self.push(Obj::Bool(known))?; + let found = known(&d, &k)?; + self.push(Obj::Bool(found))?; } Where => { let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; @@ -1120,9 +1172,12 @@ impl<'a> Interp<'a> { PdfMark => { self.pop_to_mark()?; } + // True for a simple object, and for a composite one in global VM (PLRM 3rd ed., `gcheck`); + // every composite here is in local VM. Procsets that copy values into global VM leave + // alone those whose `gcheck` is true, such as a halftone's numbers. Gcheck | Scheck => { - self.pop()?; - self.push(Obj::Bool(false))?; + let simple = !self.pop()?.is_composite(); + self.push(Obj::Bool(simple))?; } CurrentShared => self.push(Obj::Bool(false))?, SetShared | SetVmThreshold | VmReclaim | Echo => { @@ -1171,6 +1226,7 @@ impl<'a> Interp<'a> { /// The objects above the topmost mark, which is removed. fn pop_to_mark(&mut self) -> Res> { + self.spend_bytes(self.stack.len())?; let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; let items = self.stack.split_off(at + 1); self.stack.pop(); @@ -1202,6 +1258,7 @@ impl<'a> Interp<'a> { } Obj::Str(s) => { let from = self.pop_str()?.to_vec(); + self.spend_bytes(from.len())?; if !s.write(0, &from) { return ps_err("rangecheck", "copy"); } @@ -1392,6 +1449,17 @@ fn index(k: &Obj) -> Res { } } +/// `known`: a key may be any object but null (PLRM 3rd ed., §3.3.9, ), +/// and one that can't be a key here (an array, a dictionary, a real with a fraction) is in no +/// dictionary, since `put` here refuses it. +fn known(d: &DictRef, k: &Obj) -> Res { + match k.key() { + Some(key) => Ok(d.borrow().contains_key(&key)), + None if matches!(k, Obj::Null) => ps_err("typecheck", "known"), + None => Ok(false), + } +} + /// `eq`: numbers by value, strings and names by text, composites by identity. fn equal(a: &Obj, b: &Obj) -> bool { match (a, b) { diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 42f968faa..128ce4cb8 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -12,10 +12,12 @@ //! colours, gradients, transparency and images of the art. //! //! Text objects are slots in that document: their characters, fonts and places are in the file's -//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot -//! whose text is where the page has it. Type that doesn't show (a hidden layer or object, or off -//! the page) is made from the text document; where that can't be read (area type, type on a path) -//! it's left out, with a warning. +//! text document (see `ate`). When the text document has the type of every text object that shows, +//! all the type is made from it, each text object in its own place, and the page's type is only +//! compared with it. Otherwise the type it has is still made from it, and the page's type that +//! lies elsewhere goes into the text objects it can't make, by where the text document puts their +//! lines or else in painting order. A text object whose type neither has (a frame of a kind it +//! doesn't read, on a hidden layer or object) is left out, with a warning. //! //! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did //! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is @@ -38,7 +40,7 @@ use super::ai::{self, Structure, slot_of}; use super::ate::{Story, Texts}; mod build; -mod compare; +pub(super) mod compare; mod layers; mod plan; mod slots; @@ -46,8 +48,10 @@ mod slots; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; #[cfg(test)] -use compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour}; -pub use layers::{ai_alone, layered_ai}; +use compare::{ + MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, +}; +pub use layers::{ai_alone, ai_alone_on_page, layered_ai}; pub(super) use layers::{layered, program_alone}; pub use slots::is_loss; #[cfg(test)] @@ -86,9 +90,9 @@ mod tests { ring(&mut layers, Some(MADE), red, 6.0, square); ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0)); let rect = page.artboards[0].rect; - let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; let mut view = PageView::new(&frame); - assert_eq!(prune_unseen(&frame, &mut view, &mut layers), 1, "only the ring the page doesn't draw"); + assert_eq!(prune_unseen(&frame, &mut view, &mut layers, false), 1, "only the ring the page doesn't draw"); let widths: Vec = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect(); assert_eq!(widths, [12.0, 6.0]); // Taken by lightness alone, the red and the dark red are alike. @@ -99,6 +103,33 @@ mod tests { assert!(!differ(&px(dark), &px(red)) && differ_in_colour(&px(dark), &px(red))); } + /// The share of a rectangle's pixels in the areas, however many areas there are: none for one + /// that only shares their edge, lies off the drawing or isn't a number. + #[test] + fn the_share_of_a_rectangle_in_the_areas_counts_its_pixels() { + let in_areas = |areas: &[Rect], w: usize, h: usize| { + let marks: Vec = (0..w * h) + .map(|k| { + let (x, y) = ((k % w) as f64 + 0.5, (k / w) as f64 + 0.5); + areas.iter().any(|a| a.contains(Point::new(x, y))) + }) + .collect(); + let shares = Shares::of(&marks, w, h); + move |r: &Rect| shares.share(r) + }; + let share = in_areas(&[Rect::new(10.0, 10.0, 20.0, 20.0), Rect::new(30.0, 0.0, 31.0, 1.0)], 40, 40); + assert_eq!((share(&Rect::new(12.0, 12.0, 22.0, 18.0)), share(&Rect::new(10.0, 10.0, 30.0, 20.0))), (0.8, 0.5)); + assert_eq!((share(&Rect::new(15.0, 15.0, 30.0, 30.0)), share(&Rect::new(30.2, 0.2, 30.8, 0.8))), (25.0 / 225.0, 1.0)); + assert_eq!( + [share(&Rect::new(20.0, 20.0, 30.0, 30.0)), share(&Rect::new(0.0, 0.0, 5.0, 5.0)), share(&Rect::new(50.0, 50.0, 60.0, 60.0))], + [0.0; 3] + ); + assert_eq!(share(&Rect::new(f64::NAN, 0.0, 39.0, 39.0)), 0.0); + let many: Vec = (0..5000).map(|i| Rect::new(f64::from(i % 70), 50.0, f64::from(i % 70) + 1.0, 51.0)).collect(); + let share = in_areas(&many, 80, 80); + assert_eq!((share(&Rect::new(60.0, 50.0, 75.0, 51.0)), share(&Rect::new(70.0, 50.0, 75.0, 51.0))), (10.0 / 15.0, 0.0)); + } + fn piece(text: &str, x: f64, y: f64) -> Arc { Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default()))))) } @@ -161,11 +192,11 @@ mod tests { let a = comparison_render(&mut renderer, &page, rect, 0.4, 1); let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1); assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); - let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; let mut view = PageView::new(&frame); - assert!(compare(&frame, &mut view, &vector).is_ok()); - assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error"); + assert!(compare(&frame, &mut view, &vector, false).is_ok()); + assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0), false).is_err(), "missing artwork remains an error"); let shifted = Rect::new(0.0, 0.0, 500.0, 500.0); - assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error"); + assert!(view.difference(&vector, shifted, false).any > MAX_DIFFERENCE, "wrong framing remains an error"); } } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 7064ea133..f73121f23 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1,8 +1,8 @@ -use super::compare::{MAX_DIFFERENCE, PageView, compare}; -use super::plan::plan; +use super::compare::{MAX_DIFFERENCE, PageView, compare, difference}; +use super::plan::{made_only, make_slots, plan, readable_all}; use super::slots::{ - ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots, - text_objects, + ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, SlotInfo, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, + fill_slots, letters, text_objects, words, }; use super::{Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, Vec2, ai}; @@ -15,6 +15,10 @@ pub(super) enum Page { Artboard, } +/// How art moved from a page to its place in the layers' document transforms: its pattern tiles +/// move with it. +pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true }; + /// The page (`visible`, the file read as its page) and the place of its area in the layers' /// document. pub(super) struct Frame<'a> { @@ -26,6 +30,8 @@ pub(super) struct Frame<'a> { /// The page holds all the art that prints (an EPS's page is its art's box, unless it was /// saved as its artboard): the layers print nothing outside it. pub(super) whole: bool, + /// The PDF page areas and their moves into the editing document. + pub(super) pages: Vec<(Rect, Affine)>, } /// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the @@ -47,6 +53,28 @@ pub(super) fn build( build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit) } +/// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page` +/// (width and height, either way round) points large in units of `unit` points: the document is +/// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in +/// those units and not in points, as [`build_from`] decides with a page. +pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec), String> { + let structure = ai::read(data, false)?; + let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a)); + let art = structure.bbox.map(|[a, b, c, d]| structure.to_doc.transform_rect_bbox(Rect::new(a, b, c, d))); + let (w, h) = page; + let fits = |r: Option, k: f64| { + r.is_some_and(|r| [(w, h), (h, w)].iter().any(|(a, b)| (r.width() - a / k).abs() <= 0.5 && (r.height() - b / k).abs() <= 0.5)) + }; + let in_units = |r: Option| unit.is_finite() && unit > 1.0 && fits(r, unit) && !fits(r, 1.0); + let scale = in_units(board) || (!fits(board, 1.0) && in_units(art)); + let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; + if scale { + // The graphic styles here are all the file's, drawn in its units too. + scale_document(&mut doc, unit, true); + } + Ok((doc, notes)) +} + /// [`build`] from `structure`, the editing copy `data` already read. pub(super) fn build_from( structure: Structure, @@ -57,13 +85,32 @@ pub(super) fn build_from( trust: bool, unit: f64, ) -> Result<(Document, Vec), String> { - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, placed_elsewhere, warnings } = structure; + let Structure { + mut doc, + to_doc, + bbox, + artboard, + template, + hidden_unread, + unread, + unread_elsewhere, + slot_names, + restyled, + placed_elsewhere, + envelopes_drawn, + styles_read, + warnings, + } = structure; // Editing data in the units of the page's user space ([`in_units`]) is compared with the page // scaled down to those units, and the document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); - let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| { + // A page cropped to the art (see below) is in those units as well, when the art's box is and + // the artboard isn't the page in points. + let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d))); + let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit)); + let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| { let mut v = v.clone(); - scale_document(&mut v, 1.0 / unit); + scale_document(&mut v, 1.0 / unit, false); v }); let visible = small.as_ref().or(visible); @@ -81,14 +128,21 @@ pub(super) fn build_from( // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). let fits = |r: &Rect| (r.width() - page_rect.width()).abs() <= 0.5 && (r.height() - page_rect.height()).abs() <= 0.5; let mut rect = to_doc.transform_rect_bbox(art_box); + // A PDF saved with some of its artboards has pages for those only, the first of them not + // always the first artboard: the editing data keeps them all (`first`, the page's). + let first = shown_artboard(&doc, visible, page, page_rect, fits); + rect = doc.artboards.get(first).filter(|_| first > 0).map_or(rect, |a| a.rect); // A `.ai` whose PDF part's pages are cropped to its art (each page the box of all of it, // the editing data's bounding box) rather than to its artboards: the layers are compared - // with the page on that box, and keep their artboards. + // with the page on that box, and keep their artboards. Each page then holds all the art + // again, so every page must be that box. + let mut cropped = false; if matches!(page, Page::Artboard) && !fits(&rect) - && let Some(art) = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d))).filter(fits) + && visible.artboards.iter().all(|a| fits(&a.rect)) + && let Some(art) = art.filter(fits) { - rect = art; + (rect, cropped) = (art, true); } if !fits(&rect) { // Both sizes in points as the fields show them, and the editing data's page to the @@ -120,21 +174,37 @@ pub(super) fn build_from( // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each // goes where the layers have that artboard. let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); - let same = - |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; - let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); - for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { - // The page with most of it, the first for what is on none. - let on = extent(object).and_then(|r| { + let boards = page_boards(&doc.artboards, first, &pages); + let page_moves: Vec<(Rect, Affine)> = pages + .iter() + .enumerate() + .map(|(i, page)| { + let move_to_doc = + boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift); + (*page, move_to_doc) + }) + .collect(); + // The page with most of an object, the first for what is on none. + let page_of = |object: &[Arc]| { + extent(object).and_then(|r| { let inside = |p: &Rect| p.intersect(r).area().max(0.0); pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) - }); + }) + }; + // Pages cropped to the art are copies of one another: the type is the first page's. + if cropped { + for objects in [&mut shown, &mut hidden, &mut lone] { + objects.retain(|o| page_of(o).is_none()); + } + } + for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { + let on = page_of(object); let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), None => shift, }; for n in object.iter_mut() { - Arc::make_mut(n).transform(shift, false); + Arc::make_mut(n).transform(shift, PAGE_MOVE); } } // An EPS saved as its artboard has the art outside it left out of its page. @@ -148,8 +218,9 @@ pub(super) fn build_from( if let Some(a) = doc.artboards.first_mut() { a.rect = rect; } + drop_guides_off_boards(&mut doc); } - frame = Some(Frame { page: visible, page_rect, rect, whole }); + frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves }); } let mut infos = vec![]; collect_slots(&doc.layers, true, &mut infos); @@ -162,13 +233,12 @@ pub(super) fn build_from( if outlined && infos.iter().any(|i| i.shown) { return Err("its type is to come in as outlines, which only its page has".into()); } - if infos.iter().all(|i| !i.shown) && !shown.is_empty() { - return Err("its page has text, and its layers have no text objects to put it in".into()); - } - // The page's type is compared with every text object of the layers: that has its limits. - if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { - return Err("it has too many text objects".into()); - } + // The page's type inside an unread object's box comes in with the page's art that replaces the + // object (see [`replace_unread`]), not in a text object. + let unread_type = take_unread_type(&doc, frame.as_ref(), &unread, &unread_elsewhere, &mut shown); + // The page's type that needs no text object of the layers: the layers' own legacy text, or + // placed files that show it. + let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; let texts = Texts::read(data); // Each story is read once, however many text objects name it. let mut read: std::collections::BTreeMap>> = Default::default(); @@ -184,7 +254,8 @@ pub(super) fn build_from( } let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); let mut notes = warnings; - let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); + let own = (!outlined).then(|| outside_placed_files(&shown, &doc, &placed_elsewhere)); + let (content, reflow) = slot_content(&infos, &stories, template, to_doc, own.as_deref(), [&shown, &hidden], &mut notes)?; // The glyphs left out with the rest of the page's art, and how many text objects take type of // the page right next to them (not counted past `MAX_PAIRS` comparisons). let compared = lone.len().saturating_mul(shown.iter().chain(&hidden).map(Vec::len).sum()); @@ -193,25 +264,44 @@ pub(super) fn build_from( matches!(n.kind, NodeKind::Text(_)) && n.visual_bounds().is_some_and(|b| missing.iter().any(|r| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).is_zero_area())) }; - let short = content.iter().filter(|c| matches!(c, Content::Page(p) | Content::Made(_, _, _, p) if p.iter().any(next_to_missing))).count(); + let short = content.iter().filter(|c| matches!(c, Content::Page(p) if p.iter().any(next_to_missing))).count(); let mut empty = 0; let mut layers = std::mem::take(&mut doc.layers); let mut made = vec![]; fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]); doc.layers = layers; - // A story in several frames is threaded type: its frames in order (the first holds the story). made.sort(); + let threaded_frames: BTreeSet = + made.chunk_by(|a, b| a.0 == b.0).filter(|thread| thread.len() > 1).flatten().map(|(_, _, id)| *id).collect(); + if !unread.is_empty() { + let Some(frame) = frame.as_ref() else { return Err(unread_error(&unread)) }; + let mut copied = BTreeSet::new(); + let kinds = replace_unread(&mut doc, frame, &unread, &unread_elsewhere, &threaded_frames, &mut copied)?; + // With the text objects filled, the page art chosen can differ from the first try's: the + // page's type set aside for it, and no other, must be in it. + let all_copied = |o: &Vec>| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id)); + if !unread_type.iter().all(all_copied) || shown.iter().any(all_copied) { + return Err("the page's type in an unread object's box isn't in its page art".into()); + } + notes.push(format!("{}: they came in as the page's art", kinds.join(", "))); + } + // A story in several frames is threaded type: its frames in order (the first holds the story). for thread in made.chunk_by(|a, b| a.0 == b.0) { if thread.len() > 1 { doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); } } + // Area type flows around the wrap objects above it before it is compared with the page. + resolve_text_wrap(&mut doc)?; if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); } if short > 0 { notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); } + if as_outlines > 0 { + notes.push(format!("{as_outlines} {PAGE_TYPE_AS_OUTLINES}")); + } if !hidden_unread.is_empty() { notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); } @@ -219,20 +309,42 @@ pub(super) fn build_from( Some(frame) => { let mut view = PageView::new(&frame); view.restyled(&restyled, frame.rect); - let unseen = prune_unseen(&frame, &mut view, &mut doc); - if unseen > 0 { - notes.push(format!("{unseen} {UNSEEN_TEXT}")); - } - match compare(&frame, &mut view, &doc) { - Ok(note) => notes.extend(note), + // The placed files' other placement starts from the layers as read: which text the page + // doesn't draw is weighed again with the images there, since an image that covered a + // text object at the first placement may not cover it at the other. + let other = elsewhere(&doc, &placed_elsewhere); + let unseen = prune_unseen(&frame, &mut view, &mut doc, reflow); + let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); + match compare(&frame, &mut view, &doc, reflow) { + // Placed files at the other point their data gives, where that draws closer to the + // page: a small image in the wrong place differs too little to fail. + Ok(note) => { + let closer = other.and_then(|mut other| { + let unseen = prune_unseen(&frame, &mut view, &mut other, reflow); + let closer = view.difference(&other, frame.rect, reflow).any < view.difference(&doc, frame.rect, reflow).any; + closer.then(|| compare(&frame, &mut view, &other, reflow).ok()).flatten().map(|note| (other, unseen, note)) + }); + match closer { + Some((other, unseen, note)) => { + doc = other; + notes.extend(unseen_note(unseen).into_iter().chain(note)); + } + None => notes.extend(unseen_note(unseen).into_iter().chain(note)), + } + } // Placed files at the other point their data gives, where that draws as the page. - Err(why) => match elsewhere(&doc, &placed_elsewhere).map(|other| (compare(&frame, &mut view, &other), other)) { - Some((Ok(note), other)) => { + Err(why) => match other.map(|mut other| { + let unseen = prune_unseen(&frame, &mut view, &mut other, reflow); + (compare(&frame, &mut view, &other, reflow), other, unseen) + }) { + Some((Ok(note), other, unseen)) => { doc = other; - notes.extend(note); + notes.extend(unseen_note(unseen).into_iter().chain(note)); + } + _ => { + let more = drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow)?; + notes.extend(more); } - _ if trust => notes.push(format!("{why}: they come from its editing data all the same")), - _ => return Err(why), }, } } @@ -240,25 +352,653 @@ pub(super) fn build_from( } doc.limit_depth_noting(&mut notes); if small.is_some() { - scale_document(&mut doc, unit); + scale_document(&mut doc, unit, styles_read); + relayout_wrapped(&mut doc); } Ok((doc, notes)) } +/// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]). +const MAX_WRAP_WORK: usize = 2_000_000; + +/// [`relayout_wrapped`], or an error when the wrap shapes would hold more than [`MAX_WRAP_WORK`] +/// anchors. +fn resolve_text_wrap(doc: &mut Document) -> Result<(), String> { + if has_wraps(doc) && vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK { + return Err("it has too many text objects".into()); + } + relayout_wrapped(doc); + Ok(()) +} + +/// Does an object of `doc` have text wrap options, or a text object wrap shapes? +fn has_wraps(doc: &Document) -> bool { + let mut wraps = false; + doc.walk(|n| wraps |= n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty())); + wraps +} + +/// Resolve the wrap shapes of the area type in `doc` that has wrap objects above it (see +/// [`vectorcraft_doc::text::resolve_wrap`]); the type whose shapes changed is laid out again. +/// Nothing is resolved when the shapes would hold more than [`MAX_WRAP_WORK`] anchors (such a +/// file isn't read from its layers: see [`resolve_text_wrap`]). +fn relayout_wrapped(doc: &mut Document) { + if !has_wraps(doc) || vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK { + return; + } + for id in vectorcraft_doc::text::resolve_wrap(None, doc) { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.cached_bounds = None; + t.cached_baselines.clear(); + } + } +} + +/// A visible page node, with its source order and subtree range. +struct PageArt { + node: Arc, + bounds: Rect, + page: usize, + end: usize, + stateful_ancestor: bool, +} + +const ART_MARGIN: f64 = 1.0; + +fn inside(art: Rect, bounds: Rect) -> bool { + art.x0 >= bounds.x0 - ART_MARGIN && art.y0 >= bounds.y0 - ART_MARGIN && art.x1 <= bounds.x1 + ART_MARGIN && art.y1 <= bounds.y1 + ART_MARGIN +} + +/// Pick the page holding the most of a box. The first page wins a tie. +fn page_for(bounds: Rect, pages: &[Rect]) -> usize { + let mut best = (0, 0.0); + for (i, page) in pages.iter().enumerate() { + let area = page.intersect(bounds).area().max(0.0); + if area > best.1 { + best = (i, area); + } + } + best.0 +} + +fn collect_live(node: &Node, unread: &BTreeSet, live: &mut Vec) { + if !node.visible || unread.contains(&node.id) { + return; + } + if let Some(children) = node.children() { + for child in children { + collect_live(child, unread, live); + } + } else if let Some(bounds) = node.visual_bounds() { + live.push(bounds); + } +} + +fn page_node_has_drawing_state(node: &Node) -> bool { + node.clips() + || matches!(node.kind, NodeKind::Path { clipping: true, .. }) + || node.opacity < 1.0 + || node.blend != super::BlendMode::Normal + || node.isolate + || node.knockout != vectorcraft_doc::Knockout::Neutral + || node.extras().mask.is_some() + || !node.appearance.items.is_empty() + || !node.appearance.effects.is_empty() +} + +fn index_page(node: &Arc, pages: &[Rect], moves: &[Affine], art: &mut Vec, stateful_ancestor: bool) { + if !node.visible { + return; + } + let start = art.len(); + let mut indexed = false; + if let Some(bounds) = node.visual_bounds() { + let page = page_for(bounds, pages); + if let Some(shift) = moves.get(page) { + art.push(PageArt { node: Arc::clone(node), bounds: shift.transform_rect_bbox(bounds), page, end: start + 1, stateful_ancestor }); + indexed = true; + } + } + if let Some(children) = node.children() { + for child in children { + index_page(child, pages, moves, art, stateful_ancestor || page_node_has_drawing_state(node)); + } + } + let end = art.len(); + if indexed && let Some(entry) = art.get_mut(start) { + entry.end = end; + } +} + +/// Node IDs belong to the importing document, not to the pattern's drawing. +fn same_pattern_art(existing: &vectorcraft_doc::PatternDef, source: &vectorcraft_doc::PatternDef) -> bool { + fn clear_ids(node: &mut Node) { + node.id = NodeId(0); + if let Some(children) = node.children_mut() { + for child in children { + clear_ids(Arc::make_mut(child)); + } + } + if let Some(mask) = &mut node.extras_mut().mask { + clear_ids(Arc::make_mut(&mut mask.art)); + } + } + + let mut a = existing.clone(); + let mut b = source.clone(); + for pattern in [&mut a, &mut b] { + for node in &mut pattern.art { + clear_ids(Arc::make_mut(node)); + } + } + a == b +} + +/// Bring document resources used by page art across before reassigning its node IDs. +fn copy_page_resources(doc: &mut Document, page: &Document, nodes: &[&Node]) -> Result<(), String> { + fn collect(node: &Node, images: &mut BTreeSet, patterns: &mut BTreeSet, swatches: &mut BTreeSet) { + if let NodeKind::Image(im) = &node.kind { + images.insert(im.key.clone()); + } + for item in &node.appearance.items { + match item.paint() { + vectorcraft_color::Paint::Solid { swatch: Some(name), .. } => { + swatches.insert(name.clone()); + } + vectorcraft_color::Paint::Gradient(gradient) => { + if let Some(name) = &gradient.swatch { + swatches.insert(name.clone()); + } + for stop in &gradient.gradient.stops { + if let Some(name) = &stop.swatch { + swatches.insert(name.clone()); + } + } + } + vectorcraft_color::Paint::Pattern { pattern, .. } => { + patterns.insert(pattern.clone()); + } + _ => {} + } + } + if let Some(mask) = &node.extras().mask { + collect(&mask.art, images, patterns, swatches); + } + for child in node.children().into_iter().flatten() { + collect(child, images, patterns, swatches); + } + } + + let (mut images, mut patterns, mut swatches) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new()); + for node in nodes { + collect(node, &mut images, &mut patterns, &mut swatches); + } + let mut copied = BTreeSet::new(); + while let Some(name) = patterns.pop_first() { + if !copied.insert(name.clone()) { + continue; + } + let Some(source) = page.pattern(&name) else { return Err(format!("page art is missing pattern {name}")) }; + if let Some(existing) = doc.pattern(&name) { + if !same_pattern_art(existing, source) { + return Err(format!("page art conflicts with pattern {name}")); + } + } else { + for node in &source.art { + collect(node, &mut images, &mut patterns, &mut swatches); + } + let mut pattern = source.clone(); + pattern.art = pattern.art.iter().map(|node| Arc::new(reid_page_art(doc, node))).collect(); + doc.patterns.push(pattern); + } + } + for key in images { + let Some(blob) = page.images.get(&key) else { return Err(format!("page art is missing image data for {key}")) }; + if doc.images.get(&key).is_some_and(|existing| existing.bytes != blob.bytes) { + return Err(format!("page art conflicts with image data for {key}")); + } + doc.images.entry(key).or_insert_with(|| blob.clone()); + } + for name in swatches { + let Some(source) = page.swatch(&name) else { return Err(format!("page art is missing swatch {name}")) }; + if let Some(existing) = doc.swatch(&name) { + if existing != source { + return Err(format!("page art conflicts with swatch {name}")); + } + } else { + doc.swatches.push(source.clone()); + } + } + Ok(()) +} + +/// The PDF importer keeps group transparency on a group, or folds it into its only child. +/// If the editing object's state cannot be located in that art, use the PDF page as a whole. +/// Every selected page object must carry the state, on itself or down a chain of only children. +fn page_carries_state(editing: &Node, page: &[&Node]) -> bool { + fn carried(node: &Node, matches: &dyn Fn(&Node) -> bool) -> bool { + matches(node) + || match node.children().map(Vec::as_slice) { + Some([only]) => carried(only, matches), + _ => false, + } + } + let has = |matches: &dyn Fn(&Node) -> bool| !page.is_empty() && page.iter().all(|node| carried(node, matches)); + let opacity = editing.opacity >= 0.999 || has(&|node: &Node| (node.opacity - editing.opacity).abs() < 0.001); + let blend = editing.blend == super::BlendMode::Normal || has(&|node: &Node| node.blend == editing.blend); + let isolate = !editing.isolate || has(&|node: &Node| node.isolate); + let knockout = matches!(editing.knockout, vectorcraft_doc::Knockout::Neutral | vectorcraft_doc::Knockout::Off) + || has(&|node: &Node| node.knockout == editing.knockout); + let appearance = !matches!(editing.kind, NodeKind::Group { .. }) || *editing.appearance == vectorcraft_doc::Appearance::default(); + opacity && blend && isolate && knockout && appearance +} + +/// The error for unread objects with no page art to use: what isn't read, as the reader names it. +fn unread_error(unread: &[(NodeId, Vec)]) -> String { + let kinds: BTreeSet<&str> = unread.iter().flat_map(|(_, kinds)| kinds.iter().map(String::as_str)).collect(); + let (ops, other): (Vec<&str>, Vec<&str>) = kinds.into_iter().partition(|k| k.starts_with('`')); + let mut list: BTreeSet = other.into_iter().map(String::from).collect(); + if !ops.is_empty() { + list.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); + } + let list: Vec<&str> = list.iter().map(String::as_str).collect(); + format!("it has {}, which VectorCraft doesn't read from it yet", ai::join_and(&list)) +} + +/// The page's text objects in `shown` that the page objects replacing `doc`'s unread objects hold +/// (see [`replace_unread`], tried on a copy of `doc` before its text objects are filled), taken out +/// of `shown`. +fn take_unread_type( + doc: &Document, + frame: Option<&Frame<'_>>, + unread: &[(NodeId, Vec)], + elsewhere: &[(NodeId, Rect)], + shown: &mut Vec>>, +) -> Vec>> { + let Some(frame) = frame.filter(|_| !unread.is_empty() && !shown.is_empty()) else { return vec![] }; + let mut copied = BTreeSet::new(); + if replace_unread(&mut doc.clone(), frame, unread, elsewhere, &BTreeSet::new(), &mut copied).is_err() { + return vec![]; + } + let (taken, kept) = std::mem::take(shown).into_iter().partition(|o| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id))); + *shown = kept; + taken +} + +/// Replace each unread top-level object with painted page objects inside its readable bounds. +/// `copied` gets the ids of the page's nodes copied. +fn replace_unread( + doc: &mut Document, + frame: &Frame<'_>, + unread: &[(NodeId, Vec)], + elsewhere: &[(NodeId, Rect)], + threaded_frames: &BTreeSet, + copied: &mut BTreeSet, +) -> Result, String> { + fn holds_threaded_frame(node: &Node, frames: &BTreeSet) -> bool { + frames.contains(&node.id) || node.children().is_some_and(|children| children.iter().any(|child| holds_threaded_frame(child, frames))) + } + fn subtree(node: &Node, ids: &mut BTreeSet) { + ids.insert(node.id); + for child in node.children().into_iter().flatten() { + subtree(child, ids); + } + } + + let unread_ids: BTreeSet = unread.iter().map(|(id, _)| *id).collect(); + let mut live = Vec::new(); + for layer in &doc.layers { + collect_live(layer, &unread_ids, &mut live); + } + live.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + + let source_pages: Vec = frame.pages.iter().map(|(page, _)| *page).collect(); + let moves: Vec = frame.pages.iter().map(|(_, shift)| *shift).collect(); + let destination_pages: Vec = frame.pages.iter().map(|(page, shift)| shift.transform_rect_bbox(*page)).collect(); + let mut art = Vec::new(); + for layer in &frame.page.layers { + if layer.visible + && let Some(children) = layer.children() + { + for child in children { + index_page(child, &source_pages, &moves, &mut art, false); + } + } + } + let mut by_x: Vec = (0..art.len()).collect(); + by_x.sort_by(|a, b| art[*a].bounds.x0.total_cmp(&art[*b].bounds.x0)); + let mut claimed = std::collections::BTreeMap::::new(); + let mut counts = std::collections::BTreeMap::::new(); + for (id, kinds) in unread { + let Some((bounds, name, visible, locked, editing)) = + doc.node(*id).and_then(|n| n.visual_bounds().map(|b| (b, n.name.clone(), n.visible, n.locked, n.clone()))) + else { + return Err("an unread object has no readable bounds".into()); + }; + if !bounds.area().is_finite() || bounds.area() <= 0.0 { + return Err("an unread object has no readable bounds".into()); + } + let mut selected = Vec::new(); + let mut page = 0; + let mut first_rejection = None; + 'candidate: for candidate in std::iter::once(bounds).chain(elsewhere.iter().filter(|(other, _)| *other == *id).map(|(_, b)| *b)) { + let live_start = live.partition_point(|b| b.x0 < candidate.x0 - ART_MARGIN); + let live_end = live.partition_point(|b| b.x0 <= candidate.x1 + ART_MARGIN); + if live[live_start..live_end].iter().any(|art| inside(*art, candidate)) { + first_rejection.get_or_insert("an unread object's bounds also contain live art"); + continue; + } + // Each page has its own copy of art across artboards: one page's art can't stand in for it. + if destination_pages.iter().filter(|page| page.intersect(candidate).area() > 0.0).count() > 1 { + first_rejection.get_or_insert("an unread object lies on more than one artboard"); + continue; + } + let candidate_page = page_for(candidate, &destination_pages); + // An artboard that has no page (a PDF part saved with some of the artboards) has no art + // on the page for the object's part on it. + if let Some(page) = destination_pages.get(candidate_page) + && doc.artboards.iter().map(|a| a.rect.intersect(candidate)).any(|part| part.area() > page.intersect(part).area().max(0.0)) + { + first_rejection.get_or_insert("an unread object lies on an artboard that has no page"); + continue; + } + let start = by_x.partition_point(|i| art[*i].bounds.x0 < candidate.x0 - ART_MARGIN); + let end = by_x.partition_point(|i| art[*i].bounds.x0 <= candidate.x1 + ART_MARGIN); + let mut candidates: Vec = + by_x[start..end].iter().copied().filter(|i| art[*i].page == candidate_page && inside(art[*i].bounds, candidate)).collect(); + candidates.sort_unstable(); + let mut covered_until = 0; + let mut candidate_selected = Vec::new(); + for i in candidates { + if i < covered_until || matches!(art[i].node.kind, NodeKind::Path { clipping: true, .. }) { + continue; + } + if art[i].stateful_ancestor { + first_rejection.get_or_insert("an unread object's page art depends on a clipping or appearance ancestor"); + continue 'candidate; + } + covered_until = art[i].end; + candidate_selected.push(i); + } + if !candidate_selected.is_empty() + && !page_carries_state(&editing, &candidate_selected.iter().map(|&i| art[i].node.as_ref()).collect::>()) + { + first_rejection.get_or_insert("an unread object's appearance is not identifiable in the page art"); + continue; + } + if candidate_selected.iter().any(|&i| claimed.range(..art[i].end).next_back().is_some_and(|(_, end)| *end > i)) { + first_rejection.get_or_insert("one page object matches more than one unread object"); + continue; + } + if !candidate_selected.is_empty() { + selected = candidate_selected; + page = candidate_page; + break; + } + } + if selected.is_empty() { + return Err(first_rejection.unwrap_or("an unread object's page art wasn't found in its bounds").into()); + } + if holds_threaded_frame(&editing, threaded_frames) { + return Err("an unread object holds a frame of threaded type".into()); + } + copy_page_resources(doc, frame.page, &selected.iter().map(|&i| art[i].node.as_ref()).collect::>())?; + let mut found = Vec::new(); + for &i in &selected { + let mut copy = Node::clone(&art[i].node); + if let Some(shift) = moves.get(page) { + copy.transform(*shift, PAGE_MOVE); + } + found.push(Arc::new(reid_page_art(doc, ©))); + claimed.insert(i, art[i].end); + subtree(&art[i].node, copied); + } + let mut replacement = Node::group(*id, found); + replacement.name = name; + replacement.visible = visible; + replacement.locked = locked; + let Some(target) = doc.node_mut(*id) else { return Err("an unread object is missing from its layer".into()) }; + *target = replacement; + for kind in kinds { + *counts.entry(kind.clone()).or_default() += 1; + } + } + Ok(counts.into_iter().map(|(kind, count)| format!("{kind} ({count})")).collect()) +} + +/// Copy page drawing IDs, including mask art outside the ordinary child tree. +fn reid_page_art(doc: &mut Document, source: &Node) -> Node { + let mut copy = doc.reid(source); + if let Some(mask) = &mut copy.extras_mut().mask { + mask.art = Arc::new(reid_page_art(doc, &mask.art)); + } + if let Some(children) = copy.children_mut() { + for child in children { + let node = reid_page_art_masks(doc, child); + *child = Arc::new(node); + } + } + copy +} + +fn reid_page_art_masks(doc: &mut Document, source: &Node) -> Node { + let mut copy = source.clone(); + if let Some(mask) = &mut copy.extras_mut().mask { + mask.art = Arc::new(reid_page_art(doc, &mask.art)); + } + if let Some(children) = copy.children_mut() { + for child in children { + *child = Arc::new(reid_page_art_masks(doc, child)); + } + } + copy +} + +/// What goes into each of `infos`' slots, and whether the type is all made from the file's text +/// document. When the text document makes the type of every text object that shows (and the type +/// isn't asked for as outlines: `own` has the page's text objects that show outside placed files), +/// each slot takes its own made type and the page's type is only compared with it: type laid out +/// again, in another font where the file's isn't installed, may differ from the page's. Otherwise +/// see [`plan`], with `pages` (the page's text objects that show and that don't). +fn slot_content( + infos: &[SlotInfo], + stories: &[Option>], + template: Option<(f64, f64)>, + to_doc: Affine, + own: Option<&[Vec>]>, + pages: [&[Vec>]; 2], + notes: &mut Vec, +) -> Result<(Vec, bool), String> { + let made = make_slots(infos, stories, template, to_doc, notes); + if let Some(own) = own.filter(|_| readable_all(infos, &made)) { + return Ok((made_only(infos, made, [own, pages[1]], notes), true)); + } + // The page's type is matched with every text object whose type isn't made, and weighed against + // where every made one draws: that has its limits. + let open = made.iter().filter(|m| m.is_none()).count(); + if open > MAX_SLOTS || infos.len().saturating_mul(pages[0].len()) > MAX_PAIRS { + return Err("it has too many text objects".into()); + } + Ok((plan(infos, stories, made, template, to_doc, pages, notes), false)) +} + +/// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the +/// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took +/// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that; +/// else, with `trust`, the notes for `doc` as it is. An error, `why`, otherwise. The envelope note +/// is added unless `notes` has it. +#[allow(clippy::too_many_arguments)] +fn drawn_or_trusted( + frame: &Frame<'_>, + view: &mut PageView, + doc: &mut Document, + drawn: &[(NodeId, Node)], + notes: &[String], + unseen: usize, + trust: bool, + why: String, + reflow: bool, +) -> Result, String> { + let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); + let mut put_back = doc.clone(); + let mut any = false; + for (id, art) in drawn { + if let Some(n) = put_back.node_mut(*id) { + *n = art.clone(); + any = true; + } + } + if any { + let more = prune_unseen(frame, view, &mut put_back, reflow); + if let Ok(note) = compare(frame, view, &put_back, reflow) { + *doc = put_back; + let envelope = (!notes.iter().any(|n| n == ai::ENVELOPE_DRAWN)).then(|| ai::ENVELOPE_DRAWN.to_string()); + return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect()); + } + } + if trust { + return Ok(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]).collect()); + } + Err(why) +} + /// `doc` with the placed files `placed` lists at their other placement (see /// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when -/// none is listed. +/// no listed image survives replacement. fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option { if placed.is_empty() { return None; } let mut doc = doc.clone(); + let mut moved = false; for (id, shift) in placed { if let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) { im.xf *= *shift; + moved = true; + } + } + // Area type wrapped around a moved image flows around it where it now is. + if moved { + relayout_wrapped(&mut doc); + } + moved.then_some(doc) +} + +/// The page's type that needs no text object of the layers, taken out of `shown` and `hidden` (the +/// page's text objects that show and that don't): how many of them the layers have as outlines. +/// Type in the legacy format (`To` … `TO`, as files saved for Illustrator 10 and earlier keep it) +/// is read as text objects of the layers' own, with no slot (`no_slots`) for the page's type; the +/// page's type that their words don't hold is outlines on the layers, and the comparison with the +/// page says whether they draw it. Without such text and with no slot that shows (`none_shown`), +/// the page's type must all lie inside placed files, which show it. +fn page_type_elsewhere( + no_slots: bool, + none_shown: bool, + shown: &mut Vec>>, + hidden: &mut Vec>>, + doc: &Document, + placed: &[(NodeId, Affine)], +) -> Result { + let mut own = vec![]; + own_text(&doc.layers, &mut own); + let mut as_outlines = 0; + if no_slots && !own.is_empty() { + as_outlines = shown.iter().filter(|o| !in_own_text(o, &own)).count(); + shown.clear(); + hidden.clear(); + } + if none_shown && !shown.is_empty() && own.is_empty() { + if !in_placed_files(shown, doc, placed) { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + // The page's type is all inside placed files: it is theirs, which their previews show, and + // needs no text object. + shown.clear(); + } + Ok(as_outlines) +} + +/// Is the page's text object `object` the layers' own text (`own`, from [`own_text`]): one with its +/// words next to it? The rest of the page's type is what the editing data keeps as outlines (what +/// the legacy format can't hold), and the comparison with the page says whether the layers draw it. +fn in_own_text(object: &[Arc], own: &[(Rect, String)]) -> bool { + let Some(r) = extent(object) else { return false }; + let near: String = + own.iter().filter(|(b, _)| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(r).is_zero_area()).map(|(_, l)| l.as_str()).collect(); + let page_words: Vec = object + .iter() + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_lowercase())) } else { None }) + .flatten() + .collect(); + !near.is_empty() && page_words.iter().all(|w| near.contains(w.as_str())) +} + +/// The text objects of their own (not slots for the page's type) that show in `nodes`: their +/// bounds and their letters, in lower case. +fn own_text(nodes: &[Arc], out: &mut Vec<(Rect, String)>) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Text(t) => out.extend(n.visual_bounds().map(|b| (b, letters(&t.plain_text().to_lowercase())))), + NodeKind::Compound { .. } => {} + _ => { + if let Some(c) = n.children() { + own_text(c, out); + } + } + } + } +} + +/// Has `r` the size of `page`'s first artboard (in points)? +fn same_size(r: Option, page: &Document) -> bool { + let (Some(r), Some(p)) = (r, page.artboards.first().map(|a| a.rect)) else { return false }; + (r.width() - p.width()).abs() <= 0.5 && (r.height() - p.height()).abs() <= 0.5 +} + +/// Does all of `objects` (the page's text objects) lie inside placed files that show in `doc`? The +/// page draws a placed file, type and all, where the layers show its preview (a linked image, see +/// `ai::Reader::end_place`), at its first placement or at the other one that `placed` lists (see +/// [`elsewhere`]). +fn in_placed_files(objects: &[Vec>], doc: &Document, placed: &[(NodeId, Affine)]) -> bool { + let inside = |d: &Document| { + let mut files = vec![]; + placed_files(&d.layers, &mut files); + !files.is_empty() + && objects + .iter() + .all(|o| extent(o).is_some_and(|r| files.iter().any(|f: &Rect| f.inflate(OUTLINE_REACH, OUTLINE_REACH).contains_rect(r)))) + }; + inside(doc) || elsewhere(doc, placed).is_some_and(|other| inside(&other)) +} + +/// The page's text objects in `objects` that don't lie inside a placed file that shows in `doc`, at +/// either placement (see [`in_placed_files`]): the placed file's preview has the others. +fn outside_placed_files(objects: &[Vec>], doc: &Document, placed: &[(NodeId, Affine)]) -> Vec>> { + let mut files = vec![]; + placed_files(&doc.layers, &mut files); + if let Some(other) = elsewhere(doc, placed) { + placed_files(&other.layers, &mut files); + } + if files.is_empty() { + return objects.to_vec(); + } + let inside = |o: &[Arc]| extent(o).is_some_and(|r| files.iter().any(|f| f.inflate(OUTLINE_REACH, OUTLINE_REACH).contains_rect(r))); + objects.iter().filter(|o| !inside(o)).cloned().collect() +} + +/// Where the placed files that show in `nodes` are: the linked images the editing data's reader +/// makes of them. +fn placed_files(nodes: &[Arc], out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Image(im) if im.link.is_some() => out.extend(n.visual_bounds()), + _ => { + if let Some(c) = n.children() { + placed_files(c, out); + } + } } } - Some(doc) } /// Does the editing data's first artboard `board` (in the document read from it: art space through @@ -271,17 +1011,85 @@ fn in_units(board: Option, page: &Document, unit: f64) -> bool { unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) } -/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, -/// effects and pattern tiles. -fn scale_document(doc: &mut Document, k: f64) { +/// The most artboards [`shown_artboard`] weighs, a render each. +const MAX_BOARDS_WEIGHED: usize = 16; + +/// Which of the artboards of `doc` (the layers' document) the page `page_rect` of `visible` shows, +/// when `visible` has fewer pages than `doc` has artboards (a PDF saved with a range of them): of +/// those of the page's size (`fits`), the one whose art draws most like the page, the first of +/// them on a tie. Else (and for an EPS, whose page is its art) the first artboard (0). +fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rect, fits: impl Fn(&Rect) -> bool) -> usize { + if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() { + return 0; + } + let sized: Vec = doc.artboards.iter().enumerate().filter(|(_, a)| fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect(); + if let [only] = sized.as_slice() { + return *only; + } + let mut best: Option<(usize, f64)> = None; + for (i, a) in sized.iter().filter_map(|&i| Some((i, doc.artboards.get(i)?))) { + let d = difference(visible, page_rect, doc, a.rect).any; + if best.is_none_or(|(_, b)| d < b) { + best = Some((i, d)); + } + } + best.map_or(0, |(i, _)| i) +} + +/// The artboards of the layers' document (`boards`) that the pages show, one a page from the +/// first page's (`first`), when they are the pages' sizes: every artboard, or those of a PDF saved +/// with some of them (`first` after the first artboard). +fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages: &[Rect]) -> Option<&'a [vectorcraft_doc::Artboard]> { + let same = |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; + let shown = boards.get(first..first.saturating_add(pages.len()))?; + ((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown) +} + +/// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. +fn drop_guides_off_boards(doc: &mut Document) { + let kept: Vec = doc.artboards.iter().map(|a| a.id).collect(); + doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id))); +} + +/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their +/// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic +/// styles. +fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; for layer in &mut doc.layers { - Arc::make_mut(layer).transform(a, scaling); + let layer = Arc::make_mut(layer); + layer.transform(a, scaling); + scale_wrap_offsets(layer, k); } for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); } + for guide in &mut doc.guides { + guide.pos *= k; + } + if styles { + for g in doc.graphic_styles.iter_mut() { + g.appearance.scale_strokes(k); + g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); + } + } +} + +/// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points +/// they may be). +fn scale_wrap_offsets(n: &mut Node, k: f64) { + if let Some(w) = n.extras_if_any().and_then(|e| e.wrap.as_mut()) { + let v = w.offset * k; + if !v.is_nan() { + w.offset = v.clamp(-1000.0, 1000.0); + } + } + if let Some(children) = n.children_mut() { + for c in children { + scale_wrap_offsets(Arc::make_mut(c), k); + } + } } /// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's @@ -298,6 +1106,9 @@ const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't /// left out. const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; +/// The page's text objects that the layers have as outlines, as the editing data keeps them. +const PAGE_TYPE_AS_OUTLINES: &str = "text objects of the page are outlines on the layers, as the file's editing data keeps them"; + /// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. const UNSEEN_MARGIN: f64 = 5e-5; @@ -338,15 +1149,16 @@ fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { /// The text objects made from the file's text document that show and print on the page's area, /// where the page has no type to match them, and that the layers look no worse without, are not /// drawn by the app that wrote the file (which paints every shown text object; it keeps the type -/// of what was turned to outlines): they are taken out. How many. -pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize { +/// of what was turned to outlines): they are taken out. How many. `reflow`: the type is all laid +/// out again from the text document (see [`compare`]). +pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document, reflow: bool) -> usize { let mut found = vec![]; unmark(&mut doc.layers, true, true, &mut found); let rect = frame.rect; if found.is_empty() || found.len() > MAX_WEIGHED { return 0; } - let with = view.difference(doc, rect).any; + let with = view.difference(doc, rect, reflow).weighed(reflow); // Taking a text object out changes only what is drawn in its box. When the art would still // differ from the page by more than `compare` allows with every candidate gone, the layers // aren't used whatever is pruned: don't draw them again for each one (#758). @@ -360,6 +1172,8 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc let mut with = view.colour_difference(doc, rect); let mut removed = 0; for (id, bounds) in found { + // A removal before it can have let its area type flow differently. + let bounds = if removed > 0 { doc.node(id).and_then(|n| n.visual_bounds()) } else { bounds }; if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { continue; } @@ -367,12 +1181,425 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc if !take_out(&mut without.layers, id, true) { continue; } + // Area type that wrapped around the text object flows without it. + relayout_wrapped(&mut without); let d = view.colour_difference(&without, rect); if d - with <= UNSEEN_MARGIN { take_out(&mut doc.layers, id, false); + relayout_wrapped(doc); with = d; removed += 1; } } removed } + +#[cfg(test)] +mod candidate_tests { + use super::*; + + use vectorcraft_doc::{ImageBlob, ImageObject}; + + /// Layers read in the units of a page scaled up to points take their wrap offsets with their art, + /// within ±1000 points. + #[test] + fn scaling_the_document_scales_its_wrap_offsets() { + let mut doc = Document::new(100.0, 100.0); + let mut ids = vec![]; + for (x, offset) in [(0.0, 6.0), (20.0, 200.0), (40.0, -150.0)] { + let id = doc.alloc_id(); + let mut n = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Default::default()); + n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset, invert: true }); + doc.insert(Some(doc.layers[0].id), 0, n).unwrap(); + ids.push(id); + } + scale_document(&mut doc, 10.0, false); + let offsets: Vec<_> = ids.iter().map(|id| doc.node(*id).and_then(|n| n.extras().wrap)).collect(); + let wrap = |offset| Some(vectorcraft_doc::TextWrap { offset, invert: true }); + assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]); + } + + /// Area type in `doc`'s first layer at `index`, in the frame `r`. + fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId { + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default()); + t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(r) }; + let id = doc.alloc_id(); + doc.insert(Some(doc.layers[0].id), index, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap() + } + + /// A wrap object on top of `doc`'s first layer: a closed path of `anchors` anchors in `r`. + fn insert_wrap(doc: &mut Document, r: Rect, anchors: usize) -> NodeId { + let mut bp = kurbo::BezPath::new(); + bp.move_to((r.x0, r.y0)); + for i in 1..anchors { + let t = i as f64 / anchors as f64; + bp.line_to((r.x0 + r.width() * t, if i % 2 == 0 { r.y0 } else { r.y1 })); + } + bp.close_path(); + let id = doc.alloc_id(); + let mut n = Node::path(id, PathData::from_bezpath(&bp), Default::default()); + n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default()); + let top = doc.layers[0].children().map_or(0, Vec::len); + doc.insert(Some(doc.layers[0].id), top, n).unwrap() + } + + fn wrap_shapes(doc: &Document, id: NodeId) -> usize { + match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.wrap.len(), + _ => panic!("area type"), + } + } + + /// A file whose wrap shapes would copy more than `MAX_WRAP_WORK` anchors isn't read from its + /// layers; one below that is. + #[test] + fn wrap_shapes_of_too_many_text_objects_are_refused() { + for (texts, ok) in [(150, true), (250, false)] { + let mut doc = Document::new(100.0, 100.0); + let ids: Vec = (0..texts).map(|_| insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0))).collect(); + insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 10_000); + assert!(vectorcraft_doc::text::wrap_work(&doc) >= texts * 10_000); + assert_eq!(resolve_text_wrap(&mut doc).is_ok(), ok, "{texts} text objects"); + // Nor is anything copied on the way, as the placed files' other placement does. + relayout_wrapped(&mut doc); + assert!(ids.iter().all(|id| wrap_shapes(&doc, *id) == usize::from(ok)), "{texts} text objects"); + } + } + + /// Area type that wrapped around an object taken out of the layers flows without it. + #[test] + fn wrap_shapes_go_with_their_wrap_object() { + let mut doc = Document::new(100.0, 100.0); + let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0)); + let wrap = insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 4); + resolve_text_wrap(&mut doc).unwrap(); + assert_eq!(wrap_shapes(&doc, text), 1); + doc.remove(wrap).unwrap(); + relayout_wrapped(&mut doc); + assert_eq!(wrap_shapes(&doc, text), 0); + } + + /// Area type wrapped around a placed image flows around it at the image's other placement. + #[test] + fn wrap_shapes_follow_an_image_to_its_other_placement() { + let mut doc = Document::new(100.0, 100.0); + let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0)); + let image = doc.alloc_id(); + let mut n = Node::new( + image, + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: Affine::translate(Vec2::new(10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default()); + doc.insert(Some(doc.layers[0].id), 1, n).unwrap(); + resolve_text_wrap(&mut doc).unwrap(); + let x0 = |d: &Document| match d.node(text).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.wrap.first().and_then(|w| w.path.bounds()).map(|b| b.x0), + _ => None, + }; + assert_eq!(x0(&doc), Some(10.0)); + let moved = elsewhere(&doc, &[(image, Affine::translate(Vec2::new(50.0, 0.0)))]).unwrap(); + assert_eq!(x0(&moved), Some(60.0)); + } + + /// A page image with a different proxy keeps its editing layer and original blob. + #[test] + fn page_image_with_different_proxy_keeps_editing_layer() { + let mut doc = Document::new(100.0, 100.0); + let placeholder_id = doc.alloc_id(); + let placeholder = Node::path(placeholder_id, vectorcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 54.0, 54.0)), Default::default()); + doc.insert(Some(doc.layers[0].id), 0, placeholder).unwrap(); + + let mut page = Document::new(100.0, 100.0); + let mut editing_blob = ImageBlob::new("image/png", vec![1, 2, 3]); + editing_blob.proxy = Some(Arc::new(vec![4, 5, 6])); + let page_blob = ImageBlob::new("image/png", vec![1, 2, 3]); + let key = editing_blob.content_key(); + assert_eq!(key, page_blob.content_key()); + doc.images.insert(key.clone(), editing_blob.clone()); + page.images.insert(key.clone(), page_blob); + let image = Node::new( + page.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key: key.clone(), + width: 4, + height: 4, + xf: Affine::translate(Vec2::new(50.0, 50.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + page.insert(Some(page.layers[0].id), 0, image).unwrap(); + + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let kinds = replace_unread( + &mut doc, + &frame, + &[(placeholder_id, vec!["placed files".into()])], + &[(placeholder_id, Rect::new(50.0, 50.0, 54.0, 54.0))], + &BTreeSet::new(), + &mut BTreeSet::new(), + ) + .unwrap(); + assert_eq!(kinds, ["placed files (1)"]); + let replacement = doc.node(placeholder_id).unwrap(); + let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") }; + assert!(matches!(image.kind, NodeKind::Image(_))); + assert_eq!(doc.images.get(&key), Some(&editing_blob)); + } + + /// An unread object across two artboards isn't replaced by the art of one page only. + #[test] + fn unread_object_across_two_pages_is_not_replaced() { + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + doc.insert( + Some(doc.layers[0].id), + 0, + Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()), + ) + .unwrap(); + let mut page = Document::new(200.0, 100.0); + for bounds in [Rect::new(91.0, 11.0, 99.0, 19.0), Rect::new(101.0, 11.0, 109.0, 19.0)] { + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let (first, second) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)); + let frame = + Frame { page: &page, page_rect: first, rect: first, whole: false, pages: vec![(first, Affine::IDENTITY), (second, Affine::IDENTITY)] }; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object lies on more than one artboard"); + } + + /// An unread object reaching an artboard that has no page isn't replaced by the page's part of it. + #[test] + fn unread_object_on_an_artboard_without_a_page_is_not_replaced() { + let mut doc = Document::new(100.0, 100.0); + doc.artboards.push(vectorcraft_doc::Artboard { id: 2, rect: Rect::new(100.0, 0.0, 200.0, 100.0), ..doc.artboards[0].clone() }); + let id = doc.alloc_id(); + doc.insert( + Some(doc.layers[0].id), + 0, + Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()), + ) + .unwrap(); + let mut page = Document::new(100.0, 100.0); + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(91.0, 11.0, 99.0, 19.0)), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + page.insert(Some(page.layers[0].id), 0, art).unwrap(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page"); + + // Half a point onto it is still on it. + let narrow = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 100.5, 20.0)), Default::default()); + *doc.node_mut(id).unwrap() = narrow; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page"); + } + + /// A nested placeholder's corner cannot replace its readable sibling. + #[test] + fn nested_placeholder_corner_cannot_replace_readable_sibling() { + let mut doc = Document::new(100.0, 100.0); + let group_id = doc.alloc_id(); + let placeholder = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()); + let placeholder_id = placeholder.id; + let sibling = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(70.0, 70.0, 74.0, 74.0)), Default::default()); + let sibling_id = sibling.id; + let group = Node::group(group_id, vec![Arc::new(placeholder), Arc::new(sibling)]); + doc.insert(Some(doc.layers[0].id), 0, group).unwrap(); + + let mut page = Document::new(100.0, 100.0); + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(81.0, 81.0, 89.0, 89.0)), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + page.insert(Some(page.layers[0].id), 0, art).unwrap(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let result = replace_unread( + &mut doc, + &frame, + &[(group_id, vec!["placed files".into()])], + &[(placeholder_id, Rect::new(80.0, 80.0, 90.0, 90.0))], + &BTreeSet::new(), + &mut BTreeSet::new(), + ); + assert_eq!(result.unwrap_err(), "an unread object's page art wasn't found in its bounds"); + let group = doc.node(group_id).unwrap(); + assert!(group.children().unwrap().iter().any(|child| child.id == sibling_id)); + } + + /// An alternative box may carry opacity missing from the first box. + #[test] + fn alternative_box_can_carry_opacity_when_first_box_cannot() { + let mut doc = Document::new(100.0, 100.0); + let id = doc.alloc_id(); + let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()); + editing.opacity = 0.5; + doc.insert(Some(doc.layers[0].id), 0, editing).unwrap(); + + let mut page = Document::new(100.0, 100.0); + for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 1.0), (Rect::new(51.0, 51.0, 59.0, 59.0), 0.5)] { + let mut art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + art.opacity = opacity; + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let kinds = replace_unread( + &mut doc, + &frame, + &[(id, vec!["placed files".into()])], + &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], + &BTreeSet::new(), + &mut BTreeSet::new(), + ) + .unwrap(); + assert_eq!(kinds, ["placed files (1)"]); + let replacement = doc.node(id).unwrap(); + let [art] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") }; + assert_eq!(art.opacity, 0.5); + assert_eq!(art.geometric_bounds(), Some(Rect::new(51.0, 51.0, 59.0, 59.0))); + } + + /// Opacity on one of two page objects in the box is not the unread object's opacity. + #[test] + fn opacity_on_one_of_two_page_objects_is_not_identifiable() { + let mut doc = Document::new(100.0, 100.0); + let id = doc.alloc_id(); + let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Default::default()); + editing.opacity = 0.5; + doc.insert(Some(doc.layers[0].id), 0, editing).unwrap(); + + let mut page = Document::new(100.0, 100.0); + for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 0.5), (Rect::new(21.0, 21.0, 39.0, 39.0), 1.0)] { + let mut art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + art.opacity = opacity; + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object's appearance is not identifiable in the page art"); + } + + /// A claimed first box lets a second unread object use its own corner box. + #[test] + fn second_unread_object_tries_corner_after_first_box_is_claimed() { + let mut doc = Document::new(100.0, 100.0); + let first = doc.alloc_id(); + let second = doc.alloc_id(); + for (id, bounds) in [(first, Rect::new(10.0, 10.0, 20.0, 20.0)), (second, Rect::new(12.0, 12.0, 22.0, 22.0))] { + let node = Node::path(id, vectorcraft_geom::shapes::rectangle(bounds), Default::default()); + doc.insert(Some(doc.layers[0].id), doc.layers[0].children().unwrap().len(), node).unwrap(); + } + + let mut page = Document::new(100.0, 100.0); + for bounds in [Rect::new(14.0, 14.0, 16.0, 16.0), Rect::new(51.0, 51.0, 59.0, 59.0)] { + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let unread = [(first, vec!["placed files".into()]), (second, vec!["placed files".into()])]; + let mut one_box_doc = doc.clone(); + let kinds = replace_unread(&mut doc, &frame, &unread, &[(second, Rect::new(50.0, 50.0, 60.0, 60.0))], &BTreeSet::new(), &mut BTreeSet::new()) + .unwrap(); + assert_eq!(kinds, ["placed files (2)"]); + let first_art = doc.node(first).unwrap().children().unwrap()[0].geometric_bounds(); + let second_art = doc.node(second).unwrap().children().unwrap()[0].geometric_bounds(); + assert_eq!(first_art, Some(Rect::new(14.0, 14.0, 16.0, 16.0))); + assert_eq!(second_art, Some(Rect::new(51.0, 51.0, 59.0, 59.0))); + assert_eq!( + replace_unread(&mut one_box_doc, &frame, &unread, &[], &BTreeSet::new(), &mut BTreeSet::new()).unwrap_err(), + "one page object matches more than one unread object" + ); + } + + /// Only the page's type in the page art that replaces an unread object is taken out of the + /// page's type for the text objects: type in the object's other box, which isn't used, stays. + #[test] + fn only_page_type_in_the_chosen_box_is_taken_for_an_unread_object() { + let mut doc = Document::new(100.0, 100.0); + let id = doc.alloc_id(); + doc.insert( + Some(doc.layers[0].id), + 0, + Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()), + ) + .unwrap(); + let mut page = Document::new(100.0, 100.0); + let mut nodes = vec![]; + for bounds in [Rect::new(12.0, 12.0, 18.0, 18.0), Rect::new(52.0, 52.0, 58.0, 58.0)] { + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + nodes.push(Arc::new(art.clone())); + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let unread = [(id, vec!["placed files".into()])]; + let mut shown: Vec>> = nodes.iter().map(|n| vec![Arc::clone(n)]).collect(); + let taken = take_unread_type(&doc, Some(&frame), &unread, &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], &mut shown); + assert_eq!(taken.iter().flatten().map(|n| n.id).collect::>(), [nodes[0].id]); + assert_eq!(shown.iter().flatten().map(|n| n.id).collect::>(), [nodes[1].id]); + let mut copied = BTreeSet::new(); + replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap(); + assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id)); + } +} diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index cf86445e5..f382059b6 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -47,7 +47,7 @@ const WHITE: u32 = 8; /// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other /// is white? -pub(super) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { +pub(in crate::import) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { let (p, q) = (on_white(p), on_white(q)); let (a, b) = (ink(p), ink(q)); lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) @@ -59,7 +59,7 @@ const SAME_COLOUR: u32 = 64; /// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same /// lightness to be alike)? -pub(super) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { +pub(in crate::import) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { let (p, q) = (on_white(p), on_white(q)); p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) } @@ -75,8 +75,16 @@ pub(super) struct Difference { pub(super) solid: f64, } +impl Difference { + /// The share [`compare`] holds to [`MAX_DIFFERENCE`]: every pixel that differs, or with `reflow` + /// (type laid out again from the file's text) those away from type. + pub(super) fn weighed(&self, reflow: bool) -> f64 { + if reflow { self.art } else { self.any } + } +} + /// The scale the comparisons draw a region at: about 400 pixels along its longer side. -fn compare_scale(r: Rect) -> f64 { +pub(in crate::import) fn compare_scale(r: Rect) -> f64 { (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) } @@ -129,6 +137,8 @@ pub(super) struct PageView { /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't /// held to look alike. restyled: Vec, + /// Where the page draws anything (its type, or its own outlines of type) or has its type. + page_marks: Shares, layers: vectorcraft_render::Renderer, scale: f64, samples: u32, @@ -144,7 +154,11 @@ impl PageView { let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); let mut page_type = vec![]; type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } + let (w, h) = (page.width as usize, page.height as usize); + let typed = in_areas(&page_type, w, h); + let marks: Vec = page.pixels.as_chunks::<4>().0.iter().zip(&typed).map(|(p, t)| *t || ink(on_white(p)) > WHITE).collect(); + let page_marks = Shares::of(&marks, w, h); + Self { page, page_type, restyled: vec![], page_marks, layers: vectorcraft_render::Renderer::new(), scale, samples } } /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out @@ -159,13 +173,27 @@ impl PageView { } /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). - pub(super) fn difference(&mut self, b: &Document, rb: Rect) -> Difference { + /// With `reflow` (`b`'s type laid out again from the file's text), type counts as type only where + /// both have something: `b`'s where the page has type or draws anything in at least [`MARKED`] + /// of it, the page's where at least half of it lies where `b` has a text object, shown or not, + /// or a placed file. Elsewhere it is art the other lacks. + pub(super) fn difference(&mut self, b: &Document, rb: Rect, reflow: bool) -> Difference { // The page doesn't print what isn't printed. let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - let mut areas = self.page_type.clone(); + let mut areas = vec![]; type_areas(&b.layers, rb, self.scale, &mut areas); + if reflow { + areas.retain(|r| self.page_marks.share(r) >= MARKED); + let mut texts = vec![]; + all_type_areas(&b.layers, rb, self.scale, &mut texts); + let (w, h) = (self.page.width as usize, self.page.height as usize); + let on_layers = Shares::of(&in_areas(&texts, w, h), w, h); + areas.extend(self.page_type.iter().filter(|r| on_layers.share(r) >= 0.5)); + } else { + areas.extend(&self.page_type); + } compared(&self.page, &y, &areas, &self.restyled) } @@ -241,6 +269,62 @@ fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec } } +/// Where every text object in `nodes` is, shown or not, as [`type_areas`] gives it, and every placed +/// file (whose preview draws the type it has). +fn all_type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { + for n in nodes { + let placed = matches!(&n.kind, NodeKind::Image(im) if im.link.is_some()); + if (placed || matches!(n.kind, NodeKind::Text(_))) + && let Some(b) = n.visual_bounds() + { + let b = b.inflate(OUTLINE_REACH, OUTLINE_REACH); + out.push(Rect::new((b.x0 - region.x0) * scale, (b.y0 - region.y0) * scale, (b.x1 - region.x0) * scale, (b.y1 - region.y0) * scale)); + } else if let Some(c) = n.children() { + all_type_areas(c, region, scale, out); + } + } +} + +/// Which of `w` × `h` pixels are marked, counted so that the share of any rectangle's pixels that +/// are is read at once. +pub(super) struct Shares { + /// How many of the marked pixels lie above and left of each pixel corner. + sums: Vec, + w: usize, + h: usize, +} + +impl Shares { + /// The pixels `marks` marks (row by row). + pub(super) fn of(marks: &[bool], w: usize, h: usize) -> Self { + let stride = w + 1; + let mut sums = vec![0u32; stride * (h + 1)]; + for (k, marked) in marks.iter().enumerate().take(w * h) { + let (i, j) = (k % w.max(1), k / w.max(1)); + let left = sums.get((j + 1) * stride + i).copied().unwrap_or(0); + let up = sums.get(j * stride + i + 1).copied().unwrap_or(0); + let corner = sums.get(j * stride + i).copied().unwrap_or(0); + if let Some(s) = sums.get_mut((j + 1) * stride + i + 1) { + *s = (left + up + u32::from(*marked)).saturating_sub(corner); + } + } + Self { sums, w, h } + } + + /// The share (0 to 1) of `r`'s pixels that are marked: 0 for one with none of the pixels. + pub(super) fn share(&self, r: &Rect) -> f64 { + let (w, h, stride) = (self.w, self.h, self.w + 1); + let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); + if !r.is_finite() || x1 <= x0 || y1 <= y0 { + return 0.0; + } + let at = |x: usize, y: usize| self.sums.get(y * stride + x).copied().unwrap_or(0); + let inside = (at(x1, y1) + at(x0, y0)).saturating_sub(at(x0, y1) + at(x1, y0)); + f64::from(inside) / ((x1 - x0) as f64 * (y1 - y0) as f64) + } +} + /// Which of `w` × `h` pixels (row by row) lie in one of `areas`. fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { // Each area adds one inside it: corners marked, then summed along the rows and down the columns. @@ -272,26 +356,32 @@ fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() } +/// The least share of a text object's area (with the margin round it) where the page draws anything +/// or has type, for its type laid out again to count as type. +const MARKED: f64 = 0.05; + /// More of the page than this differing between the layers' art and the page's own, and the /// layers aren't used. -pub(super) const MAX_DIFFERENCE: f64 = 0.05; +pub(in crate::import) const MAX_DIFFERENCE: f64 = 0.05; /// More than this and the import says so. const NOTABLE_DIFFERENCE: f64 = 0.002; /// More of the page than this where one draws an object that the other doesn't (see /// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. -pub(super) const MAX_SOLID_DIFFERENCE: f64 = 0.001; +pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001; /// How far (points) the art the layers print may reach past a page that holds it all. const PAST_THE_PAGE: f64 = 1.0; /// Do the layers' art and the page's own look alike (where the page holds all the art that prints, -/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little: +/// also round it)? An error says they don't; `Ok` has the notes to give when they differ a little: /// with the share of the pixels that differ away from type when that is notable, otherwise with the -/// share of those that differ in the type's areas. -pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result, String> { - let mut diff = view.difference(layered, frame.rect); +/// share of those that differ in the type's areas. With `type_reflow` (all the type laid out again +/// from the file's text, which may show in another font), type that differs doesn't make them +/// differ: only art away from type does, and the type's share is a note of its own. +pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document, type_reflow: bool) -> Result, String> { + let mut diff = view.difference(layered, frame.rect, type_reflow); if frame.whole && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) @@ -302,21 +392,24 @@ pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) }; } - if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { - return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0)); + if diff.weighed(type_reflow) > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { + return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.weighed(type_reflow) * 100.0)); } if diff.any <= NOTABLE_DIFFERENCE { - return Ok(None); + return Ok(vec![]); } - Ok(Some(if diff.art > NOTABLE_DIFFERENCE { - format!( - "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", - diff.art * 100.0 - ) - } else { - format!( - "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", - (diff.any - diff.art) * 100.0 - ) - })) + let art = format!( + "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", + diff.art * 100.0 + ); + let typed = format!( + "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", + (diff.any - diff.art) * 100.0 + ); + let art_notable = diff.art > NOTABLE_DIFFERENCE; + Ok(match (art_notable, type_reflow && diff.any - diff.art > NOTABLE_DIFFERENCE) { + (true, true) => vec![art, typed], + (true, false) => vec![art], + (false, _) => vec![typed], + }) } diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 6be56c200..7f91438be 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{Page, build, build_from}; +use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -7,32 +7,38 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the -/// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty, -/// below the page's layer when that could be read (#2033). -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { - let Some(data) = editing_copy(ps) else { return visible }; +/// editing copy's palette ([`use_palette`]) and its variable library ([`use_variables`]) when it +/// could be read, and with its layer tree, empty, +/// below the page's layer when that could be read (#2033). A program in the legacy format without +/// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that +/// can't be used, the file is `visible` as it was. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported { + let Some(data) = editing_copy(ps) else { + let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok()); + return match read { + Some((document, warnings)) => with_page_notes(document, warnings, &visible), + None => visible, + }; + }; // The layer tree is kept from the structure read, for the page to come in on when the layers // can't be used. + // Private data kept apart from the program: the program's own header gives the author and + // custom colors the private data's header may lack. + let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps) } else { ai::read(d, true) }; let (tree, result) = match data.as_deref() { - Ok(d) => match ai::read(d, true) { + Ok(d) => match read(d) { Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, Err(why) => (vec![], Err(why.clone())), }; match result { - Ok((document, mut warnings)) => { - for w in &visible.warnings { - if !warnings.contains(w) { - warnings.push(w.clone()); - } - } - Imported { document, warnings, preview: false } - } + Ok((document, warnings)) => with_page_notes(document, warnings, &visible), Err(why) => { let mut v = visible; if let Ok(data) = &data { use_palette(data, &mut v.document); + use_variables(data, &mut v.document, &mut v.warnings); } if tree.is_empty() { v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); @@ -58,6 +64,17 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im } } +/// `document`, read from the editing copy with the notes `warnings`, followed by the notes of +/// reading `visible` that it doesn't already have. +fn with_page_notes(document: Document, mut warnings: Vec, visible: &Imported) -> Imported { + for w in &visible.warnings { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + Imported { document, warnings, preview: false } +} + /// The layers in `nodes` with only their sublayers in them: names, order and options without the art. fn layer_tree(nodes: &[Arc]) -> Vec> { let layers = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Layer { .. })); @@ -80,8 +97,9 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// notes, or a note saying why the layers weren't read when the editing data can't be decompressed /// or its layers can't be read. The flag returned is `true` when the document is the one read from /// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read, and its swatches those of the editing data's -/// palette ([`use_palette`]) when it was decompressed. `outlined`: the PDF part's type was read as +/// data was decompressed but its layers can't be read, and its swatches and variables those of the +/// editing data's palette ([`use_palette`]) and variable library ([`use_variables`]) when it was +/// decompressed. `outlined`: the PDF part's type was read as /// outlines (a file whose type shows then comes in as its PDF part). /// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. /// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, @@ -98,8 +116,9 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl Err(why) => return (visible, unread(warnings, &why), false), }; if !has_layers(&data) { - let mut visible = visible; + let (mut visible, mut warnings) = (visible, warnings); use_palette(&data, &mut visible); + use_variables(&data, &mut visible, &mut warnings); return (visible, warnings, false); } // The PDF part, its pages placed and named as the editing data's artboards are, when the @@ -108,7 +127,7 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl place_pages(&ai::artboards(&data), &mut visible, unit); use_palette(&data, &mut visible); let mut all = unread(warnings, why); - all.extend(ai::variables(&data).note()); + use_variables(&data, &mut visible, &mut all); (visible, all, false) }; match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { @@ -127,6 +146,18 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl } } +/// `doc` (a file read as its page or PDF part, in place of its layers) with the variables, data +/// sets, active data set and lock of the variable library of its editing data `data`, as [`build`] +/// gives a document read from the layers, and the note about what of the library wasn't read +/// added to `warnings`. Left as it is when the editing data has no library. +fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec) { + let read = ai::variables(data); + warnings.extend(read.note()); + if read.present { + doc.variables = read.variables.into(); + } +} + /// `doc` (a `.ai` read as its PDF part, in place of its layers) with the swatches and color groups /// of the palette of its editing data `data` in place of the default ones, as [`build`] gives a /// document read from the layers. The spot and pattern swatches of the PDF part's art that the @@ -204,7 +235,7 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { - Arc::make_mut(child).transform(Affine::translate(*v), false); + Arc::make_mut(child).transform(Affine::translate(*v), PAGE_MOVE); } } } @@ -216,6 +247,16 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { build(&data, None, Page::Artboard, false, false, 1.0) } +/// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height) +/// points large in units of `unit` points (its `UserUnit`): editing data whose first artboard, or +/// whose art's box, has the page's size in those units, and not in points, comes in `unit` times as +/// large, as [`layered_ai`] reads it. A page turned a quarter (`/Rotate 90`) is measured either +/// way round. +pub fn ai_alone_on_page(private: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec), String> { + let data = ai::decode_private(private)?; + build_on_page(&data, page, unit) +} + /// Does the editing data (or a program in the legacy format) have layers? fn has_layers(data: &[u8]) -> bool { data.windows(15).any(|w| w == b"%AI5_BeginLayer") @@ -243,7 +284,7 @@ pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option