diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c9de87acf..d482d6611 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,11 +10,31 @@ updates: interval: "daily" time: "06:00" timezone: "America/New_York" - # Consolidate updates into a single PR rather than one PR per crate groups: - cargo-dependencies: - patterns: - - "*" + # A dependency that matches several groups goes to the first one it matches, so the + # lockstep families are listed before the catch-all cargo-patch group. + # Families that must move together; all update types of a family land in one PR. + egui-stack: # egui/eframe/winit are [patch.crates-io]'d to storytold forks: expect this PR red on upstream releases until the forks move + patterns: ["egui*", "eframe", "epaint*", "ecolor", "emath", "winit", "wgpu*", "naga", "accesskit*"] + font-stack: + patterns: ["harfrust", "skrifa", "read-fonts", "font-types", "fontique", "parley", "write-fonts", "skera", "ttf-parser"] + pdf-render-stack: + patterns: ["hayro*", "krilla*", "vello*", "peniko", "kurbo", "resvg", "usvg", "tiny-skia*", "svgtypes"] + icu-stack: + patterns: ["icu_*", "fixed_decimal"] + ml-stack: + patterns: ["candle-*", "rten*", "ocrs"] + platform-stack: + patterns: ["zbus*", "zvariant*", "x11rb*"] + apple-stack: + patterns: ["objc2*", "block2", "dispatch2"] + rustcrypto: + patterns: ["aes", "sha1", "sha2", "sha3", "md-5", "hmac", "pbkdf2", "digest", "cipher"] + # Everything else: patch bumps only (minor/major of other crates get one PR each). + cargo-patch: + update-types: ["patch"] + patterns: ["*"] + open-pull-requests-limit: 10 commit-message: prefix: "chore(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b83d5a8f6..7b3b6bf5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0f91ed6d0..007fcfec0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -29,5 +29,5 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: require-craft-fonts: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 run-package-check: true diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 386989022..da85536c4 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,3 +23,27 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main with: extra-checks-script: packaging/check-app-specific.sh + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 794c9affc..400a81944 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -8,7 +8,7 @@ on: - "apps/vectorcraft/src/printing.rs" - "apps/vectorcraft/src/printing/**" - "crates/ui-egui/src/print.rs" - - "crates/ui-egui/src/tests_printps.rs" + - "crates/ui-egui/src/tests/printps.rs" - ".github/workflows/print.yml" workflow_dispatch: permissions: @@ -32,4 +32,8 @@ jobs: - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings - run: cargo test --locked -p vectorcraft - name: Check pending submission and queue refusal feedback - run: cargo test --locked -p vectorcraft-ui-egui tests_printps + 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; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fd996c43..d1677e538 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: with: app-name: vectorcraft app-title: VectorCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 2dc19466c..7fc9b5925 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,10 @@ permissions: jobs: windows-arm64: - # 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/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: vectorcraft app-dir: VectorCraft + install-relative-path: Programs/ArtCraft/VectorCraft + install-scope: perUser diff --git a/.github/workflows/windows7.yml b/.github/workflows/windows7.yml index 7aa11a54b..c638bcef5 100644 --- a/.github/workflows/windows7.yml +++ b/.github/workflows/windows7.yml @@ -14,7 +14,7 @@ on: - "apps/vectorcraft/src/printing.rs" - "apps/vectorcraft/src/printing/**" - "crates/ui-egui/src/print.rs" - - "crates/ui-egui/src/tests_printps.rs" + - "crates/ui-egui/src/tests/printps.rs" - "packaging/windows/windows7.ps1" - ".github/workflows/windows7.yml" schedule: diff --git a/ASSETS.md b/ASSETS.md index 8e096c1c3..26d463091 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -12,6 +12,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | @@ -350,20 +352,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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/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 | diff --git a/Cargo.lock b/Cargo.lock index 8193d018b..d329c26a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,66 +4,60 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -437,6 +432,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -449,6 +468,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -709,7 +734,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -718,7 +743,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -740,6 +770,48 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -795,6 +867,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -805,6 +883,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -818,7 +906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -831,7 +919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -871,12 +959,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "serde", @@ -886,7 +1002,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -897,29 +1013,81 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.9.1", + "roxmltree 0.21.1", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", ] [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "wayland-client", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -929,6 +1097,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -991,6 +1171,36 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -1074,10 +1284,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1085,9 +1300,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1110,6 +1324,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1121,9 +1336,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1141,9 +1355,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1161,30 +1374,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1197,9 +1413,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1211,9 +1426,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1238,13 +1452,26 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1253,22 +1480,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1294,9 +1521,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1312,16 +1538,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1401,18 +1628,45 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1433,6 +1687,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1516,6 +1792,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1552,6 +1844,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1565,8 +1863,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1600,6 +1901,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1619,9 +1933,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1631,7 +1947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -1645,22 +1961,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1675,7 +1975,7 @@ dependencies = [ "png 0.18.1", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1779,6 +2079,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1854,7 +2173,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "brotli", "hayro-postscript", @@ -1863,14 +2182,14 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bitflags 2.13.2", "hayro-cmap", "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -1881,7 +2200,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -1899,12 +2218,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "flate2", "hayro-ccitt", @@ -1917,6 +2236,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1929,6 +2254,131 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1943,6 +2393,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -1988,6 +2455,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -1997,6 +2467,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2026,6 +2522,7 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", "serde", "stable_deref_trait", "writeable", @@ -2035,6 +2532,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "icu_segmenter" version = "2.3.0" @@ -2145,6 +2666,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -2293,18 +2820,18 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2434,6 +2961,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -2456,6 +2986,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "md-5" version = "0.10.6" @@ -2490,6 +3026,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2510,6 +3052,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2522,9 +3075,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", @@ -2538,6 +3091,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2606,6 +3165,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2621,6 +3192,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2989,6 +3566,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3078,6 +3661,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3114,8 +3716,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3126,7 +3738,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3135,8 +3757,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3151,6 +3786,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -3206,6 +3850,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -3282,6 +3939,16 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -3293,6 +3960,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3308,6 +3981,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3379,6 +4076,72 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3406,23 +4169,39 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ + "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3551,6 +4330,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -3601,6 +4423,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3663,6 +4499,83 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3714,6 +4627,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -3723,6 +4642,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3746,6 +4674,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3757,6 +4708,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -3801,6 +4762,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3821,6 +4793,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3848,6 +4843,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4036,6 +5200,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4078,6 +5252,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4090,6 +5285,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -4110,6 +5311,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4132,6 +5343,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -4143,6 +5363,50 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4224,10 +5488,40 @@ dependencies = [ "flate2", "half", "quick-error 2.0.1", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4282,13 +5576,63 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -4342,6 +5686,51 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4385,6 +5774,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4409,6 +5804,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -4501,6 +5902,47 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4563,6 +6005,18 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4589,6 +6043,7 @@ dependencies = [ "chrono", "clipboard-win", "craft-print", + "craft-update", "craft-window", "eframe", "egui", @@ -4891,7 +6346,7 @@ dependencies = [ "vectorcraft-geom", "vectorcraft-testkit", "vectorcraft-text", - "weezl", + "weezl 0.2.1", ] [[package]] @@ -4917,6 +6372,7 @@ dependencies = [ "image", "jpeg-encoder", "log", + "png 0.18.1", "proptest", "psd", "serde_json", @@ -4929,10 +6385,11 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-testkit", "vectorcraft-text", - "vello_common 0.2.0", - "vello_cpu 0.2.0", + "vello_common", + "vello_cpu", "wat", - "weezl", + "weezl 0.2.1", + "winsafe", ] [[package]] @@ -5001,7 +6458,7 @@ dependencies = [ "unicode-segmentation", "vectorcraft-doc", "vectorcraft-geom", - "vello_cpu 0.2.0", + "vello_cpu", "windows-registry", "write-fonts", ] @@ -5049,6 +6506,7 @@ dependencies = [ "bytemuck", "craft-keymap", "craft-ui", + "craft-update", "egui", "egui_extras", "egui_kittest", @@ -5098,21 +6556,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -5129,18 +6572,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -5149,13 +6580,23 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo 0.3.0", + "glifo", "hashbrown 0.17.1", "ordered-channel", "png 0.18.1", "rayon", "thread_local", - "vello_common 0.2.0", + "vello_common", +] + +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", ] [[package]] @@ -5183,6 +6624,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5460,7 +6916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5498,9 +6954,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5512,12 +6968,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wgpu" version = "30.0.1" @@ -5925,8 +7396,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5937,7 +7407,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5983,6 +7453,37 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -6036,11 +7537,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -6074,6 +7584,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -6099,6 +7644,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -6294,6 +7845,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -6301,6 +7872,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", "zerovec", @@ -6329,6 +7902,23 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6341,6 +7931,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -6396,3 +7998,18 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 46a94c5c8..644bffe9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,15 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +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" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -102,10 +103,10 @@ tiff = "0.11" zune-jpeg = "0.5" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" arboard = { version = "3.6", default-features = false } clipboard-win = "5.4" @@ -140,9 +141,24 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } -hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +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" } +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" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 0d6f9dd09..8698b2dfe 100644 --- a/NOTICE +++ b/NOTICE @@ -77,10 +77,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 -2eca9b3a53a98ac52efed2a27353c6a6927ead10. Their upstream project is +92d392cff132eaf53e13afff77f38b5c11b73aa0. 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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/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. @@ -123,3 +123,21 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 3827ab1eb..82f0b100d 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -32,10 +32,11 @@ log = { workspace = true } [target.'cfg(windows)'.dependencies] winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] } -# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that -# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0. +# 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. [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } # 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 9f915f6fc..6ec320ea2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -4,7 +4,7 @@ //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -90,7 +90,7 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] + vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard @@ -103,8 +103,11 @@ USAGE: IN left things out (hidden text, art or layers it could not read; the notes say which) unless --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy even where that draws differently from its PDF part or page (which can be older), as long as - it can be read. The - result has the export's warnings, and importWarnings for what reading IN left out. + it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is + given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the + attachment is written the result's warnings say so (it is left out, with its own warning, for + --artboard or a --range without every artboard). + The result has the export's warnings, and importWarnings for what reading IN left out. --ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file per artboard, OUT-.ai when there are several; default pdf, PDF-compatible), with --hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit; @@ -274,8 +277,8 @@ fn convert(args: &[String]) -> Result<(), String> { return help(); } let mut files = vec![]; - let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) = - (1.0f64, None::, None::, false, false, false); + let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) = + (1.0f64, None::, None::, false, false, false, false); let mut decimals = None::; // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, // `transparentImages`); the engine checks their values. @@ -290,6 +293,7 @@ fn convert(args: &[String]) -> Result<(), String> { "--decimals" => decimals = Some(it.next().and_then(|v| v.parse::().ok()).ok_or("--decimals needs a number")?), "--replace-lossy" => replace_lossy = true, "--editing-data" => editing_data = true, + "--preserve-editing" => preserve_editing = true, "--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)), "--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)), "--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)), @@ -319,6 +323,9 @@ fn convert(args: &[String]) -> Result<(), String> { if let Some(decimals) = decimals { params["decimals"] = json!(decimals); } + if vectorcraft_engine::cmd::fileio::format_for_name(&output).is_some_and(|f| f.id == "pdf") { + params["preserveEditing"] = json!(preserve_editing); + } ("document.export", params) }; for (key, value) in legacy { @@ -330,6 +337,25 @@ fn convert(args: &[String]) -> Result<(), String> { if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) { out.insert("importWarnings".into(), w.clone()); } + if preserve_editing + && vectorcraft_engine::cmd::fileio::format_for_name(&output).is_some_and(|f| f.id == "pdf") + && let Some(obj) = r.as_object_mut() + { + // The export leaves the attachment out (with this warning) for --artboard or a --range + // without every artboard; otherwise it writes it. Read that from the result, not the file. + let skipped = vectorcraft_engine::cmd::fileio::pdf::EDITING_NEEDS_EVERY_ARTBOARD; + let embedded = !obj.get("warnings").and_then(Value::as_array).is_some_and(|w| w.iter().any(|w| w.as_str() == Some(skipped))); + if embedded { + let note = "The PDF embeds the native VectorCraft document as an attachment (preserveEditing)."; + if let Some(Value::Array(w)) = obj.get_mut("warnings") { + if !w.iter().any(|w| w.as_str() == Some(note)) { + w.push(json!(note)); + } + } else { + obj.insert("warnings".into(), json!([note])); + } + } + } outln!("{r}"); Ok(()) } diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs index 104aeb0fe..1423b78b4 100644 --- a/apps/vectorcraft-cli/tests/it/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -46,6 +46,29 @@ fn info(file: &str) -> Value { serde_json::from_str(&ok(&["info", file])).unwrap() } +/// #3627: `convert` to PDF omits the embedded native document unless asked. +#[test] +fn convert_pdf_omits_editing_data_unless_preserve_editing() { + let pdf = tmp("flyer.pdf"); + make(&pdf, 120, 80); + let plain = tmp("plain.pdf"); + ok(&["convert", &pdf, &plain]); + let bytes = std::fs::read(&plain).unwrap(); + assert!(!bytes.windows(b"vectorcraft-editing.vectorcraft".len()).any(|w| w == b"vectorcraft-editing.vectorcraft")); + let kept = tmp("kept.pdf"); + let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &kept, "--preserve-editing"])).unwrap(); + let bytes = std::fs::read(&kept).unwrap(); + assert!(bytes.windows(b"vectorcraft-editing.vectorcraft".len()).any(|w| w == b"vectorcraft-editing.vectorcraft"), "{r}"); + assert!(r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|s| s.contains("preserveEditing")))), "{r}"); + // A --range without every artboard leaves the attachment out: no "embeds" note, even when the + // output's own name contains the attachment's name. + let part = tmp("vectorcraft-editing.vectorcraft.pdf"); + let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &part, "--preserve-editing", "--range", "2"])).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|s| s.contains("every artboard"))), "{r}"); + assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|s| s.contains("preserveEditing"))), "{r}"); +} + #[test] fn convert_pdf_to_svg_and_info_on_ai() { let pdf = tmp("in.pdf"); diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 211bfdb18..8decb13d8 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -29,4 +29,4 @@ wasm-bindgen = "0.2" serde_json = { workspace = true } wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } diff --git a/apps/vectorcraft-web/src/keys.rs b/apps/vectorcraft-web/src/keys.rs new file mode 100644 index 000000000..8cb27096b --- /dev/null +++ b/apps/vectorcraft-web/src/keys.rs @@ -0,0 +1,118 @@ +//! Shortcuts the browser would also act on. Many of the app's Cmd and Ctrl chords are the +//! browser's too: the View menu's `Cmd+=`, `Cmd+-` and `Cmd+0` zoom the page, Edit's `Cmd+Z` and +//! `Cmd+Shift+Z` undo and redo in the browser (in Safari, Undo Close Tab when the page has nothing +//! to undo), `Cmd+[` and `Cmd+]` go back and forward, `Cmd+F` finds. eframe lets them through, so +//! the browser acted on them as well. For a press the keymap in effect binds (the user's shortcuts +//! included), the page's default action is cancelled; the key still reaches egui, which runs the +//! command (or a text field its own undo). A few chords never reach a page at all (Safari's +//! `Cmd+L`, Chrome's `Cmd+T`, `Cmd+W` and `Cmd+N`): the browser keeps those. + +use egui::{Key, Modifiers}; + +/// Whether the browser's own action for a key press is cancelled. `key` is the event's `key` (the +/// character typed), `code` its `code` (the key's position), `modifiers` as eframe reports them, +/// and `alt_graph` whether AltGr is held. +/// +/// Only Cmd and Ctrl presses: a plain or Shift press types (cancelling it would drop the +/// character it types), and so does AltGr, which Windows reports as Ctrl and Alt held. Cut, Copy +/// and Paste (with any other modifiers) are left to the browser, whose clipboard events carry +/// their text, and Reload too, as eframe leaves them. +pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt_graph: bool) -> bool { + if !(modifiers.ctrl || modifiers.mac_cmd) || alt_graph { + return false; + } + if matches!(Key::from_name(key).or(Key::from_name(code)), Some(Key::C | Key::V | Key::X | Key::R)) { + return false; + } + vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers) +} + +/// The modifiers of a key event as eframe reports them to egui (its `modifiers_from_kb_event`). +#[cfg(target_arch = "wasm32")] +fn modifiers(e: &web_sys::KeyboardEvent) -> Modifiers { + Modifiers { alt: e.alt_key(), ctrl: e.ctrl_key(), shift: e.shift_key(), mac_cmd: e.meta_key(), command: e.ctrl_key() || e.meta_key() } +} + +/// Whether the app has the keyboard for this event: it went to the canvas, or to the text input +/// eframe places right after it while a text field has the focus. Elsewhere (the page's body, a +/// file input) eframe ignores the key, so the browser keeps it. +#[cfg(target_arch = "wasm32")] +fn to_the_app(e: &web_sys::KeyboardEvent) -> bool { + use wasm_bindgen::JsCast as _; + let Some(target) = e.target() else { return false }; + if target.has_type::() { + return true; + } + target + .dyn_ref::() + .and_then(|input| input.previous_element_sibling()) + .is_some_and(|before| before.has_type::()) +} + +/// Cancel the page's default action for [`kept_from_browser`] presses, before eframe's listener +/// sees the event (it doesn't stop it). +#[cfg(target_arch = "wasm32")] +pub(crate) fn keep_app_keys() { + use wasm_bindgen::JsCast as _; + let Some(window) = web_sys::window() else { return }; + let on_key = wasm_bindgen::closure::Closure::::new(|e: web_sys::KeyboardEvent| { + // An IME's keys are its own until it commits. + if e.is_composing() || !to_the_app(&e) { + return; + } + if kept_from_browser(&e.key(), &e.code(), modifiers(&e), e.get_modifier_state("AltGraph")) { + e.prevent_default(); + } + }); + if let Err(e) = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true) { + log::error!("couldn't follow keydown events: {e:?}"); + } + // The listener lives as long as the page. + on_key.forget(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere. + fn cmd() -> Modifiers { + if cfg!(target_os = "macos") { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } + } + + #[test] + fn the_keymaps_chords_are_kept_from_the_browser() { + for (key, code, mods) in [ + ("=", "Equal", cmd()), + ("-", "Minus", cmd()), + ("0", "Digit0", cmd()), + ("z", "KeyZ", cmd()), + ("Z", "KeyZ", cmd() | Modifiers::SHIFT), + ("[", "BracketLeft", cmd()), + ] { + assert!(kept_from_browser(key, code, mods, false), "{key} ({code}) {mods:?}"); + } + } + + #[test] + fn typing_the_clipboard_and_reload_stay_with_the_browser() { + for (key, code, mods) in [ + // Typing: plain and Shift presses. + ("z", "KeyZ", Modifiers::NONE), + ("Z", "KeyZ", Modifiers::SHIFT), + ("=", "Equal", Modifiers::NONE), + // The clipboard's events carry the text; Paste and Match Style is Shift+Cmd+V. + ("c", "KeyC", cmd()), + ("x", "KeyX", cmd()), + ("v", "KeyV", cmd()), + ("V", "KeyV", cmd() | Modifiers::SHIFT), + ("r", "KeyR", cmd()), + ] { + assert!(!kept_from_browser(key, code, mods, false), "{key} ({code}) {mods:?}"); + } + // AltGr+Z types ż on a Polish layout, reported as Ctrl and Alt held on Windows. + let ctrl_alt = Modifiers::CTRL | Modifiers::COMMAND | Modifiers::ALT; + assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true)); + assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true)); + } +} diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index f76e54786..f7dd201af 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -11,7 +11,10 @@ //! - Save / Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and -//! hover, palm rejection, fingers navigate once a pen has been seen). +//! hover, palm rejection, fingers navigate once a pen has been seen, the pen's eraser end +//! selects the Eraser tool); +//! - a Cmd or Ctrl chord the keymap binds runs its command, not the browser's own action as well +//! (`Cmd+=` zooms the canvas, not the page; `Cmd+Z` undoes in the document) (`keys`). //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any //! language code) stands in for the browser's languages when the interface language is Automatic. @@ -20,6 +23,8 @@ mod api; #[cfg(target_arch = "wasm32")] mod fonts; +#[cfg(any(target_arch = "wasm32", test))] +mod keys; #[cfg(target_arch = "wasm32")] mod locks; #[cfg(any(target_arch = "wasm32", test))] diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs index 42f6294f2..b7140d7e5 100644 --- a/apps/vectorcraft-web/src/pencil.rs +++ b/apps/vectorcraft-web/src/pencil.rs @@ -5,6 +5,9 @@ //! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the //! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers //! where the pen can (Apple Pencil hover). +//! - A press of the pen's eraser end (the eraser button, `button` 5 in Pointer Events) presses +//! like the tip, and the pen's samples go into a [`StylusFeed`] with that end, so the canvas +//! selects the Eraser tool for the press. A press of the tip turns the pen back. //! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first //! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates //! only once a pen has been seen ([`touch::set_source`]), for this page session only. @@ -20,6 +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::touch::{self, Source}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast as _; @@ -28,8 +32,11 @@ use wasm_bindgen::JsCast as _; const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1); const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2); +/// The Pointer Events `button` of a pen's eraser button, which is how a pen's eraser end presses +/// (). +const ERASER_BUTTON: i16 = 5; + /// The pen and finger contacts followed, and the egui events they made since the last frame. -#[derive(Default)] pub struct Pointers { events: Vec, /// The pen pressing, by pointer id. @@ -39,6 +46,16 @@ pub struct Pointers { /// The contact driving egui's pointer. primary: Option, button: Option, + /// The pen last pressed with its eraser end. + eraser: bool, + /// The pen's pressure and end for the canvas (`Services::stylus`). + stylus: StylusFeed, +} + +impl Default for Pointers { + fn default() -> Self { + Self { events: vec![], pen: None, fingers: vec![], primary: None, button: None, eraser: false, stylus: StylusFeed::on_ui_thread() } + } } #[cfg(target_arch = "wasm32")] @@ -71,6 +88,16 @@ impl Pointers { std::mem::take(&mut self.events) } + /// The feed the pen's samples go into, for `Services::stylus`. + pub fn stylus(&self) -> StylusFeed { + self.stylus.clone() + } + + /// 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 })); + } + fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option) { self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force }); } @@ -110,6 +137,11 @@ impl Pointers { /// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe). fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool { let id = e.id; + if e.pointer_type != "pen" && self.pen.is_none() && matches!(kind, "pointerdown" | "pointermove") { + // Another pointer moved while no pen presses (a palm under a pen pressing is + // rejected): the canvas takes no pen pressure or pen end for it. + self.stylus.set(None); + } match e.pointer_type { "pen" => { let force = Some(e.pressure.clamp(0.0, 1.0)); @@ -118,14 +150,27 @@ impl Pointers { if !on_canvas && self.pen.is_none() { return false; } - let Some(button) = button(e.button) else { return false }; + // The eraser end presses like the tip; the tip and the eraser end each + // set the end the pen is turned to. + let (button, end) = match e.button { + ERASER_BUTTON => (PointerButton::Primary, Some(true)), + number => match button(number) { + Some(button) => (button, (number == 0).then_some(false)), + None => return false, + }, + }; touch::set_source(Source::Pen); self.cancel_fingers(pos, e.modifiers); + if let Some(end) = end { + self.eraser = end; + } + self.pen_sample(e.pressure); self.pen = Some(id); self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force); self.press(id, pos, button, e.modifiers); } "pointermove" if self.pen == Some(id) => { + self.pen_sample(e.pressure); // The positions the pen reported between two events (up to 240 a second // on an iPad), each with its pressure, so fast strokes keep their curve. for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) { @@ -143,10 +188,14 @@ impl Pointers { if !on_canvas { return false; } + // Pointer Events report no end while a pen hovers: it keeps the end it + // last pressed with. + self.pen_sample(e.pressure); self.events.push(Event::PointerMoved(pos)); } "pointerup" | "pointercancel" if self.pen == Some(id) => { let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; + self.pen_sample(0.0); self.pen = None; self.touch(PEN_DEVICE, id, phase, pos, None); self.release(pos, e.modifiers); @@ -388,8 +437,85 @@ mod tests { fn unknown_pen_buttons_are_not_reinterpreted_as_primary() { reset(); let mut p = Pointers::default(); - assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true)); + assert!(!p.feed("pointerdown", &input("pen", 6), Pos2::ZERO, &[], true)); assert!(p.take().is_empty()); assert!(!touch::pen_seen()); + assert_eq!(p.stylus().eraser(), None); + } + + fn presses(p: &mut Pointers) -> Vec<(PointerButton, bool)> { + p.take() + .into_iter() + .filter_map(|e| match e { + Event::PointerButton { button, pressed, .. } => Some((button, pressed)), + _ => None, + }) + .collect() + } + + /// The eraser end presses as Pointer Events' eraser button (`button` 5): a primary press whose + /// samples carry the eraser end, kept while the pen hovers, until the tip presses. + #[test] + fn the_eraser_button_presses_with_the_eraser_end() { + reset(); + let mut p = Pointers::default(); + let feed = p.stylus(); + let pos = Pos2::new(30.0, 40.0); + let eraser = input("pen", ERASER_BUTTON); + assert!(p.feed("pointerdown", &eraser, pos, &[], true)); + assert_eq!(presses(&mut p), [(PointerButton::Primary, true)]); + assert!(touch::pen_seen()); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.6))); + assert!(p.feed("pointermove", &input("pen", -1), pos, &[], true)); + assert_eq!(feed.eraser(), Some(true)); + assert!(p.feed("pointerup", &eraser, pos, &[], true)); + assert_eq!(presses(&mut p), [(PointerButton::Primary, false)]); + let mut hover = input("pen", -1); + hover.pressure = 0.0; + assert!(p.feed("pointermove", &hover, pos, &[], true)); + assert_eq!(feed.eraser(), Some(true), "a hovering pen keeps the end it pressed with"); + // The barrel button leaves the end as it was. + assert!(p.feed("pointerdown", &input("pen", 2), pos, &[], true)); + assert!(p.feed("pointerup", &input("pen", 2), pos, &[], true)); + assert_eq!(feed.eraser(), Some(true)); + assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true)); + assert_eq!(feed.eraser(), Some(false), "the tip"); + reset(); + } + + /// A mouse or a finger that moves takes the pen out of the feed, so the canvas gives it no + /// pen pressure and does not switch tools for it. + #[test] + fn other_pointers_clear_the_pen_feed() { + for (pointer_type, kind) in [("mouse", "pointermove"), ("mouse", "pointerdown"), ("touch", "pointerdown"), ("touch", "pointermove")] { + reset(); + let mut p = Pointers::default(); + let feed = p.stylus(); + assert!(p.feed("pointerdown", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true)); + assert!(p.feed("pointerup", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true)); + let mut other = input(pointer_type, 0); + other.id = 10; + p.feed(kind, &other, Pos2::ZERO, &[], true); + assert_eq!((feed.eraser(), feed.held()), (None, None), "{pointer_type} {kind}"); + } + reset(); + } + + /// A palm that lands or moves while the eraser end presses is rejected, and the feed keeps the + /// eraser end for the canvas's next frame. + #[test] + fn a_rejected_palm_keeps_the_pen_end() { + reset(); + let mut p = Pointers::default(); + let feed = p.stylus(); + assert!(p.feed("pointerdown", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true)); + let mut palm = input("touch", 0); + palm.id = 10; + for (pointer_type, kind) in [("touch", "pointerdown"), ("touch", "pointermove"), ("mouse", "pointermove")] { + palm.pointer_type = pointer_type; + p.feed(kind, &palm, Pos2::ZERO, &[], true); + assert_eq!(feed.eraser(), Some(true), "{pointer_type} {kind}"); + } + reset(); } } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index c30799475..859d41ef7 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -13,6 +13,7 @@ use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; +use vectorcraft_ui_egui::stylus::StylusFeed; use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; @@ -81,6 +82,7 @@ pub fn start() { restarts: 0, }; track_paste(&host.pasted); + crate::keys::keep_app_keys(); run(host, canvas, None).await; }); } @@ -106,6 +108,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // The old services asked the old context for frames. @@ -365,55 +369,63 @@ impl eframe::App for WebShell { self.restart(&why); } let Some(app) = &mut self.app else { return }; - let pasted = self.host.pasted.borrow_mut().flavour.take(); - if let Some((f, held)) = pasted { - app.paste_from_host(ctx, f, held); - } - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - if !dropped.is_empty() { - let at = self.host.drag.take(); - let z = ctx.zoom_factor(); - let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2)); - for f in dropped { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - let target = app.drop_target(&name, pos, shift); - let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); - wasm_bindgen_futures::spawn_local(async move { - match f.bytes_async().await { - Ok(bytes) => { - match target { - DropTarget::Place(d) => { - place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) - } - DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), - } - ctx.request_repaint(); - } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); - } - } - app.logic(ctx); + app.tick(ctx); crate::api::poll_replies(); + app.finish_ime_logic(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + /// Forward host-gathered pointer events into this frame's raw input, then let the app apply its + /// shared input hook before egui runs. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { raw.events.extend(self.host.pointers.borrow_mut().take()); if let Some(app) = &mut self.app { - app.raw_input_hook(raw); + app.raw_input_hook(ctx, raw); } } + /// Read host paste and drop input once in the UI phase, hand files to the app's drop targets, run + /// the app UI, then poll control replies. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); if let Some(app) = &mut self.app { + // DOM paste and drop are host input: read them in the UI phase, once per pass, not in + // `logic` where a hidden tab replays the last shown frame's input. + let pasted = self.host.pasted.borrow_mut().flavour.take(); + if let Some((f, held)) = pasted { + app.paste_from_host(&ctx, f, held); + } + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + if !dropped.is_empty() { + let at = self.host.drag.take(); + let z = ctx.zoom_factor(); + let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2)); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + let target = app.drop_target(&name, pos, shift); + let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + match f.bytes_async().await { + Ok(bytes) => { + match target { + DropTarget::Place(d) => { + place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) + } + DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), + } + repaint.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } app.ui(ui); } crate::api::poll_replies(); } } -fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option) -> Services { +fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option, stylus: StylusFeed) -> Services { let open_inbox = inbox.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); @@ -463,6 +475,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. print: Some(Box::new(BrowserPrint)), + // The pen's end, from the page's Pointer Events (`crate::pencil`). + stylus: Some(stylus), ..Default::default() } } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index fedc4acbe..561fd0e84 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -23,7 +23,7 @@ vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) # so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217. craft-window = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } +eframe = { workspace = true, features = ["clipboard", "default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -43,23 +43,26 @@ winit = { version = "0.30", default-features = false } craft-print = { workspace = true } [target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } clipboard-win = { workspace = true } # Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the # GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over # Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter. winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true } -# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as -# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit. +# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font +# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build +# through accesskit. # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to -# read a pen's barrel button from its pointer messages. +# 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. [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK # with default features off). Pinned exactly: review the diff before bumping. -muda = { version = "=0.21.1", default-features = false } +muda = { version = "=0.21.2", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } # Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs new file mode 100644 index 000000000..c7d6ada32 --- /dev/null +++ b/apps/vectorcraft/src/app.rs @@ -0,0 +1,166 @@ +use super::prefs::save_prefs; +use super::*; + +/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating +/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel +/// button's secondary click on Windows. The control channel's input and the native menu's keys +/// go to the main window only. +#[cfg(target_os = "macos")] +pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option { + app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook) +} + +#[cfg(all(windows, not(target_vendor = "win7")))] +pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option { + Some(std::sync::Arc::new(windows_stylus::raw_input_hook)) +} + +#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))] +pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option { + None +} + +impl eframe::App for App { + // Each frame runs unconfined: it is the person at the keyboard's. The app confines what the + // control channel asks for, and the frames carrying input it injected, to the automation roots + // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + file_access::unconfined(|| self.logic_frame(ctx, frame)); + self.app.finish_ime_logic(ctx); + } + /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the + /// app's raw-input hook while the keyboard user is unconfined. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // winit gives the window its caption back when it changes the window's flags (maximize, + // restore, …): take it off again before the frame is laid out (#1758). + if let Some(window_frame) = self.window_frame.as_mut() { + window_frame(); + } + #[cfg(target_os = "macos")] + if self.app.services.native_menu.is_some() { + mac_menu::raw_input_hook(raw); + } + // A pen contact with the barrel button held presses the secondary button (Windows Ink). + #[cfg(all(windows, not(target_vendor = "win7")))] + windows_stylus::raw_input_hook(raw); + file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); + } + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + { + let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; + let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar); + self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); + } + 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). + if self.app.take_prefs_changed() { + file_access::unconfined(|| save_prefs(&self.app)); + } + // Not while a file dialog's sheet has the keyboard: its name field's composition would go. + #[cfg(target_os = "macos")] + if !self.app.file_dialog_open() && self.app.take_ime_discard() { + discard_marked_text(); + } + } + #[cfg(not(feature = "windows7"))] + fn on_exit(&mut self) { + file_access::unconfined(|| save_prefs(&self.app)); + end_before_teardown(self.frames); + } + #[cfg(feature = "windows7")] + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + file_access::unconfined(|| save_prefs(&self.app)); + } +} + +impl App { + fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.frames = ctx.cumulative_frame_nr(); + if let Some(why) = self.graphics_loss.take() { + // eframe can't give a window a new device: the user saves and starts again. + self.graphics_lost = self.app.graphics_lost(&why); + self.app.status(if self.graphics_lost { + "The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft" + } else { + "The graphics device was lost: save your documents and restart VectorCraft" + }); + } + if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) { + // The window can't show the Save Changes question: it closes, and the next launch + // offers the changes back. + 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")] + if !self.app.file_dialog_open() { + let files = open_documents::take(); + if !files.is_empty() { + bring_window_forward(ctx); + } + open_files(&mut self.app, files); + } + self.app.tick(ctx); + if self.pending_maximize { + if let Some(w) = &self.winit_window { + self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries); + } else { + self.pending_maximize = false; + } + } + let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized()); + window::track(ctx, &mut self.app.ui.window, host_maximized); + if self.app.ui.status == "quit" { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, +/// before eframe tears the window down (#661). AccessKit then gives the window's content view back +/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the +/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. +/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics +/// failure then starts the app again on another adapter (#651). +#[allow(unused_variables)] +fn end_before_teardown(frames: u64) { + #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] + if frames >= gpu::STARTUP_FRAMES { + log::info!("quitting"); + log::logger().flush(); + std::process::exit(0); + } +} + +/// 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))] +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 +/// 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 { + if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) { + eprintln!("vectorcraft: {f}: {e}"); + } + } +} + +/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as +/// typed). winit's IME toggle only clears its own copy, so the IME would type it again. +#[cfg(target_os = "macos")] +fn discard_marked_text() { + if let Some(mtm) = objc2::MainThreadMarker::new() + && let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm) + { + ic.discardMarkedText(); + } +} diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index e163ffb95..508ab0daf 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -289,391 +289,4 @@ fn serve_lines_with( } #[cfg(test)] -mod tests { - use super::*; - - fn short_timeouts() -> ConnectionTimeouts { - ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) } - } - - fn socket_fixture( - timeouts: ConnectionTimeouts, - handle: impl FnMut(String, Value) -> Option + Send + 'static, - ) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let (finished, done) = channel(); - let worker = std::thread::spawn(move || { - let (stream, _) = listener.accept().unwrap(); - socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); - serve_socket(stream, timeouts, handle); - finished.send(()).unwrap(); - }); - let client = TcpStream::connect(addr).unwrap(); - socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap(); - client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); - (client, done, worker) - } - - fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) { - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); - // Always unblock the old implementation too, so a failed regression leaves no worker behind. - let _ = client.shutdown(std::net::Shutdown::Both); - drop(client); - worker.join().unwrap(); - assert!(stopped, "the server retained a stalled connection beyond its transport deadline"); - } - - #[test] - fn idle_control_socket_releases_its_worker() { - let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); - expect_socket_deadline(client, done, worker); - } - - /// An idle connection is closed without a line: a client that sends its next request late - /// must not find an error waiting where its reply should be. - #[test] - fn idle_expiry_closes_without_writing_a_line() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); - let mut got = Vec::new(); - client.read_to_end(&mut got).unwrap(); - assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection"); - expect_socket_deadline(client, done, worker); - } - - /// A line that stalls is refused in words a client's author can act on, not the system's. - #[test] - fn stalled_control_line_is_refused_with_the_reason() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); - client.write_all(b"{\"method\":").unwrap(); - let mut line = String::new(); - BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); - assert_eq!( - serde_json::from_str::(&line).unwrap(), - json!({"ok": false, "error": "request line not completed in time; closing the connection"}) - ); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn partial_control_line_releases_its_worker() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); - client.write_all(b"{\"method\":").unwrap(); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn trickled_control_line_has_an_absolute_deadline() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); - client.write_all(b"{").unwrap(); - let mut writer = client.try_clone().unwrap(); - let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let writing = Arc::clone(&stop); - let trickle = std::thread::spawn(move || { - while !writing.load(Ordering::SeqCst) { - if writer.write_all(b" ").is_err() { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - }); - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); - stop.store(true, Ordering::SeqCst); - // Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well. - let _ = client.shutdown(std::net::Shutdown::Both); - trickle.join().unwrap(); - worker.join().unwrap(); - assert!(stopped, "trickled bytes restarted the line's deadline"); - } - - #[test] - fn a_nonreading_control_peer_cannot_retain_the_writer() { - let payload = "x".repeat(2 * 1024 * 1024); - let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload}))); - client.write_all(b"{\"method\":\"large\"}\n").unwrap(); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn persistent_control_requests_get_fresh_transport_deadlines() { - let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() }; - let (mut client, done, worker) = socket_fixture(timeouts, |_, _| { - // Application processing is not transport time; begin the reply budget afterwards. - std::thread::sleep(Duration::from_millis(200)); - Some(json!({"ok": true})) - }); - let mut reader = BufReader::new(client.try_clone().unwrap()); - for id in [1, 2] { - // The client sends each line in one write. `writeln!` on the socket sends the JSON in many - // small writes, and when the machine is busy, a pause of more than the 120 ms line deadline - // between two of them closes the connection. - client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap(); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); - } - client.shutdown(std::net::Shutdown::Write).unwrap(); - done.recv_timeout(Duration::from_secs(3)).unwrap(); - worker.join().unwrap(); - } - - #[test] - fn buffered_partial_control_line_also_expires() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| Some(json!({"ok": true}))); - client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap(); - let mut line = String::new(); - BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); - assert_eq!(serde_json::from_str::(&line).unwrap()["id"], 1); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn timed_out_worker_returns_its_connection_slot() { - let open = Arc::new(AtomicUsize::new(0)); - let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect(); - let slot = acquire_slot(&open).unwrap(); - assert!(acquire_slot(&open).is_none()); - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); - let (stream, _) = listener.accept().unwrap(); - let (tx, _rx) = channel(); - let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap(); - let start = std::time::Instant::now(); - while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) { - std::thread::sleep(Duration::from_millis(10)); - } - let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS; - // Clean up an old implementation that never returned its slot as well. - let _ = client.shutdown(std::net::Shutdown::Both); - worker.join().unwrap(); - assert!(released, "the deadline did not free capacity for the next client"); - let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot"); - drop(replacement); - drop(held); - assert_eq!(open.load(Ordering::SeqCst), 0); - } - - fn run(input: &[u8]) -> (Vec, Vec) { - let mut out = Vec::new(); - let mut calls = Vec::new(); - serve_lines(input, &mut out, |m, _p| { - calls.push(m); - Some(json!({"ok": true, "result": null})) - }); - let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()).collect(); - (replies, calls) - } - - /// Counts the `write` calls made on it and keeps the bytes they write. - #[derive(Default)] - struct Writes { - calls: usize, - bytes: Vec, - } - - impl Write for Writes { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.calls += 1; - self.bytes.extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - /// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200. - fn inspect_reply(n: u64) -> Value { - let paths: Vec = (0..n) - .map(|k| { - let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0); - json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261", - "id": k + 2, "kind": "Path", "locked": false, "name": "", "stroke": "None", "strokeWidth": 0.0, "visible": true}) - }) - .collect(); - json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}}) - } - - /// #1725: a request its caller gave up before the app took it never runs; one the app took - /// first can't be withdrawn, and the timeout says so. - #[test] - fn a_timed_out_request_is_withdrawn_unless_it_started() { - let (waiting, _rx) = ControlRequest::new("engine.execute", json!({})); - assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run")); - assert!(!waiting.claim(), "the app skips a withdrawn request"); - let (running, _rx) = ControlRequest::new("engine.execute", json!({})); - assert!(running.claim()); - assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running")); - assert!(!running.claim(), "a request is taken once"); - } - - #[test] - fn requests_are_answered_in_order_with_ids() { - let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); - assert_eq!(calls, ["a", "b", "c"]); - assert_eq!(r.len(), 3); - assert_eq!(r[0]["id"], 1); - assert_eq!(r[1]["id"], 2); - assert_eq!(r[2]["id"], Value::Null); - } - - #[test] - fn replies_are_written_in_few_large_writes() { - for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] { - let mut out = Writes::default(); - let mut once = Some(reply.clone()); - serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take()); - let mut want = reply; - want["id"] = json!(1); - let text = String::from_utf8(out.bytes).unwrap(); - // The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB. - assert_eq!(text, format!("{want}\n")); - assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len()); - } - // A refusal is one write as well. - let mut out = Writes::default(); - serve_lines(&b"hello\n"[..], &mut out, |_, _| None); - assert_eq!(out.calls, 1); - } - - #[test] - fn http_request_closes_before_the_body_runs() { - // A browser's cross-origin `fetch` POST: the request line is rejected and the - // connection closed, so the JSON body is never executed. - let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\ - {\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n"; - let (r, calls) = run(http); - assert!(calls.is_empty(), "nothing may run: {calls:?}"); - assert_eq!(r.len(), 1); - assert_eq!(r[0]["ok"], false); - assert!(r[0]["error"].as_str().unwrap().contains("closing the connection")); - } - - #[test] - fn non_request_json_closes_the_connection() { - for junk in [ - &b"[1,2]\n{\"method\":\"a\"}\n"[..], - b"{\"id\":1}\n{\"method\":\"a\"}\n", - b"{\"method\":5}\n{\"method\":\"a\"}\n", - b"\xff\xfe\n{\"method\":\"a\"}\n", - ] { - let (r, calls) = run(junk); - assert!(calls.is_empty(), "{calls:?}"); - assert_eq!(r.len(), 1); - assert_eq!(r[0]["ok"], false); - } - } - - #[test] - fn a_request_before_junk_still_runs() { - let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); - assert_eq!(calls, ["a"]); - assert_eq!(r.len(), 2); - assert_eq!(r[1]["ok"], false); - } - - #[test] - fn overlong_line_is_refused() { - // One byte over the limit, then a valid request: neither runs. - let mut input = vec![b'x'; MAX_LINE + 1]; - input.extend_from_slice(b"\n{\"method\":\"a\"}\n"); - let (r, calls) = run(&input); - assert!(calls.is_empty(), "{calls:?}"); - assert_eq!(r.len(), 1); - assert!(r[0]["error"].as_str().unwrap().contains("longer than")); - } - - #[test] - fn line_at_the_limit_is_accepted() { - let mut buf = Vec::new(); - let line = vec![b'a'; 10]; - let mut input = line.clone(); - input.push(b'\n'); - assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true)); - assert_eq!(buf, line); - assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); - } - - #[test] - fn tcp_connection_is_closed_after_junk() { - // End to end over a real loopback socket (the transport the app uses). - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let server = std::thread::spawn(move || { - let (s, _) = listener.accept().unwrap(); - let read = s.try_clone().unwrap(); - serve_lines(read, s, |_, _| Some(json!({"ok": true}))); - }); - let mut c = TcpStream::connect(addr).unwrap(); - c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); - let mut text = String::new(); - c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection - server.join().unwrap(); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 2, "{text}"); - assert!(lines[0].contains("\"id\":7")); - assert!(lines[1].contains("closing the connection")); - } - - #[test] - fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let reply = inspect_reply(10_000); - let mut want = reply.clone(); - want["id"] = json!(1); - let want = format!("{want}\n"); - std::thread::spawn(move || { - let (s, _) = listener.accept().unwrap(); - let read = s.try_clone().unwrap(); - serve_lines(read, s, |_, _| Some(reply.clone())); - }); - // The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does. - let (tx, rx) = channel(); - std::thread::spawn(move || { - let mut c = TcpStream::connect(addr).unwrap(); - let mut read = BufReader::new(c.try_clone().unwrap()); - let mut lines = vec![]; - for _ in 0..10 { - c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap(); - let mut line = String::new(); - read.read_line(&mut line).unwrap(); - lines.push(line); - } - tx.send(lines).ok(); - }); - let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s")); - assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes"); - } - - #[test] - fn extra_connections_are_refused() { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let (tx, _rx) = channel::(); - std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); - // Idle connections are accepted in order and held open. - let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); - let mut extra = TcpStream::connect(addr).unwrap(); - extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - let mut line = String::new(); - BufReader::new(&mut extra).read_line(&mut line).unwrap(); - assert!(line.contains("too many control connections"), "{line}"); - drop(held); - } - - // A test process is not an app that App Nap applies to, so this checks the activity the - // channel asks for; the throttling itself shows only in a running app with its window hidden. - #[cfg(target_os = "macos")] - #[test] - fn the_control_channel_keeps_the_app_at_full_priority_on_macos() { - use objc2_foundation::NSActivityOptions; - let options = activity_options(); - assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20"); - assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep)); - assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake"); - keep_answering(); - } -} +mod tests; diff --git a/apps/vectorcraft/src/control_server/tests.rs b/apps/vectorcraft/src/control_server/tests.rs new file mode 100644 index 000000000..7fa3f27e7 --- /dev/null +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -0,0 +1,394 @@ +use super::*; + +fn short_timeouts() -> ConnectionTimeouts { + ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) } +} + +/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a +/// busy machine either thread can pause for more than 150 ms between connecting and sending, or +/// between starting a reply and writing it, and a short budget then closes the connection with no +/// reply. +fn line_timeouts() -> ConnectionTimeouts { + ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() } +} + +fn socket_fixture( + timeouts: ConnectionTimeouts, + handle: impl FnMut(String, Value) -> Option + Send + 'static, +) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (finished, done) = channel(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); + serve_socket(stream, timeouts, handle); + finished.send(()).unwrap(); + }); + let client = TcpStream::connect(addr).unwrap(); + socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + (client, done, worker) +} + +fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) { + let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + // Always unblock the old implementation too, so a failed regression leaves no worker behind. + let _ = client.shutdown(std::net::Shutdown::Both); + drop(client); + worker.join().unwrap(); + assert!(stopped, "the server retained a stalled connection beyond its transport deadline"); +} + +#[test] +fn idle_control_socket_releases_its_worker() { + let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); + expect_socket_deadline(client, done, worker); +} + +/// An idle connection is closed without a line: a client that sends its next request late +/// must not find an error waiting where its reply should be. +#[test] +fn idle_expiry_closes_without_writing_a_line() { + let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); + let mut got = Vec::new(); + client.read_to_end(&mut got).unwrap(); + assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection"); + expect_socket_deadline(client, done, worker); +} + +/// A line that stalls is refused in words a client's author can act on, not the system's. +#[test] +fn stalled_control_line_is_refused_with_the_reason() { + let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| panic!("incomplete requests must not execute")); + client.write_all(b"{\"method\":").unwrap(); + let mut line = String::new(); + BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + json!({"ok": false, "error": "request line not completed in time; closing the connection"}) + ); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn partial_control_line_releases_its_worker() { + let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); + client.write_all(b"{\"method\":").unwrap(); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn trickled_control_line_has_an_absolute_deadline() { + let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); + client.write_all(b"{").unwrap(); + let mut writer = client.try_clone().unwrap(); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let writing = Arc::clone(&stop); + let trickle = std::thread::spawn(move || { + while !writing.load(Ordering::SeqCst) { + if writer.write_all(b" ").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + stop.store(true, Ordering::SeqCst); + // Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well. + let _ = client.shutdown(std::net::Shutdown::Both); + trickle.join().unwrap(); + worker.join().unwrap(); + assert!(stopped, "trickled bytes restarted the line's deadline"); +} + +#[test] +fn a_nonreading_control_peer_cannot_retain_the_writer() { + let payload = "x".repeat(2 * 1024 * 1024); + let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload}))); + client.write_all(b"{\"method\":\"large\"}\n").unwrap(); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn persistent_control_requests_get_fresh_transport_deadlines() { + let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() }; + let (mut client, done, worker) = socket_fixture(timeouts, |_, _| { + // Application processing is not transport time; begin the reply budget afterwards. + std::thread::sleep(Duration::from_millis(200)); + Some(json!({"ok": true})) + }); + let mut reader = BufReader::new(client.try_clone().unwrap()); + for id in [1, 2] { + // The client sends each line in one write. `writeln!` on the socket sends the JSON in many + // small writes, and when the machine is busy, a pause of more than the 120 ms line deadline + // between two of them closes the connection. + client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap(); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + done.recv_timeout(Duration::from_secs(3)).unwrap(); + worker.join().unwrap(); +} + +#[test] +fn buffered_partial_control_line_also_expires() { + let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| Some(json!({"ok": true}))); + client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap(); + let mut line = String::new(); + BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], 1); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn timed_out_worker_returns_its_connection_slot() { + let open = Arc::new(AtomicUsize::new(0)); + let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect(); + let slot = acquire_slot(&open).unwrap(); + assert!(acquire_slot(&open).is_none()); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (stream, _) = listener.accept().unwrap(); + let (tx, _rx) = channel(); + let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap(); + let start = std::time::Instant::now(); + while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) { + std::thread::sleep(Duration::from_millis(10)); + } + let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS; + // Clean up an old implementation that never returned its slot as well. + let _ = client.shutdown(std::net::Shutdown::Both); + worker.join().unwrap(); + assert!(released, "the deadline did not free capacity for the next client"); + let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot"); + drop(replacement); + drop(held); + assert_eq!(open.load(Ordering::SeqCst), 0); +} + +fn run(input: &[u8]) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut calls = Vec::new(); + serve_lines(input, &mut out, |m, _p| { + calls.push(m); + Some(json!({"ok": true, "result": null})) + }); + let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()).collect(); + (replies, calls) +} + +/// Counts the `write` calls made on it and keeps the bytes they write. +#[derive(Default)] +struct Writes { + calls: usize, + bytes: Vec, +} + +impl Write for Writes { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.calls += 1; + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200. +fn inspect_reply(n: u64) -> Value { + let paths: Vec = (0..n) + .map(|k| { + let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0); + json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261", + "id": k + 2, "kind": "Path", "locked": false, "name": "", "stroke": "None", "strokeWidth": 0.0, "visible": true}) + }) + .collect(); + json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}}) +} + +/// #1725: a request its caller gave up before the app took it never runs; one the app took +/// first can't be withdrawn, and the timeout says so. +#[test] +fn a_timed_out_request_is_withdrawn_unless_it_started() { + let (waiting, _rx) = ControlRequest::new("engine.execute", json!({})); + assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run")); + assert!(!waiting.claim(), "the app skips a withdrawn request"); + let (running, _rx) = ControlRequest::new("engine.execute", json!({})); + assert!(running.claim()); + assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running")); + assert!(!running.claim(), "a request is taken once"); +} + +#[test] +fn requests_are_answered_in_order_with_ids() { + let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); + assert_eq!(calls, ["a", "b", "c"]); + assert_eq!(r.len(), 3); + assert_eq!(r[0]["id"], 1); + assert_eq!(r[1]["id"], 2); + assert_eq!(r[2]["id"], Value::Null); +} + +#[test] +fn replies_are_written_in_few_large_writes() { + for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] { + let mut out = Writes::default(); + let mut once = Some(reply.clone()); + serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take()); + let mut want = reply; + want["id"] = json!(1); + let text = String::from_utf8(out.bytes).unwrap(); + // The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB. + assert_eq!(text, format!("{want}\n")); + assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len()); + } + // A refusal is one write as well. + let mut out = Writes::default(); + serve_lines(&b"hello\n"[..], &mut out, |_, _| None); + assert_eq!(out.calls, 1); +} + +#[test] +fn http_request_closes_before_the_body_runs() { + // A browser's cross-origin `fetch` POST: the request line is rejected and the + // connection closed, so the JSON body is never executed. + let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n"; + let (r, calls) = run(http); + assert!(calls.is_empty(), "nothing may run: {calls:?}"); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["ok"], false); + assert!(r[0]["error"].as_str().unwrap().contains("closing the connection")); +} + +#[test] +fn non_request_json_closes_the_connection() { + for junk in [ + &b"[1,2]\n{\"method\":\"a\"}\n"[..], + b"{\"id\":1}\n{\"method\":\"a\"}\n", + b"{\"method\":5}\n{\"method\":\"a\"}\n", + b"\xff\xfe\n{\"method\":\"a\"}\n", + ] { + let (r, calls) = run(junk); + assert!(calls.is_empty(), "{calls:?}"); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["ok"], false); + } +} + +#[test] +fn a_request_before_junk_still_runs() { + let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); + assert_eq!(calls, ["a"]); + assert_eq!(r.len(), 2); + assert_eq!(r[1]["ok"], false); +} + +#[test] +fn overlong_line_is_refused() { + // One byte over the limit, then a valid request: neither runs. + let mut input = vec![b'x'; MAX_LINE + 1]; + input.extend_from_slice(b"\n{\"method\":\"a\"}\n"); + let (r, calls) = run(&input); + assert!(calls.is_empty(), "{calls:?}"); + assert_eq!(r.len(), 1); + assert!(r[0]["error"].as_str().unwrap().contains("longer than")); +} + +#[test] +fn line_at_the_limit_is_accepted() { + let mut buf = Vec::new(); + let line = vec![b'a'; 10]; + let mut input = line.clone(); + input.push(b'\n'); + assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true)); + assert_eq!(buf, line); + assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); +} + +#[test] +fn tcp_connection_is_closed_after_junk() { + // End to end over a real loopback socket (the transport the app uses). + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + let read = s.try_clone().unwrap(); + serve_lines(read, s, |_, _| Some(json!({"ok": true}))); + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); + let mut text = String::new(); + c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection + server.join().unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].contains("\"id\":7")); + assert!(lines[1].contains("closing the connection")); +} + +#[test] +fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let reply = inspect_reply(10_000); + let mut want = reply.clone(); + want["id"] = json!(1); + let want = format!("{want}\n"); + std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + let read = s.try_clone().unwrap(); + serve_lines(read, s, |_, _| Some(reply.clone())); + }); + // The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does. + let (tx, rx) = channel(); + std::thread::spawn(move || { + let mut c = TcpStream::connect(addr).unwrap(); + let mut read = BufReader::new(c.try_clone().unwrap()); + let mut lines = vec![]; + for _ in 0..10 { + c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap(); + let mut line = String::new(); + read.read_line(&mut line).unwrap(); + lines.push(line); + } + tx.send(lines).ok(); + }); + let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s")); + assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes"); +} + +#[test] +fn extra_connections_are_refused() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, _rx) = channel::(); + std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); + // Idle connections are accepted in order and held open. + let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); + let mut extra = TcpStream::connect(addr).unwrap(); + extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut line = String::new(); + BufReader::new(&mut extra).read_line(&mut line).unwrap(); + assert!(line.contains("too many control connections"), "{line}"); + drop(held); +} + +// A test process is not an app that App Nap applies to, so this checks the activity the +// channel asks for; the throttling itself shows only in a running app with its window hidden. +#[cfg(target_os = "macos")] +#[test] +fn the_control_channel_keeps_the_app_at_full_priority_on_macos() { + use objc2_foundation::NSActivityOptions; + let options = activity_options(); + assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20"); + assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep)); + assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake"); + keep_answering(); +} diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 93ca92068..f683f03b4 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -878,753 +878,4 @@ fn restart(skip: &str) -> std::io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { - Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } - } - - /// #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. - #[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); - } - - fn names(c: &[Candidate], order: &[usize]) -> Vec { - order.iter().map(|&i| c[i].name.clone()).collect() - } - - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - const WARP: (u32, u32) = (0x1414, 0x008c); - - fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { - Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } - } - - fn monitor(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: false } - } - - fn primary(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: true } - } - - /// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a - /// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and - /// OpenGL (which reports no device id). - fn ryzen_desktop() -> Vec { - vec![ - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), - pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), - ] - } - - /// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to - /// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now - /// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches. - #[test] - fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { - let c = ryzen_desktop(); - for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - } - // Without the display information the order is power saving's, as before. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]); - // After the NVIDIA adapters failed, the integrated GPU is next. - let skip = vec![c[2].key.clone(), c[0].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]); - } - - /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the - /// discrete one. Power saving and the display agree, in either enumeration order. - #[test] - fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { - let c = ryzen_desktop(); - for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); - } - // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the - // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one - // comes after both. - let linux = vec![ - pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), - pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), - ]; - let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; - assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]); - } - - /// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - /// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the - /// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the - /// compositor draws with comes first; without it, the panel's, as before. - #[test] - fn the_gpu_the_compositor_draws_with_comes_before_the_panels() { - const RTX_3050: (u32, u32) = (0x10de, 0x25ac); - const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8); - let mut c = vec![ - pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050), - pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL), - pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)), - ]; - c[2].presents = false; - let panel = [primary(INTEL_RPL)]; - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]); - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]); - // The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU. - assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]); - // `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on. - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]); - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]); - } - - /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so - /// its GPU draws, whichever kind it is. - #[test] - fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { - let c = ryzen_desktop(); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]); - // The displays only matter among adapters that can show the window; a GPU driving no - // display still loses to one that does. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]); - } - - /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, - /// Metal's zeros) change nothing: power saving's order, the old behaviour. - #[test] - fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { - let c = ryzen_desktop(); - let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); - for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}"); - } - assert_eq!( - names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0], - "NVIDIA GeForce RTX 3090" - ); - // The #502 machine's adapters report no ids in these tests: the primary display never matches. - let c = issue_502(); - assert_eq!( - adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]), - adapter_order(&c, PowerPreference::None, &[], None, None, &[]) - ); - assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty()); - } - - /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. - #[test] - fn a_named_adapter_comes_before_the_one_driving_the_display() { - let mut c = ryzen_desktop(); - let displays = [primary(NVIDIA_3090)]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); - c[2].presents = false; - c[0].presents = false; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]); - } - - /// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic - /// looks at the displays. - #[test] - fn only_automatic_considers_which_gpu_drives_the_display() { - assert!(automatic(None, None)); - assert!(automatic(Some("automatic"), None)); - assert!(automatic(Some("powerSaving"), None)); - assert!(!automatic(Some("lowPower"), None)); - assert!(!automatic(Some("highPerformance"), None)); - assert!(!automatic(None, Some(PowerPreference::None))); - assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower))); - assert!(preferred_displays(Some("lowPower"), None).is_empty()); - assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty()); - // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). - assert_eq!(preferred_displays(None, None), display_gpus()); - assert_eq!(preferred_compositor(Some("lowPower"), None), None); - assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None); - assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu()); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(pci_ids("VEN_&DEV_"), None); - assert_eq!(pci_ids(""), None); - } - - /// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary - /// display, and none is listed twice. (A headless CI runner may list none.) - #[cfg(windows)] - #[test] - fn windows_lists_each_gpu_with_a_monitor_once() { - let gpus = windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert_ne!(g.pci.0, 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!is_internal_panel(name), "{name}"); - } - } - - /// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port - /// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without - /// PCI ids. Each GPU is listed once, the one with the panel as primary. - #[test] - fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card0", AMD_IGPU)?; - card("card1", NVIDIA_3090)?; - std::fs::create_dir_all(dir.join("card2"))?; - connector("card0-eDP-1", "connected")?; - connector("card0-HDMI-A-1", "disconnected")?; - connector("card1-DP-1", "connected")?; - connector("card1-DP-2", "connected")?; - connector("card2-Virtual-1", "connected")?; - std::fs::write(dir.join("renderD128"), "")?; - let mut gpus = linux_display_gpus(&dir); - gpus.sort_by_key(|g| g.pci); - assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); - assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); - std::fs::remove_dir_all(&dir) - } - - #[test] - fn display_gpus_are_logged_as_pci_ids() { - assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); - assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); - let mut gpus = vec![]; - add_display_gpu(&mut gpus, NVIDIA_3090, false); - add_display_gpu(&mut gpus, NVIDIA_3090, true); - add_display_gpu(&mut gpus, AMD_IGPU, false); - assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); - } - - /// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's - /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software - /// renderer, and OpenGL. - fn issue_502() -> Vec { - vec![ - gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), - gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), - ] - } - - #[test] - fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { - let c = issue_502(); - let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]); - let first = &c[order[0]].name; - if cfg!(any(windows, target_os = "macos")) { - assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - } else { - // #502: the integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. - assert_eq!(first, "NVIDIA GeForce RTX 5070"); - } - } - - #[test] - fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { - let c = issue_502(); - let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[])); - assert_eq!( - order(PowerPreference::None), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::LowPower), - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::HighPerformance), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - /// A restart leaves out the adapter the window failed on, so Power Saving on the machine of - /// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try. - /// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still - /// differ, so when the integrated GPU fails, the restart renders on the discrete one. - #[test] - fn gpus_without_pci_ids_are_told_apart_by_name() { - let mac = |name: &str, device_type| Candidate { - key: key(Backend::Metal, 0, 0, name), - name: name.into(), - backend: Backend::Metal, - device_type, - pci: (0, 0), - presents: true, - }; - let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; - assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); - let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); - assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); - let skip = parse_skip(&c[first[0]].key); - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]); - // A comma in a name can't split the list. - assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); - } - - #[test] - fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { - let s = Duration::from_secs; - // The second frame began: the first was presented. - assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); - // Still drawing (or presenting) the first frame: waited for, then given up on (#964). - for frames in [0, 1] { - assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); - assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); - } - // A window minimized at the start may not draw again until restored. - assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); - // A hung adapter is left out once, like one that failed. - let skip = vec!["Dx12:10de:28a0".to_string()]; - assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); - assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); - } - - #[test] - fn a_restart_tries_the_next_adapter() { - let c = issue_502(); - let mut skip = vec![]; - let mut tried = vec![]; - while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() { - tried.push(c[i].name.clone()); - skip.push(c[i].key.clone()); - } - assert_eq!( - tried, - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - #[test] - fn adapters_that_cannot_present_to_the_window_are_never_tried() { - let mut c = issue_502(); - c[0].presents = false; - c[3].presents = false; - for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { - let order = adapter_order(&c, power, &[], None, None, &[]); - assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); - assert_eq!(order.len(), 2); - } - for p in &mut c { - p.presents = false; - } - assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty()); - assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty()); - } - - #[test] - fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { - let c = issue_502(); - let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]); - assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - assert_eq!(order.len(), 4); - // Any case; every match comes before the rest, natively first. - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]); - assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); - // An empty name or one nothing matches changes nothing. - for named in ["", "no such gpu"] { - assert_eq!( - adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]), - adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]) - ); - } - // A named adapter that failed is left out like any other. - let skip = vec![c[1].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0); - } - - /// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the - /// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545). - #[test] - fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { - let c = vec![ - gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - ]; - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); - assert_eq!(order, [3, 1, 2, 0, 4]); - assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]); - } - - /// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black. - /// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` - /// or a restart leaving DX12 out still reach it. - #[test] - fn a_windows_gpu_renders_through_dx12_before_vulkan() { - let c = vec![ - gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), - ]; - for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}"); - } - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); - } - - #[test] - fn windows_never_loads_vulkan_drivers_unless_asked() { - let all = Backends::all(); - let start = backends(all, false); - if cfg!(windows) { - assert_eq!(start, Backends::DX12 | Backends::GL, "#806"); - } else { - assert_eq!(start, all); - } - assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it"); - } - - #[test] - fn power_preference_follows_the_environment_then_the_preference() { - assert_eq!(power_preference(None, None), AUTOMATIC); - assert_eq!(power_preference(Some("automatic"), None), AUTOMATIC); - assert_eq!(power_preference(Some("lowPower"), None), PowerPreference::LowPower); - assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance); - // 0.5.0's default, and values this version doesn't know (a newer or damaged file). - for old in ["powerSaving", "turbo", ""] { - assert_eq!(power_preference(Some(old), None), AUTOMATIC, "{old}"); - } - // WGPU_POWER_PREF wins over the preference, either way. - assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance); - assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower); - assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::None)), PowerPreference::None); - assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); - } - - /// The preference the engine saves is the one the app reads back before the window opens. - #[test] - fn the_saved_engine_preference_is_found() { - let mut prefs = vectorcraft_engine::Prefs::default(); - let saved = prefs.to_json(); - assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("automatic")); - for (value, power) in [("lowPower", PowerPreference::LowPower), ("highPerformance", PowerPreference::HighPerformance)] { - prefs.gpu_preference = value.into(); - let saved = prefs.to_json(); - assert_eq!(power_preference(saved.get("gpuPreference").and_then(serde_json::Value::as_str), None), power); - } - // Every choice Preferences offer means something here. - for (value, _) in vectorcraft_engine::cmd::prefscmds::GPU_PREFERENCES { - assert!(["automatic", "lowPower", "highPerformance"].contains(value), "{value}"); - } - } - - #[test] - fn skipped_adapters_parse_from_a_comma_separated_list() { - assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); - assert!(parse_skip("").is_empty()); - assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); - assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); - } - - #[test] - fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { - for file in [ - "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", - r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", - "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", - "naga-30.0.0/src/back/spv/writer.rs", - ] { - assert!(in_graphics_stack(file), "{file}"); - } - for file in [ - "apps/vectorcraft/src/main.rs", - "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs", - "egui-0.36.2/src/context.rs", - "winit-0.30.12/src/x.rs", - ] { - assert!(!in_graphics_stack(file), "{file}"); - } - } - - #[test] - fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { - let amd = Some("Vulkan:1002:164e"); - assert_eq!(restart_without(true, 0, amd, &[]), amd); - assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); - // After start-up the adapter has shown frames; a lost window is something else. - assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); - // A bug outside the graphics, or no adapter picked: nothing another adapter would change. - assert_eq!(restart_without(false, 0, amd, &[]), None); - assert_eq!(restart_without(true, 0, None, &[]), None); - // Never the same adapter twice, so restarts end. - assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); - } - - #[test] - fn a_normal_exit_or_another_failure_does_not_restart() { - let startup = Startup::default(); - assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); - // No adapter was picked (none can show the window): the error is returned, nothing restarts. - let none = - eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); - assert_eq!( - finish(Ok(Err(none)), &startup), - Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) - ); - let _ = startup.adapter.set("Vulkan:1002:164e".into()); - GRAPHICS_PANIC.store(false, Ordering::Relaxed); - assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); - } - - /// A log folder of its own for each test. - fn start_dir(tag: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - d - } - - /// What a start in `dir` leaves out; it then ends without crashing. - fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut { - let s = Startup::default(); - let left_out = s.track(dir, None, explicit); - s.ended(); - left_out - } - - /// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and - /// Windows' software adapter. - fn issue_2243() -> Vec { - vec![ - pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)), - pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)), - pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)), - ] - } - - /// #2243: the driver ends the process after the adapter was picked, before the first frame. - /// The next start leaves that adapter out and draws through OpenGL, and so do the starts after - /// it, without a status-bar note. - #[test] - fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { - let dir = start_dir("crash"); - let c = issue_2243(); - let first = Startup::default(); - assert_eq!(first.track(&dir, None, false), LeftOut::default()); - first.picked(&c[0].key, &c[0].name, true); - // The process ends here: its file is closed and unlocked, and the start is still recorded. - drop(first); - - let second = Startup::default(); - let left_out = second.track(&dir, Some("automatic"), false); - assert_eq!(left_out.adapters, ["Dx12:8086:3184"]); - assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() })); - assert!(!left_out.opengl_only); - assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL); - let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters); - assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter"); - second.picked(&c[2].key, &c[2].name, true); - second.presented(); - - let third = Startup::default(); - let left_out = third.track(&dir, None, false); - assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept"); - assert_eq!(left_out.crash, None, "the note is shown once"); - third.ended(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A start whose first frame reached the screen, or that ended without crashing (a normal exit, - /// an error, a restart on another adapter), leaves nothing out of the next. - #[test] - fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { - let dir = start_dir("shown"); - let c = issue_2243(); - for end in [Startup::presented as fn(&Startup), Startup::ended] { - let first = Startup::default(); - first.track(&dir, None, false); - first.picked(&c[0].key, &c[0].name, true); - end(&first); - drop(first); - assert_eq!(next_start(&dir, false), LeftOut::default()); - } - // So does a start that ended before it picked an adapter. - let first = Startup::default(); - first.track(&dir, None, false); - assert!(finish(Ok(Ok(())), &first).is_ok()); - drop(first); - assert_eq!(next_start(&dir, false), LeftOut::default()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The process ended while the adapters were being listed (wgpu's DX12 backend creates a - /// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on - /// macOS, where wgpu has none. - #[test] - fn a_crash_while_listing_lists_opengl_alone() { - let dir = start_dir("listing"); - let first = Startup::default(); - first.track(&dir, None, false); - drop(first); - let second = Startup::default(); - let left_out = second.track(&dir, None, false); - assert!(left_out.adapters.is_empty()); - if cfg!(target_os = "macos") { - assert_eq!(left_out, LeftOut::default()); - } else { - assert_eq!(left_out.crash, Some(Crash::Listing)); - assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL); - assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL); - assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to"); - } - // Listing OpenGL alone found nothing that can show the window: the next start lists every - // backend again. - second.none_left(); - second.ended(); - assert_eq!(next_start(&dir, false), LeftOut::default()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that - /// start shows its first frame on the adapter that crashed, the adapter counts again. - #[test] - fn wgpu_backend_or_adapter_name_ignores_the_crashes() { - let dir = start_dir("explicit"); - let c = issue_2243(); - let first = Startup::default(); - first.track(&dir, None, false); - first.picked(&c[0].key, &c[0].name, true); - drop(first); - assert_eq!(next_start(&dir, true), LeftOut::default()); - assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them"); - - let explicit = Startup::default(); - explicit.track(&dir, None, true); - explicit.picked(&c[0].key, &c[0].name, true); - explicit.presented(); - assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A damaged record, a file that can't be opened and one another starting copy of the app holds - /// are ignored: the start goes on with nothing left out. - #[test] - fn a_damaged_or_unreadable_start_file_is_ignored() { - let dir = start_dir("damaged"); - std::fs::create_dir_all(&dir).unwrap(); - let damaged: [&[u8]; 9] = [ - b"", - b"{", - b"[]", - b"null", - b"{\"version\":1}", - b"{\"starting\":{}}", - b"\xef\xbb\xbf\0", - b"\xff\xfe{", - &[b' '; START_FILE_LIMIT as usize + 1], - ]; - for text in damaged { - std::fs::write(dir.join(START_FILE), text).unwrap(); - assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)])); - } - - // Another copy of the app is starting: this start neither reads nor writes the file. - let c = issue_2243(); - let starting = Startup::default(); - starting.track(&dir, None, false); - starting.picked(&c[0].key, &c[0].name, true); - assert_eq!(next_start(&dir, false), LeftOut::default()); - drop(starting); - assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]); - - // A folder in the file's place. - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join(START_FILE)).unwrap(); - assert_eq!(next_start(&dir, false), LeftOut::default()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn crashes_are_forgotten_in_another_version_or_under_another_preference() { - let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true }; - let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic"); - assert_eq!(crash, None); - record.starting = Some(starting("Dx12:8086:3184")); - let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic"); - assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() })); - assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]); - // Written and read back. - assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone())); - assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty()); - assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty()); - assert_eq!(preference_name(None), "automatic"); - assert_eq!(preference_name(Some("powerSaving")), "automatic"); - assert_eq!(preference_name(Some("lowPower")), "lowPower"); - // An adapter is listed once, and the list is bounded. - let mut record = record; - for n in 0..CRASHED_LIMIT + 4 { - for _ in 0..2 { - record.starting = Some(starting(&format!("Dx12:10de:{n:04x}"))); - record = StartRecord::begin(Some(record), "0.9.0", "automatic").0; - } - } - assert_eq!(record.crashed.len(), CRASHED_LIMIT); - assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); - } -} +mod tests; diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs new file mode 100644 index 000000000..906e35fb6 --- /dev/null +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -0,0 +1,744 @@ +use super::*; + +fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { + Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } +} + +/// #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. +#[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); +} + +fn names(c: &[Candidate], order: &[usize]) -> Vec { + order.iter().map(|&i| c[i].name.clone()).collect() +} + +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); +const WARP: (u32, u32) = (0x1414, 0x008c); + +fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { + Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } +} + +fn monitor(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: false } +} + +fn primary(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: true } +} + +/// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a +/// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and +/// OpenGL (which reports no device id). +fn ryzen_desktop() -> Vec { + vec![ + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), + pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), + ] +} + +/// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to +/// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now +/// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches. +#[test] +fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { + let c = ryzen_desktop(); + for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + } + // Without the display information the order is power saving's, as before. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]); + // After the NVIDIA adapters failed, the integrated GPU is next. + let skip = vec![c[2].key.clone(), c[0].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]); +} + +/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the +/// discrete one. Power saving and the display agree, in either enumeration order. +#[test] +fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { + let c = ryzen_desktop(); + for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + } + // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the + // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one + // comes after both. + let linux = vec![ + pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), + pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), + ]; + let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]); +} + +/// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with +/// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the +/// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the +/// compositor draws with comes first; without it, the panel's, as before. +#[test] +fn the_gpu_the_compositor_draws_with_comes_before_the_panels() { + const RTX_3050: (u32, u32) = (0x10de, 0x25ac); + const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8); + let mut c = vec![ + pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050), + pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL), + pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)), + ]; + c[2].presents = false; + let panel = [primary(INTEL_RPL)]; + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]); + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]); + // The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU. + assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]); + // `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on. + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]); + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]); +} + +/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so +/// its GPU draws, whichever kind it is. +#[test] +fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { + let c = ryzen_desktop(); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]); + // The displays only matter among adapters that can show the window; a GPU driving no + // display still loses to one that does. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]); +} + +/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, +/// Metal's zeros) change nothing: power saving's order, the old behaviour. +#[test] +fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { + let c = ryzen_desktop(); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); + for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}"); + } + assert_eq!( + names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0], + "NVIDIA GeForce RTX 3090" + ); + // The #502 machine's adapters report no ids in these tests: the primary display never matches. + let c = issue_502(); + assert_eq!( + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, None, &[]) + ); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty()); +} + +/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. +#[test] +fn a_named_adapter_comes_before_the_one_driving_the_display() { + let mut c = ryzen_desktop(); + let displays = [primary(NVIDIA_3090)]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + c[2].presents = false; + c[0].presents = false; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]); +} + +/// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic +/// looks at the displays. +#[test] +fn only_automatic_considers_which_gpu_drives_the_display() { + assert!(automatic(None, None)); + assert!(automatic(Some("automatic"), None)); + assert!(automatic(Some("powerSaving"), None)); + assert!(!automatic(Some("lowPower"), None)); + assert!(!automatic(Some("highPerformance"), None)); + assert!(!automatic(None, Some(PowerPreference::None))); + assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower))); + assert!(preferred_displays(Some("lowPower"), None).is_empty()); + assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty()); + // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). + assert_eq!(preferred_displays(None, None), display_gpus()); + assert_eq!(preferred_compositor(Some("lowPower"), None), None); + assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None); + assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu()); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(pci_ids("VEN_&DEV_"), None); + assert_eq!(pci_ids(""), None); +} + +/// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary +/// display, and none is listed twice. (A headless CI runner may list none.) +#[cfg(windows)] +#[test] +fn windows_lists_each_gpu_with_a_monitor_once() { + let gpus = windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert_ne!(g.pci.0, 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!is_internal_panel(name), "{name}"); + } +} + +/// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port +/// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without +/// PCI ids. Each GPU is listed once, the one with the panel as primary. +#[test] +fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card0", AMD_IGPU)?; + card("card1", NVIDIA_3090)?; + std::fs::create_dir_all(dir.join("card2"))?; + connector("card0-eDP-1", "connected")?; + connector("card0-HDMI-A-1", "disconnected")?; + connector("card1-DP-1", "connected")?; + connector("card1-DP-2", "connected")?; + connector("card2-Virtual-1", "connected")?; + std::fs::write(dir.join("renderD128"), "")?; + let mut gpus = linux_display_gpus(&dir); + gpus.sort_by_key(|g| g.pci); + assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); + assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); + std::fs::remove_dir_all(&dir) +} + +#[test] +fn display_gpus_are_logged_as_pci_ids() { + assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); + assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); + let mut gpus = vec![]; + add_display_gpu(&mut gpus, NVIDIA_3090, false); + add_display_gpu(&mut gpus, NVIDIA_3090, true); + add_display_gpu(&mut gpus, AMD_IGPU, false); + assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); +} + +/// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's +/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software +/// renderer, and OpenGL. +fn issue_502() -> Vec { + vec![ + gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), + gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), + ] +} + +#[test] +fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { + let c = issue_502(); + let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]); + let first = &c[order[0]].name; + if cfg!(any(windows, target_os = "macos")) { + assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + } else { + // #502: the integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. + assert_eq!(first, "NVIDIA GeForce RTX 5070"); + } +} + +#[test] +fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { + let c = issue_502(); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[])); + assert_eq!( + order(PowerPreference::None), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::LowPower), + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::HighPerformance), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +/// A restart leaves out the adapter the window failed on, so Power Saving on the machine of +/// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try. +/// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still +/// differ, so when the integrated GPU fails, the restart renders on the discrete one. +#[test] +fn gpus_without_pci_ids_are_told_apart_by_name() { + let mac = |name: &str, device_type| Candidate { + key: key(Backend::Metal, 0, 0, name), + name: name.into(), + backend: Backend::Metal, + device_type, + pci: (0, 0), + presents: true, + }; + let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; + assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); + assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); + let skip = parse_skip(&c[first[0]].key); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]); + // A comma in a name can't split the list. + assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); +} + +#[test] +fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { + let s = Duration::from_secs; + // The second frame began: the first was presented. + assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); + // Still drawing (or presenting) the first frame: waited for, then given up on (#964). + for frames in [0, 1] { + assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); + assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); + } + // A window minimized at the start may not draw again until restored. + assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); + // A hung adapter is left out once, like one that failed. + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); +} + +#[test] +fn a_restart_tries_the_next_adapter() { + let c = issue_502(); + let mut skip = vec![]; + let mut tried = vec![]; + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() { + tried.push(c[i].name.clone()); + skip.push(c[i].key.clone()); + } + assert_eq!( + tried, + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +#[test] +fn adapters_that_cannot_present_to_the_window_are_never_tried() { + let mut c = issue_502(); + c[0].presents = false; + c[3].presents = false; + for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { + let order = adapter_order(&c, power, &[], None, None, &[]); + assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); + assert_eq!(order.len(), 2); + } + for p in &mut c { + p.presents = false; + } + assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty()); +} + +#[test] +fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { + let c = issue_502(); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]); + assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + assert_eq!(order.len(), 4); + // Any case; every match comes before the rest, natively first. + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]); + assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); + // An empty name or one nothing matches changes nothing. + for named in ["", "no such gpu"] { + assert_eq!( + adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]), + adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]) + ); + } + // A named adapter that failed is left out like any other. + let skip = vec![c[1].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0); +} + +/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the +/// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545). +#[test] +fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { + let c = vec![ + gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + ]; + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); + assert_eq!(order, [3, 1, 2, 0, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]); +} + +/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black. +/// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` +/// or a restart leaving DX12 out still reach it. +#[test] +fn a_windows_gpu_renders_through_dx12_before_vulkan() { + let c = vec![ + gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), + ]; + for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}"); + } + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); +} + +#[test] +fn windows_never_loads_vulkan_drivers_unless_asked() { + let all = Backends::all(); + let start = backends(all, false); + if cfg!(windows) { + assert_eq!(start, Backends::DX12 | Backends::GL, "#806"); + } else { + assert_eq!(start, all); + } + assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it"); +} + +#[test] +fn power_preference_follows_the_environment_then_the_preference() { + assert_eq!(power_preference(None, None), AUTOMATIC); + assert_eq!(power_preference(Some("automatic"), None), AUTOMATIC); + assert_eq!(power_preference(Some("lowPower"), None), PowerPreference::LowPower); + assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance); + // 0.5.0's default, and values this version doesn't know (a newer or damaged file). + for old in ["powerSaving", "turbo", ""] { + assert_eq!(power_preference(Some(old), None), AUTOMATIC, "{old}"); + } + // WGPU_POWER_PREF wins over the preference, either way. + assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance); + assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower); + assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::None)), PowerPreference::None); + assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); +} + +/// The preference the engine saves is the one the app reads back before the window opens. +#[test] +fn the_saved_engine_preference_is_found() { + let mut prefs = vectorcraft_engine::Prefs::default(); + let saved = prefs.to_json(); + assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("automatic")); + for (value, power) in [("lowPower", PowerPreference::LowPower), ("highPerformance", PowerPreference::HighPerformance)] { + prefs.gpu_preference = value.into(); + let saved = prefs.to_json(); + assert_eq!(power_preference(saved.get("gpuPreference").and_then(serde_json::Value::as_str), None), power); + } + // Every choice Preferences offer means something here. + for (value, _) in vectorcraft_engine::cmd::prefscmds::GPU_PREFERENCES { + assert!(["automatic", "lowPower", "highPerformance"].contains(value), "{value}"); + } +} + +#[test] +fn skipped_adapters_parse_from_a_comma_separated_list() { + assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); + assert!(parse_skip("").is_empty()); + assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); + assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); +} + +#[test] +fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { + for file in [ + "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", + r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", + "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", + "naga-30.0.0/src/back/spv/writer.rs", + ] { + assert!(in_graphics_stack(file), "{file}"); + } + for file in + ["apps/vectorcraft/src/main.rs", "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs", "egui-0.36.2/src/context.rs", "winit-0.30.12/src/x.rs"] + { + assert!(!in_graphics_stack(file), "{file}"); + } +} + +#[test] +fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { + let amd = Some("Vulkan:1002:164e"); + assert_eq!(restart_without(true, 0, amd, &[]), amd); + assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); + // After start-up the adapter has shown frames; a lost window is something else. + assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); + // A bug outside the graphics, or no adapter picked: nothing another adapter would change. + assert_eq!(restart_without(false, 0, amd, &[]), None); + assert_eq!(restart_without(true, 0, None, &[]), None); + // Never the same adapter twice, so restarts end. + assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); +} + +#[test] +fn a_normal_exit_or_another_failure_does_not_restart() { + let startup = Startup::default(); + assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); + // No adapter was picked (none can show the window): the error is returned, nothing restarts. + let none = eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); + assert_eq!( + finish(Ok(Err(none)), &startup), + Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) + ); + let _ = startup.adapter.set("Vulkan:1002:164e".into()); + GRAPHICS_PANIC.store(false, Ordering::Relaxed); + assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); +} + +/// A log folder of its own for each test. +fn start_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d +} + +/// What a start in `dir` leaves out; it then ends without crashing. +fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut { + let s = Startup::default(); + let left_out = s.track(dir, None, explicit); + s.ended(); + left_out +} + +/// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and +/// Windows' software adapter. +fn issue_2243() -> Vec { + vec![ + pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)), + pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)), + ] +} + +/// #2243: the driver ends the process after the adapter was picked, before the first frame. +/// The next start leaves that adapter out and draws through OpenGL, and so do the starts after +/// it, without a status-bar note. +#[test] +fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { + let dir = start_dir("crash"); + let c = issue_2243(); + let first = Startup::default(); + assert_eq!(first.track(&dir, None, false), LeftOut::default()); + first.picked(&c[0].key, &c[0].name, true); + // The process ends here: its file is closed and unlocked, and the start is still recorded. + drop(first); + + let second = Startup::default(); + let left_out = second.track(&dir, Some("automatic"), false); + assert_eq!(left_out.adapters, ["Dx12:8086:3184"]); + assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() })); + assert!(!left_out.opengl_only); + assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL); + let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters); + assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter"); + second.picked(&c[2].key, &c[2].name, true); + second.presented(); + + let third = Startup::default(); + let left_out = third.track(&dir, None, false); + assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept"); + assert_eq!(left_out.crash, None, "the note is shown once"); + third.ended(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A start whose first frame reached the screen, or that ended without crashing (a normal exit, +/// an error, a restart on another adapter), leaves nothing out of the next. +#[test] +fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { + let dir = start_dir("shown"); + let c = issue_2243(); + for end in [Startup::presented as fn(&Startup), Startup::ended] { + let first = Startup::default(); + first.track(&dir, None, false); + first.picked(&c[0].key, &c[0].name, true); + end(&first); + drop(first); + assert_eq!(next_start(&dir, false), LeftOut::default()); + } + // So does a start that ended before it picked an adapter. + let first = Startup::default(); + first.track(&dir, None, false); + assert!(finish(Ok(Ok(())), &first).is_ok()); + drop(first); + assert_eq!(next_start(&dir, false), LeftOut::default()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a +/// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on +/// macOS, where wgpu has none. +#[test] +fn a_crash_while_listing_lists_opengl_alone() { + let dir = start_dir("listing"); + let first = Startup::default(); + first.track(&dir, None, false); + drop(first); + let second = Startup::default(); + let left_out = second.track(&dir, None, false); + assert!(left_out.adapters.is_empty()); + if cfg!(target_os = "macos") { + assert_eq!(left_out, LeftOut::default()); + } else { + assert_eq!(left_out.crash, Some(Crash::Listing)); + assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL); + assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL); + assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to"); + } + // Listing OpenGL alone found nothing that can show the window: the next start lists every + // backend again. + second.none_left(); + second.ended(); + assert_eq!(next_start(&dir, false), LeftOut::default()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that +/// start shows its first frame on the adapter that crashed, the adapter counts again. +#[test] +fn wgpu_backend_or_adapter_name_ignores_the_crashes() { + let dir = start_dir("explicit"); + let c = issue_2243(); + let first = Startup::default(); + first.track(&dir, None, false); + first.picked(&c[0].key, &c[0].name, true); + drop(first); + assert_eq!(next_start(&dir, true), LeftOut::default()); + assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them"); + + let explicit = Startup::default(); + explicit.track(&dir, None, true); + explicit.picked(&c[0].key, &c[0].name, true); + explicit.presented(); + assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A damaged record, a file that can't be opened and one another starting copy of the app holds +/// are ignored: the start goes on with nothing left out. +#[test] +fn a_damaged_or_unreadable_start_file_is_ignored() { + let dir = start_dir("damaged"); + std::fs::create_dir_all(&dir).unwrap(); + let damaged: [&[u8]; 9] = [ + b"", + b"{", + b"[]", + b"null", + b"{\"version\":1}", + b"{\"starting\":{}}", + b"\xef\xbb\xbf\0", + b"\xff\xfe{", + &[b' '; START_FILE_LIMIT as usize + 1], + ]; + for text in damaged { + std::fs::write(dir.join(START_FILE), text).unwrap(); + assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)])); + } + + // Another copy of the app is starting: this start neither reads nor writes the file. + let c = issue_2243(); + let starting = Startup::default(); + starting.track(&dir, None, false); + starting.picked(&c[0].key, &c[0].name, true); + assert_eq!(next_start(&dir, false), LeftOut::default()); + drop(starting); + assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]); + + // A folder in the file's place. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(START_FILE)).unwrap(); + assert_eq!(next_start(&dir, false), LeftOut::default()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn crashes_are_forgotten_in_another_version_or_under_another_preference() { + let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true }; + let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic"); + assert_eq!(crash, None); + record.starting = Some(starting("Dx12:8086:3184")); + let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic"); + assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() })); + assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]); + // Written and read back. + assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone())); + assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty()); + assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty()); + assert_eq!(preference_name(None), "automatic"); + assert_eq!(preference_name(Some("powerSaving")), "automatic"); + assert_eq!(preference_name(Some("lowPower")), "lowPower"); + // An adapter is listed once, and the list is bounded. + let mut record = record; + for n in 0..CRASHED_LIMIT + 4 { + for _ in 0..2 { + record.starting = Some(starting(&format!("Dx12:10de:{n:04x}"))); + record = StartRecord::begin(Some(record), "0.9.0", "automatic").0; + } + } + assert_eq!(record.crashed.len(), CRASHED_LIMIT); + assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs new file mode 100644 index 000000000..aa7d7f42c --- /dev/null +++ b/apps/vectorcraft/src/host_services.rs @@ -0,0 +1,326 @@ +use super::*; + +/// The window file dialogs belong to. +pub(crate) type Parent = Option>; + +/// What rfd's blocking and asynchronous file dialogs are both set up with. +trait DialogSettings: Sized { + fn parent(self, window: &winit::window::Window) -> Self; + fn title(self, title: &str) -> Self; + fn filter(self, name: &str, exts: &[impl ToString]) -> Self; + fn folder(self, folder: &str) -> Self; + fn file_name(self, name: &str) -> Self; +} + +macro_rules! dialog_settings { + ($($dialog:ty),*) => {$( + impl DialogSettings for $dialog { + fn parent(self, window: &winit::window::Window) -> Self { + self.set_parent(window) + } + fn title(self, title: &str) -> Self { + self.set_title(title) + } + fn filter(self, name: &str, exts: &[impl ToString]) -> Self { + self.add_filter(name, exts) + } + fn folder(self, folder: &str) -> Self { + self.set_directory(folder) + } + fn file_name(self, name: &str) -> Self { + self.set_file_name(name) + } + } + )*}; +} + +dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); + +/// `d` set up for `request`: its title, file types, folder and suggested name. +fn dialog_for(d: D, request: &PickRequest) -> D { + let pick = match request { + PickRequest::Open(pick) | PickRequest::Save(pick) => pick, + PickRequest::OpenMany => { + return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { + d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) + }); + } + PickRequest::Folder => return d, + }; + let d = pick.filters.iter().fold(d, |d, (name, exts)| { + d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) + }); + let d = match &pick.folder { + Some(folder) => d.folder(folder), + None => d, + }; + if pick.name.is_empty() { d } else { d.file_name(&pick.name) } +} + +/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the +/// window and keeps it in front). +#[cfg(not(target_os = "macos"))] +fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog { + match parent { + Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request), + None => dialog_for(rfd::FileDialog::new(), request), + } +} + +/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet. +fn make_folder(request: &PickRequest) { + if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request { + let _ = std::fs::create_dir_all(folder); + } +} + +/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On +/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over +/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when +/// the portal can't. +#[cfg(not(target_os = "macos"))] +#[cfg_attr(windows, allow(unused_variables))] +fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { + make_folder(request); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if let PickRequest::Save(pick) = request + && let Some(answer) = portal_save::pick(pick, parent.as_deref()) + { + return answer; + } + let paths = match request { + PickRequest::Open(_) => dialog.pick_file().into_iter().collect(), + PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(), + PickRequest::Save(_) => dialog.save_file().into_iter().collect(), + PickRequest::Folder => dialog.pick_folder().into_iter().collect(), + }; + paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect() +} + +/// Show the dialog for `request` over `parent` now → the paths picked. +#[cfg(not(target_os = "macos"))] +fn pick_now(request: PickRequest, parent: &Parent) -> Vec { + show_dialog(file_dialog(&request, parent), &request, parent) +} + +/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an +/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]); +/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing. +#[cfg(target_os = "macos")] +fn pick_now(request: PickRequest, _parent: &Parent) -> Vec { + log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only"); + Vec::new() +} + +/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. +fn reveal(path: &str) -> Result<(), String> { + explorer_safe(path)?; + reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) +} + +#[cfg(target_os = "macos")] +fn reveal_command(path: &str) -> std::process::Command { + let mut c = std::process::Command::new("open"); + c.args(["-R", path]); + c +} + +#[cfg(windows)] +fn reveal_command(path: &str) -> std::process::Command { + use std::os::windows::process::CommandExt as _; + // Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces) + // and needs backslashes. + let mut c = std::process::Command::new("explorer"); + c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\"))); + c +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn reveal_command(path: &str) -> std::process::Command { + let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let mut c = std::process::Command::new("xdg-open"); + c.arg(folder); + c +} + +/// 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()) +} + +/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the +/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then +/// offers to kill the window as not responding (#592). +#[cfg(not(target_os = "macos"))] +fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { + let dialog = file_dialog(&request, parent); + let (tx, rx) = std::sync::mpsc::channel(); + let ui = ui.clone(); + let parent = parent.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + // The app gone meanwhile has no use for the answer. + let _ = tx.send(show_dialog(dialog, &request, &parent)); + ui.request_repaint(); + }) + .ok()?; + Some(rx) +} + +/// The paths a dialog shown as a sheet picks, once it closes. +#[cfg(target_os = "macos")] +type SheetAnswer = std::pin::Pin> + Send>>; + +/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver +/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an +/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered +/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet +/// returns at once and the window keeps running; a thread of its own waits for the answer. +#[cfg(target_os = "macos")] +fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { + use winit::raw_window_handle::HasWindowHandle as _; + // rfd runs the panel modally after all unless it has the running app's window to hold the + // sheet (and from another thread it can't reach the window): no dialog then (`pick_now`). + let mtm = objc2::MainThreadMarker::new(); + let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning()); + let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else { + log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some()); + return None; + }; + // The thread first, so the answer of a sheet once shown always has somewhere to go. + let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::(); + let (tx, rx) = std::sync::mpsc::channel(); + let ui = ui.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + if let Ok(answer) = sheet_rx.recv() { + // The app gone meanwhile has no use for the answer. + let _ = tx.send(pollster::block_on(answer)); + ui.request_repaint(); + } + }) + .ok()?; + make_folder(&request); + // The panel is built and the sheet is begun below, on the main thread (as it must). + let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request); + let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string(); + let answer: SheetAnswer = match request { + PickRequest::Open(_) => { + let picked = dialog.pick_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + PickRequest::OpenMany => { + let picked = dialog.pick_files(); + Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) + } + // A save panel with two or more named file types is built by `mac_save_format`, which sets + // up its Format menu before the sheet begins (#866); rfd shows the others. + PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) { + // The dialog thread blocks here until the sheet answers. + Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }), + None => { + // Nothing is added to the sheet once rfd has begun it: an accessory view set then + // made recent macOS versions dismiss the sheet at once (#1800). + let picked = dialog.save_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + }, + PickRequest::Folder => { + let picked = dialog.pick_folder(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + }; + // The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled. + let _ = sheet_tx.send(answer); + Some(rx) +} + +/// The host's services; `ui` is woken when a file dialog answers. +pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_))); + // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764). + #[cfg(target_os = "linux")] + let x11 = parent + .as_ref() + .and_then(|w| w.window_handle().ok()) + .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); + let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); + #[cfg(target_os = "macos")] + let sampled_ui = ui.clone(); + Services { + pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), + pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), + pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())), + // Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows + // dialogs run the window's events while they are open. + start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), + dialogs_are_sheets: cfg!(target_os = "macos"), + wayland, + no_cursor_theme: wayland && cursor_theme::theme_missing(), + #[cfg(target_os = "linux")] + stylus: if x11 { tablet::start() } else { None }, + // macOS reads it from AppKit's events (#852). + #[cfg(target_os = "macos")] + stylus: mac_tablet::start(), + // Windows reads it from the pen's pointer messages (`windows_stylus`). + #[cfg(all(windows, not(target_vendor = "win7")))] + stylus: Some(windows_stylus::start()), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(write_file)), + exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + // Background Save and Export write from a worker thread. + write_shared: Some(std::sync::Arc::new(write_file)), + // Native saves stream into the file as they are encoded. + stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + fileio::write_file_with(p, fill).map_err(|e| e.to_string()) + })), + // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). + system_clipboard: Some(clipboard::system_clipboard()), + // Linux checks whether Paste has something to take on a background thread: an X11 clipboard + // owner that never answers holds a read for up to 4 s. Windows only asks which formats the + // clipboard holds and macOS asks the pasteboard server, so they check in line. + clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory), + // Help → Discord / website / GitHub, the Discord button, About and Home links. + open_url: Some(Box::new(|url: &str| { + let _ = webbrowser::open(url); + })), + 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)), + 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")] + screen_color: Some(mac_screen_color::sampler(sampled_ui)), + // File → Print: the system's printers and print queue. + print: Some(Box::new(printing::SystemPrint)), + ..Default::default() + } +} + +/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which +/// no Windows file name has) is refused rather than split into other arguments. +fn explorer_safe(path: &str) -> Result<(), String> { + if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) } +} + +/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it. +fn open_file(path: &str) -> Result<(), String> { + explorer_safe(path)?; + #[cfg(windows)] + let mut c = { + use std::os::windows::process::CommandExt as _; + let mut c = std::process::Command::new("explorer"); + c.raw_arg(format!("\"{}\"", path.replace('/', "\\"))); + c + }; + #[cfg(target_os = "macos")] + let mut c = std::process::Command::new("open"); + #[cfg(not(any(target_os = "macos", windows)))] + let mut c = std::process::Command::new("xdg-open"); + #[cfg(not(windows))] + c.arg(path); + c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) +} diff --git a/apps/vectorcraft/src/logging.rs b/apps/vectorcraft/src/logging.rs index a9fded642..e005d702d 100644 --- a/apps/vectorcraft/src/logging.rs +++ b/apps/vectorcraft/src/logging.rs @@ -296,309 +296,4 @@ pub fn install() -> Option<&'static AppLogger> { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - #[test] - fn the_default_filter_shows_vectorcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("vectorcraft"), LevelFilter::Info); - assert_eq!(f.level_for("vectorcraft::window"), LevelFilter::Info); - assert_eq!(f.level_for("vectorcraft_engine::guard"), LevelFilter::Info); - assert_eq!(f.level_for("vectorcraft_ui_egui::io"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,vectorcraft_render=trace,vectorcraft_render::tiles=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("vectorcraft_render::canvas"), LevelFilter::Trace); - assert_eq!(f.level_for("vectorcraft_render::tiles"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("vectorcraft*=debug").level_for("vectorcraft_text::shape"), LevelFilter::Debug); - assert_eq!(Filter::parse("vectorcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("vectorcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "vectorcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,vectorcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "vectorcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - } - assert_eq!(Filter::parse("warn,,vectorcraft=DEBUG").level_for("vectorcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("vectorcraft=").level_for("vectorcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // Past chrono's year range still converts via the 400-year cycle (the old code did too, - // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' - // SystemTime can't hold this instant, so it is only checked where it exists. - // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. - // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "vectorcraft", "rendering with Mesa (Vulkan, IntegratedGpu)"), - "2026-10-08T07:59:17.728Z INFO [main] vectorcraft: rendering with Mesa (Vulkan, IntegratedGpu)\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("vectorcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("vectorcraft.2.log")), "run 2"); - assert!(!dir.join("vectorcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - let record = |level, target: &'static str, msg: &'static str| { - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - }; - record(log::Level::Info, "vectorcraft", "before the settings directory"); - record(log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(log::Level::Error, "vectorcraft_engine::guard", "recovered from a panic: boom"); - record(log::Level::Debug, "vectorcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("vectorcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("vectorcraft_engine::guard: recovered from a panic: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("vectorcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("vectorcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - use log::Log; - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - l.log( - &log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build(), - ); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" vectorcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - use log::Log; - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - assert!(logger.attach_dir(&dir).is_err()); - logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build()); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - use log::Log; - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - logger.log(&log::Record::builder().level(log::Level::Warn).target("vectorcraft").args(format_args!("after the poison")).build()); - assert!(read(&path).contains("vectorcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/vectorcraft/src/logging/tests.rs b/apps/vectorcraft/src/logging/tests.rs new file mode 100644 index 000000000..a1b54b353 --- /dev/null +++ b/apps/vectorcraft/src/logging/tests.rs @@ -0,0 +1,302 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +#[test] +fn the_default_filter_shows_vectorcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("vectorcraft"), LevelFilter::Info); + assert_eq!(f.level_for("vectorcraft::window"), LevelFilter::Info); + assert_eq!(f.level_for("vectorcraft_engine::guard"), LevelFilter::Info); + assert_eq!(f.level_for("vectorcraft_ui_egui::io"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,vectorcraft_render=trace,vectorcraft_render::tiles=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("vectorcraft_render::canvas"), LevelFilter::Trace); + assert_eq!(f.level_for("vectorcraft_render::tiles"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("vectorcraft*=debug").level_for("vectorcraft_text::shape"), LevelFilter::Debug); + assert_eq!(Filter::parse("vectorcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("vectorcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "vectorcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,vectorcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "vectorcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,vectorcraft=DEBUG").level_for("vectorcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("vectorcraft=").level_for("vectorcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "vectorcraft", "rendering with Mesa (Vulkan, IntegratedGpu)"), + "2026-10-08T07:59:17.728Z INFO [main] vectorcraft: rendering with Mesa (Vulkan, IntegratedGpu)\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("vectorcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("vectorcraft.2.log")), "run 2"); + assert!(!dir.join("vectorcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + let record = |level, target: &'static str, msg: &'static str| { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + }; + record(log::Level::Info, "vectorcraft", "before the settings directory"); + record(log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(log::Level::Error, "vectorcraft_engine::guard", "recovered from a panic: boom"); + record(log::Level::Debug, "vectorcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("vectorcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("vectorcraft_engine::guard: recovered from a panic: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("vectorcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("vectorcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + use log::Log; + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + l.log(&log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build()); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" vectorcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + use log::Log; + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build()); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + use log::Log; + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + logger.log(&log::Record::builder().level(log::Level::Warn).target("vectorcraft").args(format_args!("after the poison")).build()); + assert!(read(&path).contains("vectorcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fc3f0dea4..2cdf80b42 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -65,6 +65,31 @@ use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; +mod app; +mod host_services; +mod prefs; +mod titlebar; + +#[cfg(all(test, feature = "wgpu"))] +use app::bring_window_forward; +use app::native_input_hook; +use app::open_files; +use host_services::services; +#[cfg(all(test, feature = "wgpu"))] +use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; +use prefs::{load_prefs, log_dir, prefs_enabled, prefs_path, read_prefs}; +#[cfg(all(test, feature = "wgpu", any(target_os = "macos", target_os = "windows")))] +use titlebar::CUSTOM_TITLEBAR; +#[cfg(feature = "wgpu")] +use titlebar::adapter_summary; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::custom_titlebar_from; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::opted_into_system_title_bar; +#[cfg(windows)] +use titlebar::set_taskbar_icon; +use titlebar::{custom_titlebar, native_options}; + struct App { app: VectorcraftApp, /// Reported by wgpu when the window's graphics device is lost (a driver reset). @@ -83,688 +108,6 @@ struct App { maximize_tries: u8, } -impl eframe::App for App { - // Each frame runs unconfined: it is the person at the keyboard's. The app confines what the - // control channel asks for, and the frames carrying input it injected, to the automation roots - // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. - fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - file_access::unconfined(|| self.logic_frame(ctx, frame)); - } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - // winit gives the window its caption back when it changes the window's flags (maximize, - // restore, …): take it off again before the frame is laid out (#1758). - if let Some(window_frame) = self.window_frame.as_mut() { - window_frame(); - } - #[cfg(target_os = "macos")] - if self.app.services.native_menu.is_some() { - mac_menu::raw_input_hook(raw); - } - // A pen contact with the barrel button held presses the secondary button (Windows Ink). - #[cfg(all(windows, not(target_vendor = "win7")))] - windows_stylus::raw_input_hook(raw); - file_access::unconfined(|| self.app.raw_input_hook(raw)); - } - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] - fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - #[cfg(target_os = "macos")] - { - let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; - let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar); - self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); - } - 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). - if self.app.take_prefs_changed() { - file_access::unconfined(|| save_prefs(&self.app)); - } - // Not while a file dialog's sheet has the keyboard: its name field's composition would go. - #[cfg(target_os = "macos")] - if !self.app.file_dialog_open() && self.app.take_ime_discard() { - discard_marked_text(); - } - } - #[cfg(not(feature = "windows7"))] - fn on_exit(&mut self) { - file_access::unconfined(|| save_prefs(&self.app)); - end_before_teardown(self.frames); - } - #[cfg(feature = "windows7")] - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - file_access::unconfined(|| save_prefs(&self.app)); - } -} - -impl App { - fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.frames = ctx.cumulative_frame_nr(); - if let Some(why) = self.graphics_loss.take() { - // eframe can't give a window a new device: the user saves and starts again. - self.graphics_lost = self.app.graphics_lost(&why); - self.app.status(if self.graphics_lost { - "The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft" - } else { - "The graphics device was lost: save your documents and restart VectorCraft" - }); - } - if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) { - // The window can't show the Save Changes question: it closes, and the next launch - // offers the changes back. - 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")] - if !self.app.file_dialog_open() { - let files = open_documents::take(); - if !files.is_empty() { - bring_window_forward(ctx); - } - open_files(&mut self.app, files); - } - self.app.logic(ctx); - if self.pending_maximize { - if let Some(w) = &self.winit_window { - self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries); - } else { - self.pending_maximize = false; - } - } - let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized()); - window::track(ctx, &mut self.app.ui.window, host_maximized); - if self.app.ui.status == "quit" { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } -} - -/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, -/// before eframe tears the window down (#661). AccessKit then gives the window's content view back -/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the -/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. -/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics -/// failure then starts the app again on another adapter (#651). -#[allow(unused_variables)] -fn end_before_teardown(frames: u64) { - #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] - if frames >= gpu::STARTUP_FRAMES { - log::info!("quitting"); - log::logger().flush(); - std::process::exit(0); - } -} - -/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back -/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn bring_window_forward(ctx: &egui::Context) { - ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); -} - -/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that -/// can't be opened is reported to the user and on stderr; the others still open. -fn open_files(app: &mut VectorcraftApp, files: Vec) { - for f in files { - if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) { - eprintln!("vectorcraft: {f}: {e}"); - } - } -} - -/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as -/// typed). winit's IME toggle only clears its own copy, so the IME would type it again. -#[cfg(target_os = "macos")] -fn discard_marked_text() { - if let Some(mtm) = objc2::MainThreadMarker::new() - && let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm) - { - ic.discardMarkedText(); - } -} - -/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), -/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). -fn prefs_path() -> Option { - prefs_dir::prefs_path_for("VectorCraft", "vectorcraft") -} - -/// The same place under the project's former name (DrawCraft): read once if there are no -/// VectorCraft preferences yet, so settings survive the rename. -fn legacy_prefs_path() -> Option { - prefs_dir::prefs_path_for("DrawCraft", "drawcraft") -} - -/// Where the log files live: `logs` in the preferences folder (see `logging`). -fn log_dir() -> Option { - Some(prefs_path()?.parent()?.join("logs")) -} - -/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. -fn prefs_enabled() -> bool { - std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() -} - -/// The saved UI preferences, read before the window opens (they hold its size and position). -fn read_prefs() -> Option { - if !prefs_enabled() { - return None; - } - let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?; - decode_saved_prefs(&bytes) -} - -/// Recover only optional persisted docking metadata. Runtime UiState and command decoding -/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences. -fn decode_saved_prefs(bytes: &[u8]) -> Option { - use serde_json::{Map, Value, json}; - - fn recover(object: &mut Map, hidden_key: &str) { - if let Some(layout) = object.get("docking") - && serde_json::from_value::(json!({"docking": layout})).is_err() - { - object.remove("docking"); - } - if let Some(hidden) = object.get_mut(hidden_key) { - if let Value::Object(entries) = hidden { - entries.retain(|panel, location| { - serde_json::from_value::(json!({"docking_hidden": {panel: location}})).is_ok() - }); - } else { - object.remove(hidden_key); - } - } - } - - let mut value: Value = serde_json::from_slice(bytes).ok()?; - let object = value.as_object_mut()?; - recover(object, "docking_hidden"); - // Saved workspaces and the layout changes kept for each workspace carry the same layouts. - for key in ["custom_workspaces", "workspace_changes"] { - if let Some(Value::Array(workspaces)) = object.get_mut(key) { - for workspace in workspaces { - if let Some(object) = workspace.as_object_mut() { - recover(object, "dockingHidden"); - } - } - } - } - serde_json::from_value(value).ok() -} - -fn load_prefs(app: &mut VectorcraftApp, saved: Option) { - if !prefs_enabled() { - return; - } - if let Some(ui) = saved { - app.ui = ui.sanitized(); - } - vectorcraft_ui_egui::prefs_dialog::restore(app); -} - -fn save_prefs(app: &VectorcraftApp) { - if !prefs_enabled() { - return; - } - if let Some(p) = prefs_path() { - let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = prefs_bytes(app) { - // Preferences are best effort: a failed write keeps the previous file. - let _ = fileio::write_atomic(&p, &bytes); - } - } -} - -/// The preferences file's contents: the UI state with the engine preferences. -fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { - let mut ui = app.ui.clone(); - ui.engine_prefs = app.session.prefs.to_json(); - serde_json::to_vec_pretty(&ui) -} - -/// The window file dialogs belong to. -type Parent = Option>; - -/// What rfd's blocking and asynchronous file dialogs are both set up with. -trait DialogSettings: Sized { - fn parent(self, window: &winit::window::Window) -> Self; - fn title(self, title: &str) -> Self; - fn filter(self, name: &str, exts: &[impl ToString]) -> Self; - fn folder(self, folder: &str) -> Self; - fn file_name(self, name: &str) -> Self; -} - -macro_rules! dialog_settings { - ($($dialog:ty),*) => {$( - impl DialogSettings for $dialog { - fn parent(self, window: &winit::window::Window) -> Self { - self.set_parent(window) - } - fn title(self, title: &str) -> Self { - self.set_title(title) - } - fn filter(self, name: &str, exts: &[impl ToString]) -> Self { - self.add_filter(name, exts) - } - fn folder(self, folder: &str) -> Self { - self.set_directory(folder) - } - fn file_name(self, name: &str) -> Self { - self.set_file_name(name) - } - } - )*}; -} - -dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); - -/// `d` set up for `request`: its title, file types, folder and suggested name. -fn dialog_for(d: D, request: &PickRequest) -> D { - let pick = match request { - PickRequest::Open(pick) | PickRequest::Save(pick) => pick, - PickRequest::OpenMany => { - return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) - }); - } - PickRequest::Folder => return d, - }; - let d = pick.filters.iter().fold(d, |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) - }); - let d = match &pick.folder { - Some(folder) => d.folder(folder), - None => d, - }; - if pick.name.is_empty() { d } else { d.file_name(&pick.name) } -} - -/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the -/// window and keeps it in front). -#[cfg(not(target_os = "macos"))] -fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog { - match parent { - Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request), - None => dialog_for(rfd::FileDialog::new(), request), - } -} - -/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet. -fn make_folder(request: &PickRequest) { - if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request { - let _ = std::fs::create_dir_all(folder); - } -} - -/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On -/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over -/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when -/// the portal can't. -#[cfg(not(target_os = "macos"))] -#[cfg_attr(windows, allow(unused_variables))] -fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { - make_folder(request); - #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] - if let PickRequest::Save(pick) = request - && let Some(answer) = portal_save::pick(pick, parent.as_deref()) - { - return answer; - } - let paths = match request { - PickRequest::Open(_) => dialog.pick_file().into_iter().collect(), - PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(), - PickRequest::Save(_) => dialog.save_file().into_iter().collect(), - PickRequest::Folder => dialog.pick_folder().into_iter().collect(), - }; - paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect() -} - -/// Show the dialog for `request` over `parent` now → the paths picked. -#[cfg(not(target_os = "macos"))] -fn pick_now(request: PickRequest, parent: &Parent) -> Vec { - show_dialog(file_dialog(&request, parent), &request, parent) -} - -/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an -/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]); -/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing. -#[cfg(target_os = "macos")] -fn pick_now(request: PickRequest, _parent: &Parent) -> Vec { - log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only"); - Vec::new() -} - -/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. -fn reveal(path: &str) -> Result<(), String> { - explorer_safe(path)?; - reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) -} - -#[cfg(target_os = "macos")] -fn reveal_command(path: &str) -> std::process::Command { - let mut c = std::process::Command::new("open"); - c.args(["-R", path]); - c -} - -#[cfg(windows)] -fn reveal_command(path: &str) -> std::process::Command { - use std::os::windows::process::CommandExt as _; - // Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces) - // and needs backslashes. - let mut c = std::process::Command::new("explorer"); - c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\"))); - c -} - -#[cfg(not(any(target_os = "macos", windows)))] -fn reveal_command(path: &str) -> std::process::Command { - let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); - let mut c = std::process::Command::new("xdg-open"); - c.arg(folder); - c -} - -/// 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()) -} - -/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the -/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then -/// offers to kill the window as not responding (#592). -#[cfg(not(target_os = "macos"))] -fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { - let dialog = file_dialog(&request, parent); - let (tx, rx) = std::sync::mpsc::channel(); - let ui = ui.clone(); - let parent = parent.clone(); - std::thread::Builder::new() - .name("file-dialog".into()) - .spawn(move || { - // The app gone meanwhile has no use for the answer. - let _ = tx.send(show_dialog(dialog, &request, &parent)); - ui.request_repaint(); - }) - .ok()?; - Some(rx) -} - -/// The paths a dialog shown as a sheet picks, once it closes. -#[cfg(target_os = "macos")] -type SheetAnswer = std::pin::Pin> + Send>>; - -/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver -/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an -/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered -/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet -/// returns at once and the window keeps running; a thread of its own waits for the answer. -#[cfg(target_os = "macos")] -fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { - use winit::raw_window_handle::HasWindowHandle as _; - // rfd runs the panel modally after all unless it has the running app's window to hold the - // sheet (and from another thread it can't reach the window): no dialog then (`pick_now`). - let mtm = objc2::MainThreadMarker::new(); - let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning()); - let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else { - log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some()); - return None; - }; - // The thread first, so the answer of a sheet once shown always has somewhere to go. - let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::(); - let (tx, rx) = std::sync::mpsc::channel(); - let ui = ui.clone(); - std::thread::Builder::new() - .name("file-dialog".into()) - .spawn(move || { - if let Ok(answer) = sheet_rx.recv() { - // The app gone meanwhile has no use for the answer. - let _ = tx.send(pollster::block_on(answer)); - ui.request_repaint(); - } - }) - .ok()?; - make_folder(&request); - // The panel is built and the sheet is begun below, on the main thread (as it must). - let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request); - let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string(); - let answer: SheetAnswer = match request { - PickRequest::Open(_) => { - let picked = dialog.pick_file(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } - PickRequest::OpenMany => { - let picked = dialog.pick_files(); - Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) - } - // A save panel with two or more named file types is built by `mac_save_format`, which sets - // up its Format menu before the sheet begins (#866); rfd shows the others. - PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) { - // The dialog thread blocks here until the sheet answers. - Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }), - None => { - // Nothing is added to the sheet once rfd has begun it: an accessory view set then - // made recent macOS versions dismiss the sheet at once (#1800). - let picked = dialog.save_file(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } - }, - PickRequest::Folder => { - let picked = dialog.pick_folder(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } - }; - // The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled. - let _ = sheet_tx.send(answer); - Some(rx) -} - -/// The host's services; `ui` is woken when a file dialog answers. -fn services(parent: Parent, ui: egui::Context) -> Services { - use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; - let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_))); - // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764). - #[cfg(target_os = "linux")] - let x11 = parent - .as_ref() - .and_then(|w| w.window_handle().ok()) - .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); - let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); - #[cfg(target_os = "macos")] - let sampled_ui = ui.clone(); - Services { - pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), - pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), - pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())), - // Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows - // dialogs run the window's events while they are open. - start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), - dialogs_are_sheets: cfg!(target_os = "macos"), - wayland, - no_cursor_theme: wayland && cursor_theme::theme_missing(), - #[cfg(target_os = "linux")] - stylus: if x11 { tablet::start() } else { None }, - // macOS reads it from AppKit's events (#852). - #[cfg(target_os = "macos")] - stylus: mac_tablet::start(), - read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(write_file)), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), - // Background Save and Export write from a worker thread. - write_shared: Some(std::sync::Arc::new(write_file)), - // Native saves stream into the file as they are encoded. - stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { - fileio::write_file_with(p, fill).map_err(|e| e.to_string()) - })), - // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard()), - // Linux checks whether Paste has something to take on a background thread: an X11 clipboard - // owner that never answers holds a read for up to 4 s. Windows only asks which formats the - // clipboard holds and macOS asks the pasteboard server, so they check in line. - clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory), - // Help → Discord / website / GitHub, the Discord button, About and Home links. - open_url: Some(Box::new(|url: &str| { - let _ = webbrowser::open(url); - })), - 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)), - 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")] - screen_color: Some(mac_screen_color::sampler(sampled_ui)), - // File → Print: the system's printers and print queue. - print: Some(Box::new(printing::SystemPrint)), - ..Default::default() - } -} - -/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which -/// no Windows file name has) is refused rather than split into other arguments. -fn explorer_safe(path: &str) -> Result<(), String> { - if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) } -} - -/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it. -fn open_file(path: &str) -> Result<(), String> { - explorer_safe(path)?; - #[cfg(windows)] - let mut c = { - use std::os::windows::process::CommandExt as _; - let mut c = std::process::Command::new("explorer"); - c.raw_arg(format!("\"{}\"", path.replace('/', "\\"))); - c - }; - #[cfg(target_os = "macos")] - let mut c = std::process::Command::new("open"); - #[cfg(not(any(target_os = "macos", windows)))] - let mut c = std::process::Command::new("xdg-open"); - #[cfg(not(windows))] - c.arg(path); - c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) -} - -/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets -/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches -/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon. -fn app_icon() -> egui::IconData { - #[cfg(target_os = "macos")] - let png: &[u8] = include_bytes!("../../../assets/app-icon/vectorcraft-macos-512.png"); - #[cfg(not(target_os = "macos"))] - let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png"); - eframe::icon_data::from_png_bytes(png).unwrap_or_default() -} - -/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the -/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too. -#[cfg(windows)] -fn set_taskbar_icon(w: &winit::window::Window) { - use winit::platform::windows::WindowExtWindows as _; - let icon = app_icon(); - // An icon that can't be made leaves the generic one: nothing else depends on it. - if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { - w.set_taskbar_icon(Some(i)); - } -} - -/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports. -#[cfg(feature = "wgpu")] -fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { - format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) -} - -/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). -/// macOS keeps its traffic lights over the integrated title strip. -/// -/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the -/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe. -#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] -const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); - -/// Whether the user asked for the system title bar in Preferences › User Interface › System Title -/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is -/// `false` (the default). This preference forces the desktop/native frame back on. -fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) -} - -/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate -/// decided and the user's System Title Bar preference. -/// -/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame -/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with -/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it -/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), -/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly -/// when `craft-window` says the app owns the frame **and** the user has not opted into the system -/// title bar; the preference always forces the native frame back on. -/// -/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the -/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but -/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons -/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not -/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe. -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool { - matches!(frame, craft_window::Frame::App(_)) && !opted_into_system -} - -/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see -/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); -/// Windows draws its own caption unless the user opted into the system title bar. -#[cfg(any(target_os = "macos", target_os = "windows"))] -fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool { - CUSTOM_TITLEBAR && !opted_into_system -} - -/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its -/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System -/// Title Bar preference. Call once at startup, before [`native_options`]. -/// -/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through -/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs -/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. -fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); - let opted_into_system = opted_into_system_title_bar(saved); - #[cfg(target_os = "linux")] - { - let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } - } - custom_titlebar_from(choice.frame(), opted_into_system) -} - -fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { - eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("VectorCraft") - .with_inner_size(window::DEFAULT_SIZE) - .with_min_inner_size(window::MIN_SIZE) - .with_drag_and_drop(true) - .with_decorations(!custom_titlebar) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - .with_icon(app_icon()) - .with_app_id("ai.storyteller.vectorcraft"), - #[cfg(feature = "windows7")] - renderer: eframe::Renderer::Glow, - ..Default::default() - } -} - /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. const USAGE: &str = "\ Usage: vectorcraft [options] [files…] @@ -788,6 +131,11 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli: "; fn main() -> std::process::ExitCode { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::VECTORCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // Before any thread starts: the pointer must find a cursor theme on Wayland (#2450). #[cfg(target_os = "linux")] cursor_theme::apply(); @@ -944,9 +292,9 @@ fn main() -> std::process::ExitCode { } options }; - // With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message - // hook notes each pointer contact and whether it starts with the barrel button held - // (`windows_stylus`). + // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end + // is down; the message hook notes each pointer contact and whether it starts with the barrel + // button held, and writes the pen's end into the pen feed (`windows_stylus`). #[cfg(all(windows, not(target_vendor = "win7")))] let options = { use winit::platform::windows::EventLoopBuilderExtWindows as _; @@ -1014,11 +362,13 @@ fn main() -> std::process::ExitCode { run( options, Box::new(move |cc| { + VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx); let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); prefs_dir::attach_plugin_store(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); + app.set_startup_update_check(true); load_prefs(&mut app, saved); // The saved PDF presets live in their own folder (also read by vectorcraft-cli), // which takes over from the copy the preferences file used to hold. @@ -1033,13 +383,15 @@ fn main() -> std::process::ExitCode { } // Linux may not map the window before the first frames, so a saved maximize is retried then (#3635). let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized); - // User Defined swatch, graphic style and symbol libraries live next to the preferences. + // User Defined swatch, graphic style, symbol and brush libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); app.session.style_libraries.set_user_dir(styles); let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string())); app.session.symbol_libraries.set_user_dir(symbols); + let brushes = prefs_path().and_then(|p| Some(p.parent()?.join("Brushes").to_string_lossy().to_string())); + app.session.brush_libraries.set_user_dir(brushes); // With the User Defined libraries listed, the persistent library panels reopen. vectorcraft_ui_egui::panels::library_panel::restore(&mut app); // So do the Libraries panel's libraries; runs without preferences (agents' test @@ -1109,6 +461,7 @@ fn main() -> std::process::ExitCode { } #[cfg(not(target_os = "macos"))] let _ = in_window_menus; + 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`). #[cfg(feature = "wgpu")] @@ -1142,295 +495,4 @@ fn main() -> std::process::ExitCode { } #[cfg(all(test, feature = "wgpu"))] -mod tests { - use super::*; - - fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState { - vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } - } - - /// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an - /// override that can't fire (#487), an entry for a command this version lacks, and an old set - /// name. - #[test] - fn shortcut_overrides_from_an_earlier_version_load() { - use serde_json::json; - use vectorcraft_ui_egui::{keymap, shortcut_editor}; - let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap(); - raw["shortcut_overrides"] = json!({ - "tool:pen": "Shift+9", - "edit.preferences": "", - "panel:layers": "Cmd+Alt+Shift+F7", - "tool:groupSelection": "Shift+ShiftLeft", - "test.unknown": "Cmd+Alt+Shift+F9", - "view.grid": "Cmd+Period", - }); - raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0); - let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized(); - assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1); - let km = keymap::keymap_with(&ui.shortcut_overrides); - let text = |key: &str| keymap::effective_with(&km, key); - assert_eq!(text("tool:pen").as_deref(), Some("Shift+9")); - assert_eq!(text("edit.preferences"), None); - assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7")); - assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default"); - assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period")); - let period = keymap::vc_chord("Cmd+.").unwrap(); - assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string())); - assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept"); - assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); - } - - #[test] - fn opening_files_unminimizes_and_focuses_the_window() { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx())); - // Nothing paints in the test: drop the font atlas upload egui expects to be applied. - out.textures_delta.clear(); - let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands; - assert!(cmds.contains(&egui::ViewportCommand::Minimized(false))); - assert!(cmds.contains(&egui::ViewportCommand::Focus)); - } - - #[test] - fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { - use serde_json::json; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - app.ui.toolbar = false; - app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); - app.ui.custom_workspaces = - vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; - app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")]; - let original = serde_json::to_value(&app.ui).unwrap(); - let valid_location = original["docking_hidden"]["layers"].clone(); - assert!(valid_location.is_object()); - for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] { - for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] { - let mut raw = original.clone(); - raw["docking"] = invalid_layout.clone(); - raw["docking_hidden"] = invalid_hidden.clone(); - raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); - raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); - raw["workspace_changes"][0]["docking"] = invalid_layout.clone(); - raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone(); - assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); - let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); - assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); - assert!(!loaded.toolbar); - assert!(loaded.docking.is_none()); - assert!(!loaded.docking_hidden.contains_key("layers")); - if invalid_hidden.is_object() { - assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location); - } else { - assert!(loaded.docking_hidden.is_empty()); - } - assert_eq!(loaded.custom_workspaces.len(), 2); - assert_eq!(loaded.custom_workspaces[0].name, "Future"); - assert!(loaded.custom_workspaces[0].docking.is_none()); - assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); - assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); - assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost"); - assert!(loaded.workspace_changes[0].docking.is_none()); - } - } - let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); - assert_eq!(serde_json::to_value(roundtrip).unwrap(), original); - assert!( - decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(), - "unrelated fields retain normal strict parsing" - ); - } - - #[test] - fn the_image_trace_preview_setting_is_kept_across_a_restart() { - for preview in [false, true] { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.ui.image_trace_preview = preview; - let saved = prefs_bytes(&app).unwrap(); - let loaded = decode_saved_prefs(&saved).unwrap().sanitized(); - assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart"); - } - } - - #[test] - fn the_system_title_bar_preference_keeps_the_window_decorations() { - use craft_window::{Frame, Layout}; - - // The System Title Bar preference (Preferences › User Interface) is read the same way on - // every platform; a missing or unreadable value is `false`. - assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); - let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); - assert!(!super::opted_into_system_title_bar(Some(&off))); - let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); - assert!(super::opted_into_system_title_bar(Some(&on))); - - // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title - // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native - // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws - // our own title bar — unless the user opted into the system title bar, which forces the - // native frame back on. - let app = Frame::App(Layout::default()); - let desktop = Frame::Desktop; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); - assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); - assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); - assert!(!super::custom_titlebar_from(&desktop, true)); - } - // macOS and Windows keep today's behavior, independent of the probed frame: the pref still - // forces the native frame back on where a custom one would otherwise be drawn. - #[cfg(any(target_os = "macos", target_os = "windows"))] - { - assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); - assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); - assert!(!super::custom_titlebar_from(&desktop, true)); - } - - assert_eq!(super::native_options(false).viewport.decorations, Some(true)); - assert_eq!(super::native_options(true).viewport.decorations, Some(false)); - } - - /// The file extensions the macOS bundle declares: its document types and its own exported type. - fn plist_extensions(plist: &str) -> Vec<&str> { - ["CFBundleTypeExtensions", "public.filename-extension"] - .iter() - .flat_map(|key| plist.split(key).skip(1)) - .filter_map(|rest| rest.split("").next()) - .flat_map(|array| array.split("").skip(1)) - .filter_map(|s| s.split("").next()) - .collect() - } - - /// Finder offers the app for every file File › Open reads (#295, #354) but those with an - /// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app - /// rather than to AppKit's document machinery. - #[test] - fn the_macos_bundle_opens_every_readable_format() { - let plist = include_str!("../../../packaging/macos/Info.plist.in"); - let declared = plist_extensions(plist); - for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) { - assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}"); - } - for e in &declared { - assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open"); - } - assert!(!plist.contains("NSDocumentClass"), "not an NSDocument app: AppKit would refuse the files"); - let types = plist.matches("CFBundleTypeName").count(); - assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank"); - assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned"); - } - - /// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS` - /// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml` - /// declares. - const LINUX_TYPES: &[(&str, &str)] = &[ - ("vectorcraft", "application/x-vectorcraft"), - ("drawcraft", "application/x-vectorcraft"), - ("svg", "image/svg+xml"), - ("svgz", "image/svg+xml-compressed"), - ("pdf", "application/pdf"), - ("ai", "application/illustrator"), // brand-ok: shared-mime-info's type - ("ait", "application/x-ai-template"), - ("png", "image/png"), - ("jpg", "image/jpeg"), - ("jpeg", "image/jpeg"), - ("gif", "image/gif"), - ("webp", "image/webp"), - ("tif", "image/tiff"), - ("tiff", "image/tiff"), - ("bmp", "image/bmp"), - ("vctemplate", "application/x-vectorcraft-template"), - ("dxf", "image/vnd.dxf"), - ("emf", "image/emf"), - ("wmf", "image/wmf"), - ("eps", "image/x-eps"), - ("af", "application/x-affinity"), - ("afdesign", "application/x-affinity"), - ("afpub", "application/x-affinity"), - ]; - - /// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every - /// other one. - const LINUX_SHARED_TYPES: &[&str] = &[ - "image/svg+xml", - "image/svg+xml-compressed", - "application/pdf", - "application/illustrator", // brand-ok: shared-mime-info's type - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/tiff", - "image/bmp", - "image/vnd.dxf", - "image/emf", - "image/wmf", - "image/x-eps", - ]; - - /// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a - /// glob, with the package's `` rule for that type (its lines joined, indentation removed). - const LINUX_BY_CONTENT: &[(&str, &str)] = &[( - "af", - // `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs). - r#""#, - )]; - - /// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but - /// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't - /// have. - #[test] - fn the_linux_desktop_entry_opens_every_readable_format() { - let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop"); - let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml"); - let listed: Vec<&str> = - desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect(); - // Each type the MIME package declares, with the extensions of its globs and its `` - // rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives. - let declared: Vec<(&str, Vec<&str>, String)> = mime - .split("").next().unwrap_or_default(); - let globs = body.split("")) { - (Some(start), Some(end)) => body[start..end + "".len()].lines().map(str::trim).collect(), - _ => String::new(), - }; - (block.split('"').next().unwrap_or_default(), globs, magic) - }) - .collect(); - let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect(); - let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect(); - associated.sort_unstable(); - typed.sort_unstable(); - typed.dedup(); - assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app"); - for (e, t) in LINUX_TYPES { - assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})"); - let package = declared.iter().find(|(name, ..)| name == t); - if LINUX_SHARED_TYPES.contains(t) { - assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again"); - continue; - } - let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") }; - if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) { - assert!(!globs.contains(e), "{t} matches .{e} files by content only"); - assert_eq!(magic, rule, "{t}'s magic rule for .{e} files"); - } else { - assert!(globs.contains(e), "{t} has no *.{e} glob"); - } - } - for t in &listed { - assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open"); - } - for (t, globs, _) in &declared { - assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}"); - for g in globs { - assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}"); - } - } - } -} +mod tests; diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs new file mode 100644 index 000000000..67a5a4a08 --- /dev/null +++ b/apps/vectorcraft/src/prefs.rs @@ -0,0 +1,107 @@ +use super::*; + +/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), +/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). +pub(crate) fn prefs_path() -> Option { + prefs_dir::prefs_path_for("VectorCraft", "vectorcraft") +} + +/// The same place under the project's former name (DrawCraft): read once if there are no +/// VectorCraft preferences yet, so settings survive the rename. +fn legacy_prefs_path() -> Option { + prefs_dir::prefs_path_for("DrawCraft", "drawcraft") +} + +/// Where the log files live: `logs` in the preferences folder (see `logging`). +pub(crate) fn log_dir() -> Option { + Some(prefs_path()?.parent()?.join("logs")) +} + +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. +pub(crate) fn prefs_enabled() -> bool { + std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() +} + +/// The saved UI preferences, read before the window opens (they hold its size and position). +/// A file read only in part, or with layouts converted from an older version, is copied beside +/// itself before anything saves over it. +pub(crate) fn read_prefs() -> Option { + if !prefs_enabled() { + return None; + } + if let Some(p) = prefs_path() + && let Ok(bytes) = std::fs::read(&p) + { + let read = vectorcraft_ui_egui::UiState::read_preferences(&bytes); + if read.as_ref().is_none_or(|read| !read.intact) { + keep_prefs_copy(&p, &bytes, "ui.unreadable"); + } + if read.as_ref().is_some_and(|read| read.converted) { + keep_prefs_copy(&p, &bytes, "ui.before-layout-migration"); + } + return read.map(|read| read.ui); + } + let bytes = legacy_prefs_path().and_then(|p| std::fs::read(p).ok())?; + decode_saved_prefs(&bytes) +} + +/// Saved preferences, with what this version cannot read of their docking data dropped and +/// older layouts converted (`vectorcraft_ui_egui::layout_migration`). +pub(crate) fn decode_saved_prefs(bytes: &[u8]) -> Option { + vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui) +} + +/// Set when `ui.json` needed a copy kept ([`keep_prefs_copy`]) and none could be: the file is +/// then left as it is rather than overwritten. +pub(crate) static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Copy `ui.json` beside it as `.json` or the first free `-N.json`, before the next +/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may +/// have written it), `ui.before-layout-migration` when its layouts were converted. +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)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; + } + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, + } + } + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); +} + +pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option) { + if !prefs_enabled() { + return; + } + if let Some(ui) = saved { + app.ui = ui.sanitized(); + } + vectorcraft_ui_egui::prefs_dialog::restore(app); +} + +pub(crate) fn save_prefs(app: &VectorcraftApp) { + if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + if let Some(p) = prefs_path() { + let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); + if let Ok(bytes) = prefs_bytes(app) { + // Preferences are best effort: a failed write keeps the previous file. + let _ = fileio::write_atomic(&p, &bytes); + } + } +} + +/// The preferences file's contents: the UI state with the engine preferences. +pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { + let mut ui = app.ui.clone(); + ui.engine_prefs = app.session.prefs.to_json(); + serde_json::to_vec_pretty(&ui) +} diff --git a/apps/vectorcraft/src/system_fonts.rs b/apps/vectorcraft/src/system_fonts.rs index 6752aef67..67bbf7843 100644 --- a/apps/vectorcraft/src/system_fonts.rs +++ b/apps/vectorcraft/src/system_fonts.rs @@ -1,39 +1,56 @@ -//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders -//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports, -//! MCP) both install it; the CLI shares this file. +//! Windows: the font files in DirectWrite's system font collection and those of the fonts GDI has +//! loaded, scanned with the font folders (`vectorcraft_text::set_platform_font_files`). The desktop +//! app and `vectorcraft-cli` (exports, MCP) both install it; the CLI shares this file. //! //! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their //! own folders, without installing them: the font folders and the registry don't list them. Since -//! Windows 10 (version 1607) DirectWrite's system font collection holds them too +//! Windows 10 (version 1607) DirectWrite's system font collection is meant to hold them too //! (, //! "Support for Adobe Typekit and other font-service clients"), so VectorCraft lists them as other -//! apps do, reading the files in place (#579). +//! apps do, reading the files in place (#579). It doesn't always: on some machines the fonts +//! Creative Cloud loads are in GDI's font list (Word and the font dialogs show them) but not in +//! the collection (#1258). So the families GDI lists that the collection doesn't name are asked +//! for one by one: a GDI font selected into a device context gives the DirectWrite font face +//! GDI uses (`IDWriteGdiInterop::CreateFontFaceFromHdc`), and that face its files. //! -//! DirectWrite is COM, which has no safe API, hence the scoped `unsafe_code` allowance. +//! DirectWrite is COM and GDI a C API, neither with a safe API, hence the scoped `unsafe_code` +//! allowance. #![allow(unsafe_code)] -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Mutex; +use windows::Win32::Foundation::LPARAM; use windows::Win32::Graphics::DirectWrite::{ - DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory3, IDWriteFontSet, IDWriteLocalFontFileLoader, + DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_PROPERTY_ID_WIN32_FAMILY_NAME, DWriteCreateFactory, IDWriteFactory3, IDWriteFontFile, IDWriteFontSet, + IDWriteGdiInterop, IDWriteLocalFontFileLoader, IDWriteStringList, +}; +use windows::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateFontIndirectW, DEFAULT_CHARSET, DeleteDC, DeleteObject, EnumFontFamiliesExW, HDC, HGDIOBJ, LOGFONTW, RASTER_FONTTYPE, + SelectObject, TEXTMETRICW, }; use windows::core::Interface; /// The most fonts read from the collection (a system has a few thousand). const MAX_FONTS: u32 = 1 << 16; +/// The most families and faces taken from GDI's font list. +const MAX_GDI_FONTS: usize = 1 << 16; +/// The most files one font face is made of (one, but for a few Type 1 fonts). +const MAX_FACE_FILES: u32 = 8; /// The longest file path taken, in UTF-16 units (Windows' own limit). const MAX_PATH: u32 = 32_767; -/// Have the font scans read the font files DirectWrite lists. +/// Have the font scans read the font files DirectWrite and GDI list. pub fn install() { vectorcraft_text::set_platform_font_files(font_files); } /// The local files of the fonts in DirectWrite's system font collection as it is now (fonts -/// loaded since the last call included). None before Windows 10, or when DirectWrite fails: the -/// font folders and the registry still list the installed fonts. +/// loaded since the last call included), with those of the fonts GDI lists that the collection +/// leaves out. None before Windows 10, or when DirectWrite fails: the font folders and the +/// registry still list the installed fonts. fn font_files() -> Vec { - match collection_files() { + match listed_files() { Ok(files) => files.into_iter().collect(), Err(e) => { log::debug!("DirectWrite's system fonts aren't listed: {e}"); @@ -42,30 +59,48 @@ fn font_files() -> Vec { } } -fn collection_files() -> windows::core::Result> { +fn listed_files() -> windows::core::Result> { // SAFETY: plain DirectWrite calls on interfaces it returned, each checked for failure; the // collection is asked to check for fonts loaded since the factory last looked. - let set = unsafe { + let (factory, set) = unsafe { let factory: IDWriteFactory3 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; let mut collection = None; factory.GetSystemFontCollection(false, &mut collection, true)?; - let Some(collection) = collection else { return Ok(BTreeSet::new()) }; - collection.GetFontSet()? + (factory, collection.map(|c| c.GetFontSet()).transpose()?) }; + let mut files = set.as_ref().map(collection_files).unwrap_or_default(); + // GDI's own names for the collection's families, the names GDI lists them by. + // SAFETY: a list DirectWrite returns, read by `strings`. + let named = set.as_ref().and_then(|s| unsafe { s.GetPropertyValues(DWRITE_FONT_PROPERTY_ID_WIN32_FAMILY_NAME) }.ok()); + let named: BTreeSet = named.as_ref().map(strings).unwrap_or_default().iter().map(|n| n.to_lowercase()).collect(); + match gdi_files(&factory, &named) { + Ok(more) => files.extend(more), + Err(e) => log::debug!("GDI's fonts aren't listed: {e}"), + } + Ok(files) +} + +fn collection_files(set: &IDWriteFontSet) -> BTreeSet { // SAFETY: a count of the set's fonts. let count = unsafe { set.GetFontCount() }.min(MAX_FONTS); // Faces of one file (a collection's, a variable font's instances) name it once. - Ok((0..count).filter_map(|i| local_path(&set, i)).collect()) + (0..count).filter_map(|i| local_path(set, i)).collect() } /// The path of the file holding font `i` of `set`, when it is a local file (not a downloadable /// font, nor one in memory). fn local_path(set: &IDWriteFontSet, i: u32) -> Option { - // SAFETY: `i` is below the set's count; the key DirectWrite hands back stays valid while - // `file` (which owns it) lives, and is passed back with its own size; the path buffer is - // sized from DirectWrite's answer plus the terminating NUL. + // SAFETY: `i` is below the set's count. + let file = unsafe { set.GetFontFaceReference(i).ok()?.GetFontFile().ok()? }; + file_path(&file) +} + +/// The path of `file`, when it is a local file. +fn file_path(file: &IDWriteFontFile) -> Option { + // SAFETY: the key DirectWrite hands back stays valid while `file` (which owns it) lives, and is + // passed back with its own size; the path buffer is sized from DirectWrite's answer plus the + // terminating NUL. unsafe { - let file = set.GetFontFaceReference(i).ok()?.GetFontFile().ok()?; let loader: IDWriteLocalFontFileLoader = file.GetLoader().ok()?.cast().ok()?; let (mut key, mut size) = (std::ptr::null_mut(), 0u32); file.GetReferenceKey(&mut key, &mut size).ok()?; @@ -79,6 +114,159 @@ fn local_path(set: &IDWriteFontSet, i: u32) -> Option { } } +/// The strings of `list`. +fn strings(list: &IDWriteStringList) -> Vec { + // SAFETY: indices below the list's count; each buffer is sized from DirectWrite's answer plus + // the terminating NUL. + let count = unsafe { list.GetCount() }.min(MAX_FONTS); + (0..count) + .filter_map(|i| unsafe { + let len = list.GetStringLength(i).ok()?; + if len > MAX_PATH { + return None; + } + let mut s = vec![0u16; len as usize + 1]; + list.GetString(i, &mut s).ok()?; + Some(String::from_utf16_lossy(s.get(..len as usize)?)) + }) + .collect() +} + +/// A memory device context, deleted when dropped. +struct MemoryDc(HDC); + +impl MemoryDc { + fn new() -> windows::core::Result { + // SAFETY: a device context compatible with the screen, owned by the guard. + let dc = unsafe { CreateCompatibleDC(None) }; + if dc.is_invalid() { Err(windows::core::Error::from_thread()) } else { Ok(Self(dc)) } + } +} + +impl Drop for MemoryDc { + fn drop(&mut self) { + // SAFETY: the guard's own device context, with its first font back in it. + // Failing to delete it leaks a handle, which there's nothing to do about. + let _ = unsafe { DeleteDC(self.0) }; + } +} + +/// The files of the faces of the font families GDI lists that DirectWrite's collection doesn't +/// name (`named`, lower case). +fn gdi_files(factory: &IDWriteFactory3, named: &BTreeSet) -> windows::core::Result> { + // SAFETY: a DirectWrite call checked for failure. + let interop = unsafe { factory.GetGdiInterop()? }; + let dc = MemoryDc::new()?; + let mut families = BTreeSet::new(); + enum_fonts(&dc, [0; 32], add_family, &mut families); + let mut faces = Vec::new(); + for family in families.iter().filter(|f| !named.contains(&face_name(f).to_lowercase())) { + enum_fonts(&dc, *family, add_face, &mut faces); + } + // A face listed once for each of its character sets is read once. + faces.sort_by_key(face_key); + faces.dedup_by_key(|f| face_key(f)); + // Asking for a face's files takes about a third of a millisecond, and the fonts are looked at + // every few seconds (#2576): the faces still listed keep the files they had while those exist. + // A face no longer listed is forgotten, so one activated again is asked for anew, and one + // whose files couldn't be read (GDI or DirectWrite failed, or it has no local file) is asked + // again at the next look. + let mut known = KNOWN_FACES.lock().unwrap_or_else(|e| e.into_inner()); + let mut now = BTreeMap::new(); + for f in &faces { + let key = face_key(f); + let files = match known.remove(&key) { + Some(files) if !files.is_empty() && files.iter().all(|p| std::path::Path::new(p).is_file()) => files, + _ => face_files(&interop, &dc, f), + }; + now.insert(key, files); + } + *known = now; + Ok(known.values().flatten().cloned().collect()) +} + +/// A GDI face: its name (NUL-padded), weight and whether it's italic. +type FaceKey = ([u16; 32], i32, u8); + +fn face_key(f: &LOGFONTW) -> FaceKey { + (f.lfFaceName, f.lfWeight, f.lfItalic) +} + +/// The files of the faces GDI listed at the last look ([`gdi_files`]). +static KNOWN_FACES: Mutex>> = Mutex::new(BTreeMap::new()); + +/// Have GDI call `add` with `out` for each font of the family named `family` (NUL-padded), or for +/// one font of each family when it's empty. +fn enum_fonts( + dc: &MemoryDc, + family: [u16; 32], + add: unsafe extern "system" fn(*const LOGFONTW, *const TEXTMETRICW, u32, LPARAM) -> i32, + out: &mut T, +) { + let query = LOGFONTW { lfCharSet: DEFAULT_CHARSET, lfFaceName: family, ..Default::default() }; + // SAFETY: `add` reads the fonts GDI passes and casts the LPARAM back to the `T` it is, which + // outlives the call (GDI calls back before returning). Its answer is the last callback's. + let _ = unsafe { EnumFontFamiliesExW(dc.0, &query, Some(add), LPARAM(std::ptr::from_mut(out) as isize), 0) }; +} + +/// A GDI font list callback adding the font's family name to the `BTreeSet<[u16; 32]>` in `out`. +unsafe extern "system" fn add_family(font: *const LOGFONTW, _: *const TEXTMETRICW, kind: u32, out: LPARAM) -> i32 { + // SAFETY: GDI passes a valid font, and `out` is the set `enum_fonts` was given. + let (Some(font), Some(out)) = (unsafe { font.as_ref() }, unsafe { (out.0 as *mut BTreeSet<[u16; 32]>).as_mut() }) else { + return 0; + }; + // Bitmap fonts have no file DirectWrite reads; `@` names are vertical writing's copies. + if kind & RASTER_FONTTYPE == 0 && font.lfFaceName.first().is_some_and(|&c| c != 0 && c != u16::from(b'@')) { + out.insert(font.lfFaceName); + } + i32::from(out.len() < MAX_GDI_FONTS) +} + +/// A GDI font list callback adding the font to the `Vec` in `out`. +unsafe extern "system" fn add_face(font: *const LOGFONTW, _: *const TEXTMETRICW, kind: u32, out: LPARAM) -> i32 { + // SAFETY: GDI passes a valid font, and `out` is the list `enum_fonts` was given. + let (Some(font), Some(out)) = (unsafe { font.as_ref() }, unsafe { (out.0 as *mut Vec).as_mut() }) else { + return 0; + }; + if kind & RASTER_FONTTYPE == 0 { + out.push(*font); + } + i32::from(out.len() < MAX_GDI_FONTS) +} + +/// `name`, NUL-padded, as a string. +fn face_name(name: &[u16; 32]) -> String { + String::from_utf16_lossy(name.split(|&c| c == 0).next().unwrap_or_default()) +} + +/// The local files of the DirectWrite font face GDI uses for `font`. +fn face_files(interop: &IDWriteGdiInterop, dc: &MemoryDc, font: &LOGFONTW) -> Vec { + // SAFETY: the font is selected into the guard's device context only while DirectWrite reads + // it, then the context's own font goes back and the font is deleted; the files array is + // sized from DirectWrite's count. + unsafe { + let gdi_font = CreateFontIndirectW(font); + if gdi_font.is_invalid() { + return Vec::new(); + } + let first = SelectObject(dc.0, HGDIOBJ::from(gdi_font)); + let face = interop.CreateFontFaceFromHdc(dc.0); + SelectObject(dc.0, first); + // Failing to delete it leaks a handle, which there's nothing to do about. + let _ = DeleteObject(HGDIOBJ::from(gdi_font)); + let Ok(face) = face else { return Vec::new() }; + let mut count = 0; + if face.GetFiles(&mut count, None).is_err() || count == 0 || count > MAX_FACE_FILES { + return Vec::new(); + } + let mut files = vec![None; count as usize]; + if face.GetFiles(&mut count, Some(files.as_mut_ptr())).is_err() { + return Vec::new(); + } + files.iter().flatten().filter_map(file_path).collect() + } +} + #[cfg(test)] mod tests { /// DirectWrite lists the installed fonts, by their files' full paths. @@ -89,4 +277,34 @@ mod tests { let fonts = std::path::Path::new(&std::env::var("WINDIR").unwrap()).join("Fonts").to_string_lossy().to_lowercase(); assert!(files.iter().any(|f| f.to_lowercase().starts_with(&fonts)), "{} files, {:?}", files.len(), files.first()); } + + /// A font loaded with GDI from a file without an extension outside the font folders, as + /// Creative Cloud loads Adobe Fonts, is listed by its file even though DirectWrite's collection + /// doesn't hold it (#1258): fonts loaded for one process never are. + #[test] + fn fonts_only_gdi_has_loaded_are_listed_by_their_files() { + use windows::Win32::Graphics::Gdi::{AddFontResourceExW, FR_PRIVATE, RemoveFontResourceExW}; + let dir = std::env::temp_dir().join(format!("vc-gdi-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("29457"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf"); + let mut data = std::fs::read(source).unwrap(); + // Renamed (to a name as long) so no installed Source Sans 3 stands in for it. + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16("GdiOnly Sans3")), (b"Source Sans 3".to_vec(), b"GdiOnly Sans3".to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + std::fs::write(&file, data).unwrap(); + let path = windows::core::HSTRING::from(file.as_os_str()); + assert_eq!(unsafe { AddFontResourceExW(&path, FR_PRIVATE, None) }, 1); + let files = super::font_files(); + let _ = unsafe { RemoveFontResourceExW(&path, FR_PRIVATE.0, None) }; + let _ = std::fs::remove_dir_all(&dir); + let wanted = file.to_string_lossy().to_lowercase(); + assert!(files.iter().any(|f| f.to_lowercase() == wanted), "{wanted} not in {} files", files.len()); + } } diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs new file mode 100644 index 000000000..e6096c54a --- /dev/null +++ b/apps/vectorcraft/src/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState { + vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } +} + +/// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an +/// override that can't fire (#487), an entry for a command this version lacks, and an old set +/// name. +#[test] +fn shortcut_overrides_from_an_earlier_version_load() { + use serde_json::json; + use vectorcraft_ui_egui::{keymap, shortcut_editor}; + let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap(); + raw["shortcut_overrides"] = json!({ + "tool:pen": "Shift+9", + "edit.preferences": "", + "panel:layers": "Cmd+Alt+Shift+F7", + "tool:groupSelection": "Shift+ShiftLeft", + "test.unknown": "Cmd+Alt+Shift+F9", + "view.grid": "Cmd+Period", + }); + raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0); + let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized(); + assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1); + let km = keymap::keymap_with(&ui.shortcut_overrides); + let text = |key: &str| keymap::effective_with(&km, key); + assert_eq!(text("tool:pen").as_deref(), Some("Shift+9")); + assert_eq!(text("edit.preferences"), None); + assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7")); + assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default"); + assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period")); + let period = keymap::vc_chord("Cmd+.").unwrap(); + assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string())); + assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept"); + assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); +} + +#[test] +fn opening_files_unminimizes_and_focuses_the_window() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx())); + // Nothing paints in the test: drop the font atlas upload egui expects to be applied. + out.textures_delta.clear(); + let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands; + assert!(cmds.contains(&egui::ViewportCommand::Minimized(false))); + assert!(cmds.contains(&egui::ViewportCommand::Focus)); +} + +#[test] +fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { + use serde_json::json; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.ui.toolbar = false; + app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); + app.ui.custom_workspaces = + vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; + app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")]; + let original = serde_json::to_value(&app.ui).unwrap(); + let valid_location = original["docking_hidden"]["layers"].clone(); + assert!(valid_location.is_object()); + for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] { + for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] { + let mut raw = original.clone(); + raw["docking"] = invalid_layout.clone(); + raw["docking_hidden"] = invalid_hidden.clone(); + raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); + raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); + raw["workspace_changes"][0]["docking"] = invalid_layout.clone(); + raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone(); + assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); + let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); + assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); + assert!(!loaded.toolbar); + assert!(loaded.docking.is_none()); + assert!(!loaded.docking_hidden.contains_key("layers")); + if invalid_hidden.is_object() { + assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location); + } else { + assert!(loaded.docking_hidden.is_empty()); + } + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Future"); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); + assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); + assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost"); + assert!(loaded.workspace_changes[0].docking.is_none()); + } + } + let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(roundtrip).unwrap(), original); + assert!( + decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(), + "unrelated fields retain normal strict parsing" + ); +} + +#[test] +fn an_unreadable_ui_json_is_kept_beside_it() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("vectorcraft-ui-prefs-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("ui.json"); + super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable"); + super::keep_prefs_copy(&path, b"not json", "ui.unreadable"); + super::keep_prefs_copy(&path, b"{}", "ui.before-layout-migration"); + let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); + let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); + let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); + assert_eq!(second, b"not json"); + assert_eq!(before, b"{}"); + assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); +} + +#[test] +fn the_image_trace_preview_setting_is_kept_across_a_restart() { + for preview in [false, true] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.image_trace_preview = preview; + let saved = prefs_bytes(&app).unwrap(); + let loaded = decode_saved_prefs(&saved).unwrap().sanitized(); + assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart"); + } +} + +#[test] +fn the_system_title_bar_preference_keeps_the_window_decorations() { + use craft_window::{Frame, Layout}; + + // The System Title Bar preference (Preferences › User Interface) is read the same way on + // every platform; a missing or unreadable value is `false`. + assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + assert!(!super::opted_into_system_title_bar(Some(&off))); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + assert!(super::opted_into_system_title_bar(Some(&on))); + + // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title + // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native + // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws + // our own title bar — unless the user opted into the system title bar, which forces the + // native frame back on. + let app = Frame::App(Layout::default()); + let desktop = Frame::Desktop; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); + assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); + assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); + assert!(!super::custom_titlebar_from(&desktop, true)); + } + // macOS and Windows keep today's behavior, independent of the probed frame: the pref still + // forces the native frame back on where a custom one would otherwise be drawn. + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); + assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); + assert!(!super::custom_titlebar_from(&desktop, true)); + } + + assert_eq!(super::native_options(false).viewport.decorations, Some(true)); + assert_eq!(super::native_options(true).viewport.decorations, Some(false)); +} + +/// The file extensions the macOS bundle declares: its document types and its own exported type. +fn plist_extensions(plist: &str) -> Vec<&str> { + ["CFBundleTypeExtensions", "public.filename-extension"] + .iter() + .flat_map(|key| plist.split(key).skip(1)) + .filter_map(|rest| rest.split("").next()) + .flat_map(|array| array.split("").skip(1)) + .filter_map(|s| s.split("").next()) + .collect() +} + +/// Finder offers the app for every file File › Open reads (#295, #354) but those with an +/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app +/// rather than to AppKit's document machinery. +#[test] +fn the_macos_bundle_opens_every_readable_format() { + let plist = include_str!("../../../packaging/macos/Info.plist.in"); + let declared = plist_extensions(plist); + for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) { + assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}"); + } + for e in &declared { + assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open"); + } + assert!(!plist.contains("NSDocumentClass"), "not an NSDocument app: AppKit would refuse the files"); + let types = plist.matches("CFBundleTypeName").count(); + assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank"); + assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned"); +} + +/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS` +/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml` +/// declares. +const LINUX_TYPES: &[(&str, &str)] = &[ + ("vectorcraft", "application/x-vectorcraft"), + ("drawcraft", "application/x-vectorcraft"), + ("svg", "image/svg+xml"), + ("svgz", "image/svg+xml-compressed"), + ("pdf", "application/pdf"), + ("ai", "application/illustrator"), // brand-ok: shared-mime-info's type + ("ait", "application/x-ai-template"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("gif", "image/gif"), + ("webp", "image/webp"), + ("tif", "image/tiff"), + ("tiff", "image/tiff"), + ("bmp", "image/bmp"), + ("vctemplate", "application/x-vectorcraft-template"), + ("dxf", "image/vnd.dxf"), + ("emf", "image/emf"), + ("wmf", "image/wmf"), + ("eps", "image/x-eps"), + ("af", "application/x-affinity"), + ("afdesign", "application/x-affinity"), + ("afpub", "application/x-affinity"), +]; + +/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every +/// other one. +const LINUX_SHARED_TYPES: &[&str] = &[ + "image/svg+xml", + "image/svg+xml-compressed", + "application/pdf", + "application/illustrator", // brand-ok: shared-mime-info's type + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/tiff", + "image/bmp", + "image/vnd.dxf", + "image/emf", + "image/wmf", + "image/x-eps", +]; + +/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a +/// glob, with the package's `` rule for that type (its lines joined, indentation removed). +const LINUX_BY_CONTENT: &[(&str, &str)] = &[( + "af", + // `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs). + r#""#, +)]; + +/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but +/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't +/// have. +#[test] +fn the_linux_desktop_entry_opens_every_readable_format() { + let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop"); + let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml"); + let listed: Vec<&str> = + desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect(); + // Each type the MIME package declares, with the extensions of its globs and its `` + // rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives. + let declared: Vec<(&str, Vec<&str>, String)> = mime + .split("").next().unwrap_or_default(); + let globs = body.split("")) { + (Some(start), Some(end)) => body[start..end + "".len()].lines().map(str::trim).collect(), + _ => String::new(), + }; + (block.split('"').next().unwrap_or_default(), globs, magic) + }) + .collect(); + let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect(); + let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect(); + associated.sort_unstable(); + typed.sort_unstable(); + typed.dedup(); + assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app"); + for (e, t) in LINUX_TYPES { + assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})"); + let package = declared.iter().find(|(name, ..)| name == t); + if LINUX_SHARED_TYPES.contains(t) { + assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again"); + continue; + } + let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") }; + if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) { + assert!(!globs.contains(e), "{t} matches .{e} files by content only"); + assert_eq!(magic, rule, "{t}'s magic rule for .{e} files"); + } else { + assert!(globs.contains(e), "{t} has no *.{e} glob"); + } + } + for t in &listed { + assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open"); + } + for (t, globs, _) in &declared { + assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}"); + for g in globs { + assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}"); + } + } +} diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs new file mode 100644 index 000000000..3d538ec88 --- /dev/null +++ b/apps/vectorcraft/src/titlebar.rs @@ -0,0 +1,120 @@ +use super::*; + +/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets +/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches +/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon. +fn app_icon() -> egui::IconData { + #[cfg(target_os = "macos")] + let png: &[u8] = include_bytes!("../../../assets/app-icon/vectorcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] + let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png"); + eframe::icon_data::from_png_bytes(png).unwrap_or_default() +} + +/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the +/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too. +#[cfg(windows)] +pub(crate) fn set_taskbar_icon(w: &winit::window::Window) { + use winit::platform::windows::WindowExtWindows as _; + let icon = app_icon(); + // An icon that can't be made leaves the generic one: nothing else depends on it. + if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { + w.set_taskbar_icon(Some(i)); + } +} + +/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports. +#[cfg(feature = "wgpu")] +pub(crate) fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { + format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) +} + +/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). +/// macOS keeps its traffic lights over the integrated title strip. +/// +/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the +/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe. +#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] +pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); + +/// Whether the user asked for the system title bar in Preferences › User Interface › System Title +/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is +/// `false` (the default). This preference forces the desktop/native frame back on. +pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate +/// decided and the user's System Title Bar preference. +/// +/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame +/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with +/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it +/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), +/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly +/// when `craft-window` says the app owns the frame **and** the user has not opted into the system +/// title bar; the preference always forces the native frame back on. +/// +/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the +/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but +/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons +/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not +/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool { + matches!(frame, craft_window::Frame::App(_)) && !opted_into_system +} + +/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see +/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); +/// Windows draws its own caption unless the user opted into the system title bar. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool { + CUSTOM_TITLEBAR && !opted_into_system +} + +/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its +/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System +/// Title Bar preference. Call once at startup, before [`native_options`]. +/// +/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through +/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs +/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native +/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. +pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); + let opted_into_system = opted_into_system_title_bar(saved); + #[cfg(target_os = "linux")] + { + let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); + // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window + // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are + // present, the same two conditions the launch path checks below — that run is effectively + // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so + // the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); + if choice.overridden().is_none() && forced_to_x11 { + return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); + } + } + custom_titlebar_from(choice.frame(), opted_into_system) +} + +pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("VectorCraft") + .with_inner_size(window::DEFAULT_SIZE) + .with_min_inner_size(window::MIN_SIZE) + .with_drag_and_drop(true) + .with_decorations(!custom_titlebar) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + .with_icon(app_icon()) + .with_app_id("ai.storyteller.vectorcraft"), + #[cfg(feature = "windows7")] + renderer: eframe::Renderer::Glow, + ..Default::default() + } +} diff --git a/apps/vectorcraft/src/window_frame.rs b/apps/vectorcraft/src/window_frame.rs index 950673da8..e69806ac2 100644 --- a/apps/vectorcraft/src/window_frame.rs +++ b/apps/vectorcraft/src/window_frame.rs @@ -11,10 +11,17 @@ //! //! Windows sizes a maximize by the frame its styles describe, so a window maximized while its //! caption is gone is sized without one: its rect covers the whole monitor, taskbar included, and -//! the shell reads a caption-less window covering its monitor as a full-screen app (#1977). The -//! guard therefore also trims a parked maximize's rect back to the work area plus its own frame, -//! clear of the taskbar, as PhotoCraft does (storytold/photocraft#3308). winit pins the client -//! area to the work area either way (`WM_NCCALCSIZE`), so the app's pixels do not move. +//! the shell reads a caption-less window covering its monitor as a full-screen app (#1977). With +//! the taskbar on the top or a side edge, the strip between the window's edge and the client area +//! that winit pins to the work area (`WM_NCCALCSIZE`) is drawn as a classic title bar (#3242, +//! #3723). The guard therefore also moves a parked maximize's rect back inside the work area on +//! every edge, plus its own frame, clear of the taskbar wherever it is, and repaints the window, as +//! PhotoCraft does (storytold/photocraft#3308, storytold/photocraft#5237). +//! +//! An auto-hidden taskbar reserves no work area, so that move leaves a maximize covering the whole +//! monitor. In PhotoCraft the taskbar then did not show when the pointer reached its edge +//! (storytold/photocraft#4845). On each edge with an auto-hidden taskbar the guard stops the window +//! one pixel short of the monitor instead, as PhotoCraft does (storytold/photocraft#6878). /// Run once per frame; a no-op once the window is borderless. pub type WindowFrameFn = Box; @@ -24,7 +31,8 @@ pub use platform::service; #[cfg(all(windows, feature = "wgpu"))] mod platform { use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; - use winsafe::{HWND, HwndPlace, POINT, SIZE, co}; + use winsafe::prelude::Handle; + use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co}; /// A guard that keeps the window borderless each frame, or `None` with the system title bar /// (the window is decorated) or when its handle can't be found. @@ -76,12 +84,14 @@ mod platform { HwndPlace::None, POINT::new(), SIZE::new(), - co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, ); } - /// [`super::clamped_height`] for this frame's window: trim a maximized window whose rect runs - /// under the taskbar back to the work area plus its frame. No-op otherwise. + /// [`super::clamped_rect`] for this frame's window: move a maximized window whose rect runs + /// under a taskbar on any edge back inside the work area plus its frame, and repaint it. A + /// maximized window that covers its whole monitor instead stops one pixel short of each edge + /// with an auto-hidden taskbar ([`super::short_of_autohide_taskbars`]). No-op otherwise. fn keep_clear_of_the_taskbar(hwnd: &HWND) { if !hwnd.IsZoomed() { return; @@ -90,17 +100,48 @@ mod platform { return; }; let (m, w) = (mi.rcMonitor, mi.rcWork); - let Some(height) = - super::clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) - else { + let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)); + // The taskbars are looked up (a few `FindWindowEx` calls) only while the window covers its + // whole monitor, which it no longer does once trimmed. + let Some((left, top, right, bottom)) = super::clamped_rect(rect, monitor, work).or_else(|| { + super::covers(rect, monitor).then(|| super::short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten() + }) else { return; }; + let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else { + return; + }; + // The frame moves while winit keeps the client rect on the work area: `NOCOPYBITS` and the + // invalidation have the whole window drawn again instead of the old frame's pixels being + // copied into the custom title bar. let _ = hwnd.SetWindowPos( HwndPlace::None, - POINT::new(), - SIZE { cx: r.right - r.left, cy: height }, - co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + POINT { x: left, y: top }, + SIZE { cx: width, cy: height }, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE, ); + let _ = hwnd.InvalidateRect(None, false); + } + + /// The edges of `monitor` that have a taskbar, shown or auto-hidden: the primary monitor's + /// `Shell_TrayWnd` window and the other monitors' `Shell_SecondaryTrayWnd` windows. + fn taskbar_edges(monitor: super::Rect) -> Vec { + let mut edges = Vec::new(); + for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] { + let mut after: Option = None; + // A monitor has at most one taskbar; the bound ends the walk if the shell keeps + // returning windows. + for _ in 0..64 { + let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else { + break; + }; + if let Ok(r) = tray.GetWindowRect() { + edges.extend(super::taskbar_edge((r.left, r.top, r.right, r.bottom), monitor)); + } + after = Some(tray); + } + } + edges } } @@ -111,35 +152,115 @@ mod platform { } } -/// How far a parked maximize reaches past its monitor on the left, top and right: the resize border -/// (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not parked but -/// being dragged or snapped, and is left alone. +/// How far a parked maximize reaches past a monitor or work-area edge: the resize border (about +/// 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not parked but being +/// dragged or snapped, and is left alone. const FRAME: i32 = 32; -/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it already -/// does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at its maximize -/// place. `monitor` is the monitor's full rect and `work` its work area (the monitor less the -/// taskbar) in the same coordinates. The parked check is against the full monitor, because that is -/// where Windows puts a caption-less maximize; the trim is against the work area. Platform-neutral -/// so it is tested everywhere. +/// The rect to move a maximize to so it stays inside the work area on every edge, plus the resize +/// border it was parked with, or `None` when it already does or `rect` (`left, top, right, +/// bottom`, desktop coordinates) is not parked at its maximize place. `monitor` is the monitor's +/// full rect and `work` its work area (the monitor less the taskbar) in the same coordinates. The +/// border is read from the edges that have no taskbar, and every edge must sit that far outside +/// either the monitor (where Windows puts a caption-less maximize) or the work area (where the +/// guard puts it), so a rect it already moved is left alone. Platform-neutral so it is tested +/// everywhere. #[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))] -fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option { +fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> { let (l, t, r, b) = rect; - let (ml, mt, mr, _) = monitor; - let (_, _, _, wb) = work; - let side = ml - l; - if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) { + let (ml, mt, mr, mb) = monitor; + let (wl, wt, wr, wb) = work; + if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb { return None; } - if b <= wb + side { + // On an edge with a taskbar, the offsets from the monitor and from the work area can both + // look like a border, so only the edges without one are read. + let border = [ + (ml == wl).then(|| ml.checked_sub(l)).flatten(), + (mt == wt).then(|| mt.checked_sub(t)).flatten(), + (mr == wr).then(|| r.checked_sub(mr)).flatten(), + (mb == wb).then(|| b.checked_sub(mb)).flatten(), + ] + .into_iter() + .flatten() + .filter(|n| (0..=FRAME).contains(n)) + .min()?; + let parked = |monitor: Option, work: Option| monitor == Some(border) || work == Some(border); + if !parked(ml.checked_sub(l), wl.checked_sub(l)) + || !parked(mt.checked_sub(t), wt.checked_sub(t)) + || !parked(r.checked_sub(mr), r.checked_sub(wr)) + || !parked(b.checked_sub(mb), b.checked_sub(wb)) + { return None; } - Some(wb + side - t) + let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?); + (corrected != rect).then_some(corrected) +} + +/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive. +#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))] +type Rect = (i32, i32, i32, i32); + +/// A monitor edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))] +enum Edge { + Left, + Top, + Right, + Bottom, +} + +/// Whether `rect` reaches every edge of `monitor`, which the shell treats as a full-screen app. +#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))] +fn covers(rect: Rect, monitor: Rect) -> bool { + rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3 +} + +/// The edge of `monitor` that a taskbar with the rect `taskbar` sits on: the edge nearest the +/// taskbar's center, or `None` when the taskbar does not overlap the monitor. An auto-hidden +/// taskbar is parked mostly past its edge with a sliver left on the monitor, so its center can lie +/// outside the monitor. +#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))] +fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option { + let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3)); + let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3)); + if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb { + return None; + } + // The center and the distances are doubled, so nothing is rounded. + let (cx, cy) = (tl + tr, tt + tb); + [(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)] + .into_iter() + .min_by_key(|&(_, distance)| distance) + .map(|(edge, _)| edge) +} + +/// A maximize that covers its monitor, trimmed to stop one pixel short of the monitor on each of +/// the `taskbars` edges where the work area reaches the monitor's edge (the taskbar there is +/// auto-hidden). `None` when `rect` does not cover the monitor or nothing is trimmed. +#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))] +fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option { + if !covers(rect, monitor) { + return None; + } + let (mut l, mut t, mut r, mut b) = rect; + for edge in taskbars { + match edge { + Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?, + Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?, + Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?, + Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?, + _ => {} + } + } + let trimmed = (l, t, r, b); + (l < r && t < b && trimmed != rect).then_some(trimmed) } #[cfg(test)] mod tests { - use super::clamped_height; + use super::{Edge, clamped_rect, short_of_autohide_taskbars, taskbar_edge}; #[test] fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() { @@ -148,27 +269,97 @@ mod tests { // window was parked with. let monitor = (0, 0, 1920, 1080); let work = (0, 0, 1920, 1046); - assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7))); + assert_eq!(clamped_rect((-7, -7, 1927, 1087), monitor, work), Some((-7, -7, 1927, 1046 + 7))); } #[test] fn a_maximize_already_clear_of_the_taskbar_is_left_alone() { // What Windows sizes a framed maximize to: the work area plus the frame. - assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None); + assert_eq!(clamped_rect((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None); } #[test] - fn a_maximize_beside_a_side_taskbar_is_left_alone() { - // Taskbar on the left: the work area starts further right, but the monitor's bottom is - // still clear, so the rect reaches nothing. - assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None); + fn a_maximize_stays_clear_of_a_taskbar_on_any_edge() { + // A taskbar on the top or a side edge (#3242, #3723): the rect moves inside the work area + // there and keeps its frame on the other edges. Once moved, it is left alone. + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (work, expected) in [ + ((0, 0, 1920, 1046), (-8, -8, 1928, 1054)), + ((0, 48, 1920, 1080), (-8, 40, 1928, 1088)), + ((0, 2, 1920, 1080), (-8, -6, 1928, 1088)), + ((0, 0, 1920, 1078), (-8, -8, 1928, 1086)), + ((48, 0, 1920, 1080), (40, -8, 1928, 1088)), + ((0, 0, 1872, 1080), (-8, -8, 1880, 1088)), + ] { + assert_eq!(clamped_rect(rect, monitor, work), Some(expected), "work area {work:?}"); + assert_eq!(clamped_rect(expected, monitor, work), None, "already moved, work area {work:?}"); + } + } + + #[test] + fn a_top_taskbar_is_cleared_on_any_monitor_and_at_any_frame_size() { + // A monitor above and left of the primary one, at frame sizes up to the limit. + let monitor = (-1920, -1080, 0, 0); + let work = (-1920, -1032, 0, 0); + for border in [0, 8, 16, 32] { + let rect = (-1920 - border, -1080 - border, border, border); + let expected = (-1920 - border, -1032 - border, border, border); + assert_eq!(clamped_rect(rect, monitor, work), Some(expected), "border {border}"); + assert_eq!(clamped_rect(expected, monitor, work), None, "border {border}"); + } + } + + #[test] + fn a_maximize_without_a_reserved_taskbar_is_left_alone() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None); } #[test] fn a_maximize_being_dragged_away_is_left_alone() { let monitor = (0, 0, 1920, 1080); let work = (0, 0, 1920, 1046); - assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved"); - assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized"); + assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved"); + assert_eq!(clamped_rect((-500, -7, 1427, 1087), monitor, work), None, "resized"); + let work = (0, 48, 1920, 1080); + assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "resized under a top taskbar"); + assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "snapped to half the screen"); + } + + #[test] + fn a_maximize_stops_a_pixel_short_of_an_auto_hidden_taskbar_edge() { + // An auto-hidden taskbar reserves no work area, so the work area is the whole monitor. + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (edge, expected) in [ + (Edge::Bottom, (-8, -8, 1928, 1079)), + (Edge::Top, (-8, 1, 1928, 1088)), + (Edge::Left, (1, -8, 1928, 1088)), + (Edge::Right, (-8, -8, 1919, 1088)), + ] { + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected), "{edge:?}"); + assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed, {edge:?}"); + assert_eq!(clamped_rect(expected, monitor, monitor), None, "left alone on the next frame, {edge:?}"); + } + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar"); + // A taskbar that reserves its work area is moved clear of by `clamped_rect` instead. + assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None, "shown taskbar"); + assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen"); + // A monitor above and left of the primary one. + let monitor = (-1920, -1080, 0, 0); + assert_eq!(short_of_autohide_taskbars((-1928, -1088, 8, 8), monitor, monitor, &[Edge::Top]), Some((-1928, -1079, 8, 8))); + } + + #[test] + fn a_taskbar_sits_on_the_monitor_edge_nearest_its_center() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown"); + assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden"); + assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top)); + assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left)); + assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right)); + assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor"); + assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow"); } } diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs index cf17976b5..814ba55fe 100644 --- a/apps/vectorcraft/src/windows_stylus.rs +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -5,30 +5,74 @@ //! second-button flag ("analogous to a mouse right button down", //! ): [`msg_hook`] notes //! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one. +//! +//! winit also drops which end of the pen is down. For each pointer message of a pen, [`msg_hook`] +//! reads the pen's `POINTER_PEN_INFO` and writes its pressure and end into the canvas's pen feed +//! ([`start`]): `PEN_FLAG_INVERTED` ("The pen is inverted") or `PEN_FLAG_ERASER` ("The eraser +//! button is pressed") is the eraser end +//! (). Pointer +//! messages also come while the pen hovers, so turning the pen over selects the Eraser tool before +//! it touches. A message of another pointer, a pen leaving detection range or the window, or a +//! mouse message that Windows did not make from a pen or a touch +//! () +//! clears the feed. #![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))] use std::cell::RefCell; +use std::sync::OnceLock; use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase}; +use vectorcraft_ui_egui::stylus::{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. const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020; +/// `POINTER_MESSAGE_FLAG_INRANGE` (`winuser.h`): not set on the message of a pointer that has left +/// detection range. +const POINTER_MESSAGE_FLAG_INRANGE: usize = 0x0002; /// At most this many pointer contacts wait for their touch event. const MAX_PENDING: usize = 16; +/// `PEN_FLAG_INVERTED` and `PEN_FLAG_ERASER` (`winuser.h`), in `POINTER_PEN_INFO::penFlags`. +const PEN_FLAG_INVERTED: u32 = 0x0002; +const PEN_FLAG_ERASER: u32 = 0x0004; +/// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure. +const PEN_MASK_PRESSURE: u32 = 0x0001; +/// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows +/// made from a pen or a touch. +const MI_WP_SIGNATURE: u32 = 0xFF51_5700; +const SIGNATURE_MASK: u32 = 0xFFFF_FF00; +/// `WM_MOUSEFIRST` to `WM_MOUSELAST` (`winuser.h`). +const MOUSE_MESSAGES: std::ops::RangeInclusive = 0x0200..=0x020E; +/// `WM_POINTERUPDATE`, `WM_POINTERDOWN`, `WM_POINTERUP`, `WM_POINTERENTER` and `WM_POINTERLEAVE` +/// (`winuser.h`). +const WM_POINTERUPDATE: u32 = 0x0245; +const WM_POINTERDOWN: u32 = 0x0246; +const WM_POINTERUP: u32 = 0x0247; +const WM_POINTERENTER: u32 = 0x0249; +const WM_POINTERLEAVE: u32 = 0x024A; thread_local! { static STROKES: RefCell = RefCell::new(BarrelStrokes::default()); } +/// The pen feed [`msg_hook`] writes, once [`start`] has made it. +static FEED: OnceLock = OnceLock::new(); + +/// The canvas's pen feed, which [`msg_hook`] writes on the UI thread before winit handles each +/// message, so a press never waits for it. +pub fn start() -> StylusFeed { + FEED.get_or_init(StylusFeed::on_ui_thread).clone() +} + /// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each /// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id -/// and whether the barrel button was held. +/// and whether the barrel button was held, and writes the pen's pressure and end into the feed. /// It never consumes the message. #[cfg(all(windows, not(target_vendor = "win7")))] #[allow(unsafe_code)] pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { - use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN}; + use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO}; + use windows::Win32::UI::WindowsAndMessaging::{GetMessageExtraInfo, MSG}; // SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this // call; `as_ref` gives `None` for a null pointer. let Some(msg) = (unsafe { msg.cast::().as_ref() }) else { @@ -41,9 +85,63 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { } }); } + let Some(feed) = FEED.get() else { + return false; + }; + let read_pen = || { + let mut info = POINTER_PEN_INFO::default(); + // SAFETY: `info` is a `POINTER_PEN_INFO` that lives for the call. The call fails for a + // pointer that is not a pen. + unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?; + let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0; + Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact)) + }; + // SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message. + let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32); + if let Some(sample) = feed_update(msg.message, msg.wParam.0, read_pen, from_mouse) { + feed.set_sample(sample); + } false } +/// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags` +/// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen +/// in contact that reports none; and the eraser end while it is inverted or its eraser button is +/// pressed. +fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample { + let pressure = match pressure { + 1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0, + _ if in_contact => 1.0, + _ => 0.0, + }; + PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 } +} + +/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse, +/// not from a pen or a touch. +fn is_mouse(extra_info: u32) -> bool { + extra_info & SIGNATURE_MASK != MI_WP_SIGNATURE +} + +/// What the feed is set to for a message with this `wParam`: `Some(Some(sample))` for a pointer +/// message of a pen in range (`read_pen`), `Some(None)` for a pointer message of another pointer, +/// a pointer leaving detection range or the window, or a mouse message from a mouse +/// (`from_mouse`), and `None` to leave it as it is. +fn feed_update( + message: u32, + wparam: usize, + read_pen: impl FnOnce() -> Option, + from_mouse: impl FnOnce() -> bool, +) -> Option> { + match message { + WM_POINTERENTER | WM_POINTERUPDATE | WM_POINTERDOWN | WM_POINTERUP if (wparam >> 16) & POINTER_MESSAGE_FLAG_INRANGE == 0 => Some(None), + WM_POINTERENTER | WM_POINTERUPDATE | WM_POINTERDOWN | WM_POINTERUP => Some(read_pen()), + WM_POINTERLEAVE => Some(None), + m if MOUSE_MESSAGES.contains(&m) && from_mouse() => Some(None), + _ => None, + } +} + /// Before egui reads a frame's input: the primary button of a contact that started with the /// barrel button held becomes the secondary button. pub fn raw_input_hook(raw: &mut egui::RawInput) { @@ -287,6 +385,59 @@ mod tests { assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]); } + /// `PEN_FLAG_BARREL` (`winuser.h`). + const PEN_FLAG_BARREL: u32 = 0x0001; + + #[test] + fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() { + let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser; + assert!(!eraser(0), "the tip"); + assert!(!eraser(PEN_FLAG_BARREL), "the barrel button"); + assert!(eraser(PEN_FLAG_INVERTED), "turned over"); + assert!(eraser(PEN_FLAG_ERASER), "the eraser button"); + assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_ERASER)); + assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL)); + } + + #[test] + 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, 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"); + assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0); + } + + #[test] + fn mouse_messages_from_a_pen_or_a_touch_are_told_apart() { + assert!(is_mouse(0), "a mouse"); + assert!(!is_mouse(0xFF51_5701), "a pen"); + assert!(!is_mouse(0xFF51_5780), "a touch"); + assert!(is_mouse(0xFF51_5600)); + } + + #[test] + fn pen_messages_set_the_feed_and_other_pointers_clear_it() { + let pen = PenSample { pressure: 0.0, eraser: true }; + // `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") }; + let no_pen = || -> Option { panic!("no pen to read") }; + for m in [WM_POINTERENTER, WM_POINTERUPDATE, WM_POINTERDOWN, WM_POINTERUP] { + assert_eq!(feed_update(m, in_range, || Some(pen), no_mouse), Some(Some(pen)), "{m:#x}"); + assert_eq!(feed_update(m, in_range, || None, no_mouse), Some(None), "a touch: {m:#x}"); + assert_eq!(feed_update(m, 7, no_pen, no_mouse), Some(None), "out of range: {m:#x}"); + } + assert_eq!(feed_update(WM_POINTERLEAVE, in_range, no_pen, no_mouse), Some(None)); + // WM_MOUSEMOVE and WM_LBUTTONDOWN. + assert_eq!(feed_update(0x0200, 0, no_pen, || true), Some(None), "a mouse moved"); + assert_eq!(feed_update(0x0201, 0, no_pen, || false), None, "made from a pen"); + // WM_KEYDOWN. + assert_eq!(feed_update(0x0100, 0, no_pen, || -> bool { panic!("not a mouse message") }), None); + } + #[test] fn contacts_whose_touch_never_arrives_are_bounded() { let mut strokes = BarrelStrokes::default(); diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index 7c7624031..059b9c476 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -1,19 +1,22 @@ //! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a //! terminal, SSH or an agent's shell it used to open the window (or hang without a display). +use std::io::Read; use std::process::{Command, Output, Stdio}; +use std::sync::mpsc; use std::time::{Duration, Instant}; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); -/// Runs the desktop binary without a display, so a regression fails instead of opening a window. +/// Runs the desktop binary without a display and with a control port it cannot parse, so a +/// regression exits with that error instead of opening a window. fn run(args: &[&str], env: &[(&str, &str)]) -> Output { let mut child = Command::new(BIN) .args(args) .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") .env_remove("WAYLAND_SOCKET") - .env_remove("VECTORCRAFT_CONTROL_PORT") + .env("VECTORCRAFT_CONTROL_PORT", "not-a-port") .env("VECTORCRAFT_NO_PREFS", "1") .envs(env.iter().copied()) .stdin(Stdio::null()) @@ -21,15 +24,28 @@ fn run(args: &[&str], env: &[(&str, &str)]) -> Output { .stderr(Stdio::piped()) .spawn() .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while child.try_wait().unwrap().is_none() { - if Instant::now() >= deadline { - child.kill().unwrap(); - panic!("{args:?}: the app started instead of printing its usage"); - } - std::thread::sleep(Duration::from_millis(10)); - } - child.wait_with_output().unwrap() + // Both pipes end when the binary exits, however long a loaded machine takes to start it. The + // deadline only stops a binary that is still running. + let deadline = Instant::now() + Duration::from_secs(120); + let stdout = read_to_end(child.stdout.take().unwrap()); + let stderr = read_to_end(child.stderr.take().unwrap()); + let recv = |rx: &mpsc::Receiver>| rx.recv_timeout(deadline.saturating_duration_since(Instant::now())); + let (Ok(stdout), Ok(stderr)) = (recv(&stdout), recv(&stderr)) else { + child.kill().unwrap(); + panic!("{args:?}: the app started instead of printing its usage"); + }; + Output { status: child.wait().unwrap(), stdout, stderr } +} + +/// Reads `pipe` to its end on another thread. +fn read_to_end(mut pipe: impl Read + Send + 'static) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + rx } fn prints_usage(args: &[&str], env: &[(&str, &str)]) { diff --git a/clippy.toml b/clippy.toml index 0369326bc..d02d2c40c 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,3 +3,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/contributors/people.toml b/contributors/people.toml index f8c651239..d43e6fa1b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -159,3 +159,13 @@ display_name = "Aditya Sisodiya" real_name = "Angelo Bernardini" display_name = "Angelo Bernardini" sync_github_name = false + +[people.DCaioP] +real_name = "Caio Pontes Magalhães" +display_name = "Caio Pontes" +sync_github_name = false + +[people.xbirks] +real_name = "Andrés Ortega Montoya" +display_name = "Andrés Ortega Montoya" +sync_github_name = false diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 7f10f8823..a6d18f339 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -8,6 +8,16 @@ use crate::paint::{self, Paint, Stroke}; use crate::stream::{ObjId, Stream, Tag}; use crate::{Archive, Error, Limits, stream}; +mod bounds; +mod read; + +pub(crate) use bounds::rect; +pub use bounds::{kind_bounds, nodes_bounds}; +pub(crate) use read::Reader; +pub use read::read; +#[cfg(test)] +pub(crate) use read::read_within; + /// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order). #[derive(Debug, Clone, Copy, PartialEq)] pub struct Affine(pub [f64; 6]); @@ -459,743 +469,3 @@ pub struct Document { /// colorimetric intent and black-point compensation in every document checked. pub cmyk_profile: Option>, } - -/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). -const MAX_PROFILE_BYTES: usize = 32 << 20; - -/// Deepest layer nesting followed; deeper content is reported, not imported. -const MAX_TREE_DEPTH: usize = 128; -/// Total layer nodes imported. -const MAX_NODES: usize = 500_000; - -/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits -/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). -const MAX_LAYER_PIXELS: u64 = 512 << 20; - -/// Embedded documents inside embedded documents are followed this deep. -const MAX_EMBED_DEPTH: usize = 4; -/// Characters of all stories together (a story may be shared by many text layers). -const MAX_DOCUMENT_CHARS: usize = 16 << 20; -/// Table cells of all tables together (each becomes a text object with its edge lines). -const MAX_DOCUMENT_CELLS: usize = 1 << 18; - -/// What a whole read may still use. Shared objects can be linked from many layers, and embedded -/// documents are read with what their embedding document left, so linking or embedding the -/// same content many times can't multiply the work. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Budget { - /// Layer nodes ([`MAX_NODES`] in all). - pub(crate) nodes: usize, - /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). - pub(crate) pixels: u64, - /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). - pub(crate) chars: usize, - /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). - pub(crate) cells: usize, -} - -impl Budget { - pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; -} - -/// Read a document: archive, `doc.dat` and the model. -pub fn read(bytes: &[u8], limits: Limits) -> Result { - read_within(bytes, limits, MAX_LAYER_PIXELS) -} - -/// [`read`], its pixel layers holding at most `pixels` pixels together. -pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { - read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document -} - -/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted -/// either way. -struct ReadAt { - document: Result, - extracted: usize, -} - -/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` -/// deep). What it uses is taken from `budget`, also when it fails part of the way. -/// The most characters of a layer's name kept. -const MAX_NAME_CHARS: usize = 1024; - -fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { - let mut archive = match Archive::open(bytes, limits) { - Ok(a) => a, - Err(e) => return ReadAt { document: Err(e), extracted: 0 }, - }; - let document = read_archive(&mut archive, budget, base, depth); - ReadAt { document, extracted: archive.extracted() } -} - -fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { - let doc = archive.read("doc.dat")?; - let s = stream::parse(&doc)?; - let mut reader = Reader::new(&s, archive, base, depth); - reader.budget = *budget; - let document = reader.document(); - *budget = reader.budget; - document -} - -pub(crate) struct Reader<'s, 'a, 'b> { - pub(crate) s: &'s Stream, - pub(crate) archive: &'b mut Archive<'a>, - pub(crate) dpi: f64, - pub(crate) warnings: BTreeMap, - active: HashSet, - /// Where the spreads go: the identity, or an embedding document's placement. - base: Affine, - /// The linear part of `base`, which also scales frame text. - pub(crate) type_base: Affine, - /// How deep inside embedded documents this one is. - depth: usize, - /// An embedded document's content, waiting to become its node's children. - pending: Option>, - /// The master spreads, in the order of [`Document::masters`]. - master_ids: Vec, - /// What the rest of the read may use. - pub(crate) budget: Budget, -} - -impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { - let [a, b, c, d, ..] = base.0; - Self { - s, - archive, - dpi: 72.0, - warnings: BTreeMap::new(), - active: HashSet::new(), - base, - type_base: Affine([a, b, c, d, 0.0, 0.0]), - depth, - pending: None, - master_ids: Vec::new(), - budget: Budget::FULL, - } - } - - pub(crate) fn warn(&mut self, what: impl Into) { - *self.warnings.entry(what.into()).or_default() += 1; - } - - /// The document. - fn document(&mut self) -> Result { - let s = self.s; - let root = s.root; - self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); - let saved_by = s.obj(root, b"NVer").map(|v| { - let product = s.str(v, b"Prod").unwrap_or("Affinity"); - match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { - (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), - _ => product.to_string(), - } - }); - let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; - self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); - let mut spreads = Vec::new(); - for spread in s.objs(doc, b"Chld") { - if let Some(sp) = self.spread(doc, spread)? { - spreads.push(sp); - } - } - if spreads.is_empty() { - return Err(Error::Malformed("document has no pages")); - } - let mut masters = Vec::new(); - for master in self.master_ids.clone() { - match self.spread(doc, master)? { - Some(sp) => masters.push(sp), - // Keep the indices of `MasterInstance::master` valid. - None => masters.push(Spread { - bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, - pages: Vec::new(), - transparent: true, - nodes: Vec::new(), - }), - } - } - let cmyk_profile = s - .obj(root, b"CMYP") - .and_then(|p| s.bytes(p, b"Data")) - .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) - .map(<[u8]>::to_vec); - let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); - Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) - } - - /// A spread (`Sprd`) of the document `doc`, or of its master spreads. - fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { - let s = self.s; - if !s.is(spread, b"Sprd") { - self.warn("an unknown kind of page"); - return Ok(None); - } - let pages = pages(s, spread); - let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); - let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { - x0: 0.0, - y0: 0.0, - x1: 0.0, - y1: 0.0, - }); - let transparent = s.bool(spread, b"SprT").unwrap_or(false); - let (nodes, mask) = self.children(spread, self.base, 0)?; - if mask.is_some() { - self.warn("mask layers directly on a page (imported without them)"); - } - Ok(Some(Spread { bounds, pages, transparent, nodes })) - } - - /// The children of `parent`, and the mask layer among them that masks the parent (with its - /// placement), read later over just what it masks. - #[allow(clippy::type_complexity)] - fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { - let s = self.s; - let mut out = Vec::new(); - let mut mask = None; - for c in s.objs(parent, b"Chld") { - if s.is(c, b"MRst") { - // A mask layer inside a group or layer masks that container. - if s.bool(c, b"Visi") == Some(false) { - continue; - } - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - if mask.is_none() { - mask = Some((c, local.then(world))); - } else { - self.warn("several pixel masks on one layer (only the first is used)"); - } - continue; - } - if let Some(n) = self.node(c, world, depth + 1)? { - out.push(n); - } - } - Ok((out, mask)) - } - - fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { - let s = self.s; - if depth > MAX_TREE_DEPTH { - self.warn("layers nested deeper than 128 levels"); - return Ok(None); - } - let Some(left) = self.budget.nodes.checked_sub(1) else { - return Err(Error::Limit("too many layers")); - }; - self.budget.nodes = left; - // A node that contains itself (a shared object linked back into its own subtree). - if !self.active.insert(id) { - self.warn("a layer that contains itself"); - return Ok(None); - } - let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let world = local.then(parent); - let class = s.class(id).unwrap_or(Tag(0)); - // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); - let visible = s.bool(id, b"Visi").unwrap_or(true); - let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); - let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); - let blend = match s.enumeration(id, b"Blnd") { - None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - }; - let kind = if !world.is_finite() { - self.warn("a layer with an invalid transform"); - Kind::Unsupported - } else { - self.kind(id, class, world, parent)? - }; - // This node's embedded content, before its children's `node` calls can take it. - let embedded = self.pending.take(); - // A compound's children are its operands, already merged into its outline. - let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; - if let Some(embedded) = embedded { - children.extend(embedded); - } - // Pixel masks are read only over what they mask (they can span a whole spread at print - // resolution, a gigapixel). - let over = mask_extent(&kind, &children); - let child_mask = match child_mask { - Some((c, at)) => crate::raster::mask(self, c, at, over)?, - None => None, - }; - let (mask, attached_mask) = self.attached_masks(id, world, over)?; - let pixel_mask = attached_mask.or(child_mask); - self.active.remove(&id); - let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; - // Gradient overlays are laid out over the node's bounds, also while it is hidden. - let bounds = tree_bounds(&node); - node.effects = self.effects(id, world, bounds); - Ok(Some(node)) - } - - fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { - let s = self.s; - Ok(match &class.0.to_be_bytes() { - b"Scop" => Kind::Layer, - b"Grup" => Kind::Group, - b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { - // Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp` - // property object in `phrp`, while older documents use `ABEn`. Ordinary shapes - // in that fixture have neither. See the pinned corpus and validation audit. - if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { - return Ok(self.artboard(id, world)); - } - let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { - self.warn(format!("a {} that could not be read", describe(class))); - return Ok(Kind::Unsupported); - }; - let (fills, strokes) = paint::node_paint(self, id, world); - Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } - } - b"TxtT" => match crate::text::table(self, id, world) { - Some(t) => Kind::Table(t), - None => { - self.warn("tables that could not be read"); - Kind::Unsupported - } - }, - b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { - Some(t) => Kind::Text(t), - None => { - self.warn("text whose story could not be read (master-page text, or an unknown layout)"); - Kind::Unsupported - } - }, - b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { - Some(img) => Kind::Image(img), - None => Kind::Unsupported, - }, - b"MPIN" => { - // `SLnk` links the instance with the master spread it shows. - let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); - let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); - let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); - match master { - Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, - None => { - self.warn("master page instances without their master"); - Kind::Unsupported - } - } - } - b"EmbN" => match self.embedded_document(id, world) { - // The embedded document itself, as editable content. - Some(nodes) => { - self.pending = Some(nodes); - Kind::Group - } - None => match crate::raster::embedded(self, id, world)? { - Some(img) => { - self.warn("embedded documents and symbols (imported as pictures of them)"); - Kind::Image(img) - } - None => { - self.warn("embedded documents and symbols without a cached picture"); - Kind::Unsupported - } - }, - }, - b"FRst" => { - // A fill layer paints its whole bitmap area; its gradient lives in page space. - let b = s.obj(id, b"Bitm"); - let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); - match (size(b"BmpW"), size(b"BmpH")) { - (Some(w), Some(h)) => { - let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; - let fills = paint::fills(self, id, parent, b"BFFl"); - Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } - } - _ => { - self.warn("fill layers without an area"); - Kind::Unsupported - } - } - } - _ => { - self.warn(format!("{} layers", describe(class))); - Kind::Unsupported - } - }) - } - - /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the - /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. - fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { - let s = self.s; - let mut out = Vec::new(); - for e in s.objs(id, b"FiEf").into_iter().take(64) { - if s.bool(e, b"Enab") == Some(false) { - continue; - } - // "Scale with object": lengths follow the node's transform; otherwise they are absolute. - let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; - let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; - let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); - let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); - let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); - let class = s.class(e).map(|c| c.0.to_be_bytes()); - match (class.as_ref(), color) { - (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), - (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { - let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); - out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); - } - (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), - (Some(b"BevE" | b"EmbE"), _) => { - // No radius, no bevel (as Affinity draws it). - let size = len(b"Radi"); - if size > 0.0 { - let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; - let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; - let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); - let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); - let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); - let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); - let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); - let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); - out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); - } - } - (Some(b"ColO"), Some(color)) => { - let blend = self.effect_blend(e); - out.push(Effect::ColorOverlay { color, opacity, blend }); - } - (Some(b"GrdO"), _) => { - let blend = self.effect_blend(e); - // The gradient's unit space spans the node's bounding box from -1 to 1, centred - // on it (y down): checked against the stored previews of real documents. - let space = - bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); - match (s.obj(e, b"GrFl"), space) { - (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { - Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), - Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), - _ => {} - }, - // Nothing visible to lay it over. - (Some(_), None) => {} - (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), - } - } - (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), - (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), - _ => self.warn("layer effects of other kinds (left out)"), - } - } - out - } - - /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). - fn effect_blend(&mut self, e: ObjId) -> Blend { - match self.s.enumeration(e, b"BlnM") { - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - } - } - - /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; - /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. - fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { - let s = self.s; - let attached = s.objs(id, b"AdCh"); - let mut mask: Option = None; - let mut pixels: Option = None; - for a in attached { - let class = s.class(a).unwrap_or(Tag(0)); - if s.bool(a, b"Visi") == Some(false) { - continue; - } - if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { - mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); - } - } else if class == Tag::of(b"MRst") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - match crate::raster::mask(self, a, local, over)? { - Some(m) if pixels.is_none() => pixels = Some(m), - Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), - None => {} - } - } else { - self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); - } - } - Ok((mask, pixels)) - } - - /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds - /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, - /// placed so the centre of its page, first artboard or content is the node's origin. - fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { - let s = self.s; - let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); - if entry.is_empty() { - return None; - } - if self.depth >= MAX_EMBED_DEPTH { - self.warn("embedded documents nested more than four deep"); - return None; - } - let data = self.archive.read(&entry).ok()?; - let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; - if !crate::is_affinity(inner) { - return None; - } - // A first reading finds its page and resolution, the second places it. - // Both readings are charged to the budget and the archive's limits, also when they fail: - // each does the work. - let mut budget = self.budget; - let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); - self.budget = budget; - if self.archive.charge(probe.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let probe = match probe.document { - Ok(d) => d, - Err(e) => { - self.warn(format!("embedded documents that could not be read ({e})")); - return None; - } - }; - let spread = probe.spreads.first()?; - // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. - let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); - let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); - let page = if content { None } else { spread.pages.first().copied().or(first_board) }; - let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); - // Its pixels aren't needed any more: never hold both readings' at once. - drop(probe); - // Its document pixels are the node's units, whatever either document's resolution. - let k = 1.0; - let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); - let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); - if !base.is_finite() { - self.warn("embedded documents with an invalid placement"); - return None; - } - let mut budget = self.budget; - let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); - self.budget = budget; - if self.archive.charge(placed.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let doc = placed.document.ok()?; - for w in doc.warnings { - self.warn(format!("{w} [in embedded documents]")); - } - if doc.spreads.len() > 1 { - self.warn("embedded documents with several pages (the first is used)"); - } - let mut nodes = doc.spreads.into_iter().next()?.nodes; - // Its master page instances count its own masters, which aren't kept: in this document - // they would show other masters. - fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { - let mut found = false; - for n in nodes { - if matches!(n.kind, Kind::MasterInstance { .. }) { - n.kind = Kind::Unsupported; - found = true; - } - if depth < MAX_TREE_DEPTH { - found |= strip_masters(&mut n.children, depth + 1); - } - } - found - } - if strip_masters(&mut nodes, 0) { - self.warn("master pages of embedded documents (left out)"); - } - Some(nodes) - } - - fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { - let s = self.s; - let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { - self.warn("an artboard without valid bounds (its children are kept without clipping)"); - return Kind::Unsupported; - }; - let corners = - [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); - let [a, b2, c, d, ..] = world.0; - if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { - self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)"); - } - let xs = corners.map(|p| p.x); - let ys = corners.map(|p| p.y); - let rect = Rect { - x0: xs.iter().copied().fold(f64::INFINITY, f64::min), - y0: ys.iter().copied().fold(f64::INFINITY, f64::min), - x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 { - self.warn("an artboard with an invalid transformed size (its children are kept without clipping)"); - return Kind::Unsupported; - } - let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); - let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { - self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); - (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) - }); - let (background, strokes) = paint::node_paint(self, id, world); - Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } - } -} - -/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames -/// and tables), in document pixels. -pub fn nodes_bounds(nodes: &[Node]) -> Option { - let mut out = None; - walk_bounds(nodes, true, 0, &mut out); - out -} - -/// The bounds of `node` and everything in it, hidden or not. -fn tree_bounds(node: &Node) -> Option { - let mut out = None; - walk_bounds(std::slice::from_ref(node), false, 0, &mut out); - out -} - -fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { - if depth > MAX_TREE_DEPTH { - return; - } - for n in nodes.iter().filter(|n| n.visible || !visible_only) { - kind_points(&n.kind, &mut |p| add_point(out, p)); - walk_bounds(&n.children, visible_only, depth + 1, out); - } -} - -/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they -/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: -/// artistic text, whose glyphs only the layout places. -fn mask_extent(kind: &Kind, children: &[Node]) -> Option { - fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { - if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { - return false; - } - let mut own = kind_bounds(kind); - if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { - // As far as a stroke can reach out of its path, miter joins included. - let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); - own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); - } - if let Some(r) = own { - *out = Some(out.map_or(r, |o| union(o, r))); - } - children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) - } - let mut out = None; - if visit(kind, children, 0, &mut out) { out } else { None } -} - -/// The bounds of what one node's own kind draws (not its children): its outline's points, its -/// image's or frame's corners, or artistic text's anchor. -pub fn kind_bounds(kind: &Kind) -> Option { - let mut out = None; - kind_points(kind, &mut |p| add_point(&mut out, p)); - out -} - -fn add_point(out: &mut Option, p: Point) { - if p.x.is_finite() && p.y.is_finite() { - let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; - *out = Some(out.map_or(r, |o| union(o, r))); - } -} - -fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { - let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); - match kind { - Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { - for sp in &path.subpaths { - add(sp.start); - for seg in &sp.segments { - seg.iter().for_each(|p| add(*p)); - } - } - } - Kind::Image(img) => { - corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) - } - Kind::Text(t) => match t.frame { - Some(f) => corners(f, t.transform).into_iter().for_each(add), - None => add(t.transform.apply(t.anchor)), - }, - Kind::Table(t) => { - if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { - corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); - } - } - Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} - } -} - -/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` -/// equal pages (Publisher 1 facing spreads). -fn pages(s: &Stream, spread: ObjId) -> Vec { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = - s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); - if !v.is_empty() { - return v; - } - } - match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { - (Some(b), Some(n @ 2..=16)) => { - let w = (b.x1 - b.x0) / n as f64; - (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() - } - _ => Vec::new(), - } -} - -pub(crate) fn union(a: Rect, b: Rect) -> Rect { - Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } -} - -fn is_container(class: Tag) -> bool { - class == Tag::of(b"Scop") || class == Tag::of(b"Grup") -} - -pub(crate) fn rect(v: [f64; 4]) -> Rect { - Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } -} - -/// A readable name for a layer class in warnings. -pub(crate) fn describe(class: Tag) -> &'static str { - match &class.0.to_be_bytes() { - b"PCrv" => "curve", - b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", - b"Comp" => "compound", - b"Rstr" => "pixel", - b"ImgN" => "image", - b"EmbN" => "embedded document", - b"MPIN" => "master page instance", - b"FRst" => "fill", - b"MRst" => "mask", - b"Slic" | b"SlcP" => "slice", - b"3DLA" => "3D", - [_, _, b'R', b'A'] => "adjustment", - _ => "unknown", - } -} diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs new file mode 100644 index 000000000..0a48193e2 --- /dev/null +++ b/crates/affinity/src/model/bounds.rs @@ -0,0 +1,142 @@ +use super::read::MAX_TREE_DEPTH; +use super::*; + +/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames +/// and tables), in document pixels. +pub fn nodes_bounds(nodes: &[Node]) -> Option { + let mut out = None; + walk_bounds(nodes, true, 0, &mut out); + out +} + +/// The bounds of `node` and everything in it, hidden or not. +pub(super) fn tree_bounds(node: &Node) -> Option { + let mut out = None; + walk_bounds(std::slice::from_ref(node), false, 0, &mut out); + out +} + +fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { + if depth > MAX_TREE_DEPTH { + return; + } + for n in nodes.iter().filter(|n| n.visible || !visible_only) { + kind_points(&n.kind, &mut |p| add_point(out, p)); + walk_bounds(&n.children, visible_only, depth + 1, out); + } +} + +/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they +/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: +/// artistic text, whose glyphs only the layout places. +pub(super) fn mask_extent(kind: &Kind, children: &[Node]) -> Option { + fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { + if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { + return false; + } + let mut own = kind_bounds(kind); + if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { + // As far as a stroke can reach out of its path, miter joins included. + let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); + own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); + } + if let Some(r) = own { + *out = Some(out.map_or(r, |o| union(o, r))); + } + children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) + } + let mut out = None; + if visit(kind, children, 0, &mut out) { out } else { None } +} + +/// The bounds of what one node's own kind draws (not its children): its outline's points, its +/// image's or frame's corners, or artistic text's anchor. +pub fn kind_bounds(kind: &Kind) -> Option { + let mut out = None; + kind_points(kind, &mut |p| add_point(&mut out, p)); + out +} + +fn add_point(out: &mut Option, p: Point) { + if p.x.is_finite() && p.y.is_finite() { + let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; + *out = Some(out.map_or(r, |o| union(o, r))); + } +} + +fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { + let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); + match kind { + Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { + for sp in &path.subpaths { + add(sp.start); + for seg in &sp.segments { + seg.iter().for_each(|p| add(*p)); + } + } + } + Kind::Image(img) => { + corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) + } + Kind::Text(t) => match t.frame { + Some(f) => corners(f, t.transform).into_iter().for_each(add), + None => add(t.transform.apply(t.anchor)), + }, + Kind::Table(t) => { + if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { + corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); + } + } + Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} + } +} + +/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` +/// equal pages (Publisher 1 facing spreads). +pub(super) fn pages(s: &Stream, spread: ObjId) -> Vec { + if let Some(md) = s.obj(spread, b"SpMd") { + let v: Vec = + s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); + if !v.is_empty() { + return v; + } + } + match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { + (Some(b), Some(n @ 2..=16)) => { + let w = (b.x1 - b.x0) / n as f64; + (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + } + _ => Vec::new(), + } +} + +pub(crate) fn union(a: Rect, b: Rect) -> Rect { + Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } +} + +pub(super) fn is_container(class: Tag) -> bool { + class == Tag::of(b"Scop") || class == Tag::of(b"Grup") +} + +pub(crate) fn rect(v: [f64; 4]) -> Rect { + Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } +} + +/// A readable name for a layer class in warnings. +pub(crate) fn describe(class: Tag) -> &'static str { + match &class.0.to_be_bytes() { + b"PCrv" => "curve", + b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", + b"Comp" => "compound", + b"Rstr" => "pixel", + b"ImgN" => "image", + b"EmbN" => "embedded document", + b"MPIN" => "master page instance", + b"FRst" => "fill", + b"MRst" => "mask", + b"Slic" | b"SlcP" => "slice", + b"3DLA" => "3D", + [_, _, b'R', b'A'] => "adjustment", + _ => "unknown", + } +} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs new file mode 100644 index 000000000..755e93745 --- /dev/null +++ b/crates/affinity/src/model/read.rs @@ -0,0 +1,605 @@ +use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union}; +use super::*; + +/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). +const MAX_PROFILE_BYTES: usize = 32 << 20; + +/// Deepest layer nesting followed; deeper content is reported, not imported. +pub(super) const MAX_TREE_DEPTH: usize = 128; + +/// Total layer nodes imported. +const MAX_NODES: usize = 500_000; + +/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits +/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). +const MAX_LAYER_PIXELS: u64 = 512 << 20; + +/// Embedded documents inside embedded documents are followed this deep. +const MAX_EMBED_DEPTH: usize = 4; + +/// Characters of all stories together (a story may be shared by many text layers). +const MAX_DOCUMENT_CHARS: usize = 16 << 20; + +/// Table cells of all tables together (each becomes a text object with its edge lines). +const MAX_DOCUMENT_CELLS: usize = 1 << 18; + +/// What a whole read may still use. Shared objects can be linked from many layers, and embedded +/// documents are read with what their embedding document left, so linking or embedding the +/// same content many times can't multiply the work. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Budget { + /// Layer nodes ([`MAX_NODES`] in all). + pub(crate) nodes: usize, + /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). + pub(crate) pixels: u64, + /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). + pub(crate) chars: usize, + /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). + pub(crate) cells: usize, +} + +impl Budget { + pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; +} + +/// Read a document: archive, `doc.dat` and the model. +pub fn read(bytes: &[u8], limits: Limits) -> Result { + read_within(bytes, limits, MAX_LAYER_PIXELS) +} + +/// [`read`], its pixel layers holding at most `pixels` pixels together. +pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { + read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document +} + +/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted +/// either way. +struct ReadAt { + document: Result, + extracted: usize, +} + +/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` +/// deep). What it uses is taken from `budget`, also when it fails part of the way. +/// The most characters of a layer's name kept. +const MAX_NAME_CHARS: usize = 1024; + +fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { + let mut archive = match Archive::open(bytes, limits) { + Ok(a) => a, + Err(e) => return ReadAt { document: Err(e), extracted: 0 }, + }; + let document = read_archive(&mut archive, budget, base, depth); + ReadAt { document, extracted: archive.extracted() } +} + +fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { + let doc = archive.read("doc.dat")?; + let s = stream::parse(&doc)?; + let mut reader = Reader::new(&s, archive, base, depth); + reader.budget = *budget; + let document = reader.document(); + *budget = reader.budget; + document +} + +pub(crate) struct Reader<'s, 'a, 'b> { + pub(crate) s: &'s Stream, + pub(crate) archive: &'b mut Archive<'a>, + pub(crate) dpi: f64, + pub(crate) warnings: BTreeMap, + active: HashSet, + /// Where the spreads go: the identity, or an embedding document's placement. + base: Affine, + /// The linear part of `base`, which also scales frame text. + pub(crate) type_base: Affine, + /// How deep inside embedded documents this one is. + depth: usize, + /// An embedded document's content, waiting to become its node's children. + pending: Option>, + /// The master spreads, in the order of [`Document::masters`]. + master_ids: Vec, + /// What the rest of the read may use. + pub(crate) budget: Budget, +} + +impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { + pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { + let [a, b, c, d, ..] = base.0; + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + active: HashSet::new(), + base, + type_base: Affine([a, b, c, d, 0.0, 0.0]), + depth, + pending: None, + master_ids: Vec::new(), + budget: Budget::FULL, + } + } + + pub(crate) fn warn(&mut self, what: impl Into) { + *self.warnings.entry(what.into()).or_default() += 1; + } + + /// The document. + fn document(&mut self) -> Result { + let s = self.s; + let root = s.root; + self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); + let saved_by = s.obj(root, b"NVer").map(|v| { + let product = s.str(v, b"Prod").unwrap_or("Affinity"); + match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { + (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), + _ => product.to_string(), + } + }); + let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; + self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); + let mut spreads = Vec::new(); + for spread in s.objs(doc, b"Chld") { + if let Some(sp) = self.spread(doc, spread)? { + spreads.push(sp); + } + } + if spreads.is_empty() { + return Err(Error::Malformed("document has no pages")); + } + let mut masters = Vec::new(); + for master in self.master_ids.clone() { + match self.spread(doc, master)? { + Some(sp) => masters.push(sp), + // Keep the indices of `MasterInstance::master` valid. + None => masters.push(Spread { + bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, + pages: Vec::new(), + transparent: true, + nodes: Vec::new(), + }), + } + } + let cmyk_profile = s + .obj(root, b"CMYP") + .and_then(|p| s.bytes(p, b"Data")) + .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) + .map(<[u8]>::to_vec); + let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); + Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) + } + + /// A spread (`Sprd`) of the document `doc`, or of its master spreads. + fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { + let s = self.s; + if !s.is(spread, b"Sprd") { + self.warn("an unknown kind of page"); + return Ok(None); + } + let pages = pages(s, spread); + let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); + let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { + x0: 0.0, + y0: 0.0, + x1: 0.0, + y1: 0.0, + }); + let transparent = s.bool(spread, b"SprT").unwrap_or(false); + let (nodes, mask) = self.children(spread, self.base, 0)?; + if mask.is_some() { + self.warn("mask layers directly on a page (imported without them)"); + } + Ok(Some(Spread { bounds, pages, transparent, nodes })) + } + + /// The children of `parent`, and the mask layer among them that masks the parent (with its + /// placement), read later over just what it masks. + #[allow(clippy::type_complexity)] + fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { + let s = self.s; + let mut out = Vec::new(); + let mut mask = None; + for c in s.objs(parent, b"Chld") { + if s.is(c, b"MRst") { + // A mask layer inside a group or layer masks that container. + if s.bool(c, b"Visi") == Some(false) { + continue; + } + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + if mask.is_none() { + mask = Some((c, local.then(world))); + } else { + self.warn("several pixel masks on one layer (only the first is used)"); + } + continue; + } + if let Some(n) = self.node(c, world, depth + 1)? { + out.push(n); + } + } + Ok((out, mask)) + } + + fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { + let s = self.s; + if depth > MAX_TREE_DEPTH { + self.warn("layers nested deeper than 128 levels"); + return Ok(None); + } + let Some(left) = self.budget.nodes.checked_sub(1) else { + return Err(Error::Limit("too many layers")); + }; + self.budget.nodes = left; + // A node that contains itself (a shared object linked back into its own subtree). + if !self.active.insert(id) { + self.warn("a layer that contains itself"); + return Ok(None); + } + let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let world = local.then(parent); + let class = s.class(id).unwrap_or(Tag(0)); + // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let visible = s.bool(id, b"Visi").unwrap_or(true); + let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); + let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); + let blend = match s.enumeration(id, b"Blnd") { + None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + }; + let kind = if !world.is_finite() { + self.warn("a layer with an invalid transform"); + Kind::Unsupported + } else { + self.kind(id, class, world, parent)? + }; + // This node's embedded content, before its children's `node` calls can take it. + let embedded = self.pending.take(); + // A compound's children are its operands, already merged into its outline. + let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; + if let Some(embedded) = embedded { + children.extend(embedded); + } + // Pixel masks are read only over what they mask (they can span a whole spread at print + // resolution, a gigapixel). + let over = mask_extent(&kind, &children); + let child_mask = match child_mask { + Some((c, at)) => crate::raster::mask(self, c, at, over)?, + None => None, + }; + let (mask, attached_mask) = self.attached_masks(id, world, over)?; + let pixel_mask = attached_mask.or(child_mask); + self.active.remove(&id); + let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; + // Gradient overlays are laid out over the node's bounds, also while it is hidden. + let bounds = tree_bounds(&node); + node.effects = self.effects(id, world, bounds); + Ok(Some(node)) + } + + fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { + let s = self.s; + Ok(match &class.0.to_be_bytes() { + b"Scop" => Kind::Layer, + b"Grup" => Kind::Group, + b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { + // Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp` + // property object in `phrp`, while older documents use `ABEn`. Ordinary shapes + // in that fixture have neither. See the pinned corpus and validation audit. + if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { + return Ok(self.artboard(id, world)); + } + let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { + self.warn(format!("a {} that could not be read", describe(class))); + return Ok(Kind::Unsupported); + }; + let (fills, strokes) = paint::node_paint(self, id, world); + Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } + } + b"TxtT" => match crate::text::table(self, id, world) { + Some(t) => Kind::Table(t), + None => { + self.warn("tables that could not be read"); + Kind::Unsupported + } + }, + b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { + Some(t) => Kind::Text(t), + None => { + self.warn("text whose story could not be read (master-page text, or an unknown layout)"); + Kind::Unsupported + } + }, + b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { + Some(img) => Kind::Image(img), + None => Kind::Unsupported, + }, + b"MPIN" => { + // `SLnk` links the instance with the master spread it shows. + let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); + let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); + let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); + match master { + Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, + None => { + self.warn("master page instances without their master"); + Kind::Unsupported + } + } + } + b"EmbN" => match self.embedded_document(id, world) { + // The embedded document itself, as editable content. + Some(nodes) => { + self.pending = Some(nodes); + Kind::Group + } + None => match crate::raster::embedded(self, id, world)? { + Some(img) => { + self.warn("embedded documents and symbols (imported as pictures of them)"); + Kind::Image(img) + } + None => { + self.warn("embedded documents and symbols without a cached picture"); + Kind::Unsupported + } + }, + }, + b"FRst" => { + // A fill layer paints its whole bitmap area; its gradient lives in page space. + let b = s.obj(id, b"Bitm"); + let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); + match (size(b"BmpW"), size(b"BmpH")) { + (Some(w), Some(h)) => { + let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; + let fills = paint::fills(self, id, parent, b"BFFl"); + Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } + } + _ => { + self.warn("fill layers without an area"); + Kind::Unsupported + } + } + } + _ => { + self.warn(format!("{} layers", describe(class))); + Kind::Unsupported + } + }) + } + + /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the + /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. + fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { + let s = self.s; + let mut out = Vec::new(); + for e in s.objs(id, b"FiEf").into_iter().take(64) { + if s.bool(e, b"Enab") == Some(false) { + continue; + } + // "Scale with object": lengths follow the node's transform; otherwise they are absolute. + let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; + let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; + let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); + let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); + let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); + let class = s.class(e).map(|c| c.0.to_be_bytes()); + match (class.as_ref(), color) { + (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), + (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { + let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); + out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); + } + (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), + (Some(b"BevE" | b"EmbE"), _) => { + // No radius, no bevel (as Affinity draws it). + let size = len(b"Radi"); + if size > 0.0 { + let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); + let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); + let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); + let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); + let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); + let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); + out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); + } + } + (Some(b"ColO"), Some(color)) => { + let blend = self.effect_blend(e); + out.push(Effect::ColorOverlay { color, opacity, blend }); + } + (Some(b"GrdO"), _) => { + let blend = self.effect_blend(e); + // The gradient's unit space spans the node's bounding box from -1 to 1, centred + // on it (y down): checked against the stored previews of real documents. + let space = + bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); + match (s.obj(e, b"GrFl"), space) { + (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { + Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), + Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), + _ => {} + }, + // Nothing visible to lay it over. + (Some(_), None) => {} + (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), + } + } + (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), + (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), + _ => self.warn("layer effects of other kinds (left out)"), + } + } + out + } + + /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). + fn effect_blend(&mut self, e: ObjId) -> Blend { + match self.s.enumeration(e, b"BlnM") { + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + } + } + + /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; + /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. + fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { + let s = self.s; + let attached = s.objs(id, b"AdCh"); + let mut mask: Option = None; + let mut pixels: Option = None; + for a in attached { + let class = s.class(a).unwrap_or(Tag(0)); + if s.bool(a, b"Visi") == Some(false) { + continue; + } + if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { + let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); + if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { + mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); + } + } else if class == Tag::of(b"MRst") { + let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); + match crate::raster::mask(self, a, local, over)? { + Some(m) if pixels.is_none() => pixels = Some(m), + Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), + None => {} + } + } else { + self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); + } + } + Ok((mask, pixels)) + } + + /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds + /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, + /// placed so the centre of its page, first artboard or content is the node's origin. + fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { + let s = self.s; + let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); + if entry.is_empty() { + return None; + } + if self.depth >= MAX_EMBED_DEPTH { + self.warn("embedded documents nested more than four deep"); + return None; + } + let data = self.archive.read(&entry).ok()?; + let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; + if !crate::is_affinity(inner) { + return None; + } + // A first reading finds its page and resolution, the second places it. + // Both readings are charged to the budget and the archive's limits, also when they fail: + // each does the work. + let mut budget = self.budget; + let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); + self.budget = budget; + if self.archive.charge(probe.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let probe = match probe.document { + Ok(d) => d, + Err(e) => { + self.warn(format!("embedded documents that could not be read ({e})")); + return None; + } + }; + let spread = probe.spreads.first()?; + // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. + let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); + let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); + let page = if content { None } else { spread.pages.first().copied().or(first_board) }; + let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); + // Its pixels aren't needed any more: never hold both readings' at once. + drop(probe); + // Its document pixels are the node's units, whatever either document's resolution. + let k = 1.0; + let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); + let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); + if !base.is_finite() { + self.warn("embedded documents with an invalid placement"); + return None; + } + let mut budget = self.budget; + let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); + self.budget = budget; + if self.archive.charge(placed.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let doc = placed.document.ok()?; + for w in doc.warnings { + self.warn(format!("{w} [in embedded documents]")); + } + if doc.spreads.len() > 1 { + self.warn("embedded documents with several pages (the first is used)"); + } + let mut nodes = doc.spreads.into_iter().next()?.nodes; + // Its master page instances count its own masters, which aren't kept: in this document + // they would show other masters. + fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { + let mut found = false; + for n in nodes { + if matches!(n.kind, Kind::MasterInstance { .. }) { + n.kind = Kind::Unsupported; + found = true; + } + if depth < MAX_TREE_DEPTH { + found |= strip_masters(&mut n.children, depth + 1); + } + } + found + } + if strip_masters(&mut nodes, 0) { + self.warn("master pages of embedded documents (left out)"); + } + Some(nodes) + } + + fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { + let s = self.s; + let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { + self.warn("an artboard without valid bounds (its children are kept without clipping)"); + return Kind::Unsupported; + }; + let corners = + [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); + let [a, b2, c, d, ..] = world.0; + if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { + self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)"); + } + let xs = corners.map(|p| p.x); + let ys = corners.map(|p| p.y); + let rect = Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 { + self.warn("an artboard with an invalid transformed size (its children are kept without clipping)"); + return Kind::Unsupported; + } + let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); + let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { + self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); + (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) + }); + let (background, strokes) = paint::node_paint(self, id, world); + Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } + } +} diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 80d302b66..9ef049f86 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -119,7 +119,7 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { for r in &mut t.runs { for p in [&mut r.style.fill, &mut r.style.stroke] { if let Paint::Solid { color: c, .. } = p { - *c = c.lerp(color, amount); + *p = Paint::solid(c.lerp(color, amount)); } } } diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 1f7af6e60..422f09d9c 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -285,6 +285,13 @@ pub struct ArtBrush { pub scale: ArtScale, /// Width in percent. pub width: f64, + /// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from + /// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest. + #[serde(skip_serializing_if = "Option::is_none")] + pub width_mode: Option, + /// The width at the heaviest pen pressure (%), for Pressure (`width` without one). + #[serde(skip_serializing_if = "Option::is_none")] + pub width_max: Option, pub flip_along: bool, pub flip_across: bool, /// When true, art is placed on each run between sharp corners so folds do not overlap. @@ -295,6 +302,15 @@ pub struct ArtBrush { impl ArtBrush { /// The range of the width (%). pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0); + + /// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the + /// width follows the pressure. + pub fn width_range(&self) -> (f64, f64) { + match self.width_mode { + Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)), + _ => (self.width, self.width), + } + } } impl Default for ArtBrush { @@ -304,6 +320,8 @@ impl Default for ArtBrush { direction: Direction::LeftToRight, scale: ArtScale::Stretch, width: 100.0, + width_mode: None, + width_max: None, flip_along: false, flip_across: false, overlap: false, @@ -458,6 +476,18 @@ impl Brush { } } + /// [`Brush::art`], to change (relink the resources it uses). + pub fn art_mut(&mut self) -> Vec<&mut Node> { + match &mut self.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => std::iter::once(&mut p.side) + .chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()) + .collect(), + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } + } + /// A brush definition from JSON (a stored library entry or a command's params), its values /// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key` /// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or @@ -508,6 +538,11 @@ impl Brush { } BrushKind::Art(a) => { a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0); + // The width follows the pen's pressure or stays fixed. + if a.width_mode != Some(Variation::Pressure) { + a.width_mode = None; + } + a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width)); if let ArtScale::BetweenGuides { start, end } = &mut a.scale { *start = within(*start, (0.0, 1.0), 0.0); *end = within(*end, (0.0, 1.0), 1.0); @@ -592,7 +627,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok let mut out = match &brush.kind { 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), + 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::Pattern(p) => warp::pattern(p, bp, w), }; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 8c04e102b..0968f5d6c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -363,6 +363,12 @@ fn stain_mixes_type_strokes_too() { let mut art = type_art(red.clone(), red); tint_node(&mut art, &blue, 1.0); assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]); + // Characters painted with a global swatch lose the link, as a path's paint does: the mixed color + // is not the swatch's color. + let linked = Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 }; + let mut art = type_art(linked.clone(), linked); + tint_node(&mut art, &blue, 1.0); + assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]); } #[test] @@ -579,6 +585,78 @@ fn scatter_values_follow_their_modes() { assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset"); } +/// Art Brush Options: with Width on Pressure the art goes from Width at the lightest pen pressure +/// to the second width at the heaviest, along the stroke where the pen pressed; Fixed keeps Width. +#[test] +fn art_brush_width_follows_the_pressure() { + let brush = |mode: Option, max: Option| { + let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch); + if let BrushKind::Art(a) = &mut b.kind { + (a.width, a.width_mode, a.width_max) = (50.0, mode, max); + } + b + }; + let bp = line(0.0, 0.0, 100.0, 0.0); + let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st); + let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]); + // 4 pt art at 50 %: 1 pt either side at the lightest; at 300 %: 6 pt at the heaviest. + let out = draw(&brush(Some(Variation::Pressure), Some(300.0)), &ramp); + assert!(near(half_width(&out, 0.0, 1.0), 1.0, 0.1), "{}", half_width(&out, 0.0, 1.0)); + assert!(near(half_width(&out, 99.0, 100.0), 6.0, 0.1), "{}", half_width(&out, 99.0, 100.0)); + assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0)); + // The stroke weight scales it. + let out = draw(&brush(Some(Variation::Pressure), Some(300.0)), &StrokeLayer { width: 2.0, ..ramp.clone() }); + assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0)); + // Without recorded pressure, half way between the two widths. + assert!(near(bounds(&draw(&brush(Some(Variation::Pressure), Some(300.0)), &stroke(1.0))).height(), 7.0, 1e-6)); + // Fixed (or a definition without a mode) keeps Width, whatever the pressure and the second width. + for (mode, max) in [(None, Some(300.0)), (None, None), (Some(Variation::Pressure), None)] { + let out = draw(&brush(mode, max), &ramp); + assert!(near(bounds(&out).height(), 2.0, 1e-6), "{mode:?} {max:?}: {:?}", bounds(&out)); + } + // A stroke in the art takes the width at the middle of its path: 50 % and 300 % for an even + // light and heavy pressure, 175 % without recorded pressure. + let mut stroked = brush(Some(Variation::Pressure), Some(300.0)); + if let BrushKind::Art(a) = &mut stroked.kind { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((10.0, 0.0)); + a.art = Node::path(NodeId(0), vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + } + for (st, want) in [(pressed(vec![(0.0, 0.0), (1.0, 0.0)]), 1.0), (pressed(vec![(0.0, 1.0), (1.0, 1.0)]), 6.0), (stroke(1.0), 3.5)] { + let out = draw(&stroked, &st); + let w = out.first().and_then(|n| n.appearance.stroke()).map(|s| s.width); + assert!(w.is_some_and(|w| near(w, want, 1e-6)), "{want}: {w:?}"); + } + // Stretch Between Guides: an end outside the guides keeps the proportions of the width at its + // end of the stroke. A 2 pt marker at the start of 10 × 4 pt art (guides at 20 % and 80 %), + // 100 % to 300 % wide: 2 pt long at the lightest pressure, 6 pt at the heaviest. + let marker = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -1.0, 2.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut spacer = rect_art(10.0, 4.0); + spacer.appearance = Appearance::default().into(); + let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]); + let mut guided = art_brush(art, ArtScale::BetweenGuides { start: 0.2, end: 0.8 }); + if let BrushKind::Art(a) = &mut guided.kind { + (a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0)); + } + for (p, long) in [(0.0, 2.0), (1.0, 6.0)] { + let out = draw(&guided, &pressed(vec![(0.0, p), (1.0, p)])); + let m = out.first().and_then(|g| g.children()).and_then(|c| c.get(1)).and_then(|m| m.geometric_bounds()); + assert!(m.is_some_and(|m| near(m.x0, 0.0, 1e-6) && near(m.x1, long, 1e-6)), "{p}: {m:?}"); + } + // A mode the width does not take is read as Fixed, and the second width is kept within range. + let lib = parse_library(&serde_json::json!([ + {"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 1e308}, + {"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "pressure", "width_max": 0.0}, + ])); + let [BrushKind::Art(a), BrushKind::Art(b)] = [&lib[0].kind, &lib[1].kind] else { panic!("{lib:?}") }; + assert_eq!((a.width_mode, a.width_max, b.width_mode, b.width_max), (None, Some(1000.0), Some(Variation::Pressure), Some(1.0))); + // A brush without the new fields is written back without them. + let plain = serde_json::to_value(art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)).unwrap(); + assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}"); +} + /// Definitions from files and commands are kept within their ranges. #[test] fn sanitize_keeps_values_within_their_ranges() { diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 9d61216a1..d20fe5700 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -169,6 +169,33 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option f64) -> Option { + let cy = b.center().y; + let x0 = b.x0; + let f = move |p: Point| { + let s = along.at(p.x - x0); + t.map(s, (p.y - cy) * cross * widen(s)) + }; + let factor = |n: &Node| n.geometric_bounds().map_or(1.0, |r| widen(along.at(r.center().x - x0))); + let art = widen_strokes(art, &factor); + let scale = along.max_scale().max(cross.abs()).max(1e-6); + let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale; + warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs()) +} + +/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they +/// belong to. +fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node { + let mut out = n.clone(); + out.appearance.scale_strokes(factor(n)); + 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(); + } + out +} + /// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp /// corner (the whole path when it has none). fn art_runs(t: &Track) -> Vec<(f64, f64)> { @@ -189,7 +216,17 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> { bounds.windows(2).map(|w| (w[0], w[1])).collect() } -fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option { +/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and +/// `width` the width (%) there. +fn art_on_run( + t: &Track, + (s0, s1): (f64, f64), + a: &ArtBrush, + (art, b): (&Node, Rect), + cross: f64, + width: f64, + widen: &dyn Fn(f64) -> f64, +) -> Option { let l = s1 - s0; if l <= 1e-9 { return None; @@ -199,33 +236,49 @@ fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cr ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross), ArtScale::Proportional => { let k = l / w; - (AlongMap::linear(w, s0, s1), k * a.width / 100.0) + (AlongMap::linear(w, s0, s1), k * width / 100.0) } ArtScale::BetweenGuides { start, end } => { let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w); let (g0, g1) = (g0.min(g1), g0.max(g1)); - let fixed = (g0 + (w - g1)) * cross; + // The ends keep the proportions of the width at their end of the run. + let (c0, c1) = (cross * widen(s0), cross * widen(s1)); + let fixed = g0 * c0 + (w - g1) * c1; if fixed >= l || g1 - g0 < 1e-9 { (AlongMap::linear(w, s0, s1), cross) } else { - (AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross) + (AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * c0, s1 - (w - g1) * c1, s1] }, cross) } } }; - bend(t, art, b, along, cross) + bend_widened(t, art, b, along, cross, widen) } -pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec { +/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as +/// wide as the pressure recorded at each point of the stroke gives (half way without a recording). +pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec { let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] }; - let cross = weight * a.width / 100.0; + let (light, heavy) = a.width_range(); + let width = light.max(heavy); + let cross = weight * width / 100.0; let mut out = vec![]; for t in tracks(bp, 0.05) { - if t.len() <= 1e-9 { + let l = t.len(); + if l <= 1e-9 { continue; } - let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] }; - for (s0, s1) in runs { - if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) { + let widen = |s: f64| { + if light == heavy || width <= 0.0 { + return 1.0; + } + // A run past the start of a closed track wraps round to it. + let at = if s > l { s - l } else { s }; + let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0); + (light + (heavy - light) * p) / width + }; + let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] }; + for run in runs { + if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) { out.push(n); } } diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms.rs similarity index 100% rename from crates/color/src/cms/mod.rs rename to crates/color/src/cms.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index f23eb18e6..528d56033 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -801,468 +801,4 @@ pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sample_endpoints_and_mid() { - let g = Gradient::default(); - assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff"); - assert_eq!(g.sample(1.0).0.to_hex(), "#000000"); - let mid = g.sample(0.5).0.to_rgb()[0]; - assert!((mid - 0.5).abs() < 1e-5); - } - - /// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order - /// and repeated stop offsets included. - #[test] - fn increasing_samples_match_single_samples() { - let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; - let g = Gradient::new( - GradientKind::Linear, - vec![ - stop(0.1, "#ff0000", 0.3), - stop(0.4, "#00ff00", 0.7), - stop(0.4, "#0000ff", 0.5), - stop(0.2, "#ffff00", 0.5), - stop(0.9, "#000000", 0.5), - ], - ); - let ts: Vec = (0..=40).map(|k| k as f32 / 40.0).collect(); - let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect(); - assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); - } - - #[test] - fn midpoint_shifts() { - let mut g = Gradient::default(); - g.stops[0].midpoint = 0.25; - // At t = 0.25 we should be half way. - assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5); - assert_eq!(g.expanded_stops().len(), 3); - } - - #[test] - fn fit_linear_horizontal() { - let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); - assert_eq!(g.start, Point::new(0.0, 25.0)); - assert_eq!(g.end, Point::new(100.0, 25.0)); - assert!((g.angle_deg()).abs() < 1e-9); - let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0); - assert!((v.angle_deg() - 90.0).abs() < 1e-9); - assert!((v.length() - 50.0).abs() < 1e-9); - } - - #[test] - fn reverse_stops() { - let mut g = Gradient::default(); - g.reverse(); - assert_eq!(g.stops[0].color.to_hex(), "#000000"); - assert_eq!(g.stops[0].offset, 0.0); - } - - /// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same - /// the other way round. - #[test] - fn reverse_mirrors_midpoints() { - let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; - let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]); - let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]); - for g in [two, three] { - let mut r = g.clone(); - r.reverse(); - let mids: Vec = r.stops.iter().map(|s| s.midpoint).collect(); - let want: Vec = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect(); - assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}"); - for i in 0..=20 { - let t = i as f32 / 20.0; - let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t)); - let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1); - assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}"); - } - r.reverse(); - let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6); - assert!(back, "reversed twice, it is as it was: {:?}", r.stops); - } - } - - fn close(a: Point, b: Point) -> bool { - a.distance(b) < 1e-9 - } - - fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { - GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None } - } - - #[test] - fn rotate_90_gives_vertical_vector() { - let mut g = linear((0.0, 0.0), (100.0, 0.0)); - g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); - assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}"); - assert!((g.angle_deg() + 90.0).abs() < 1e-9); - } - - #[test] - fn reflect_swaps_start_and_end() { - let mut g = linear((0.0, 25.0), (100.0, 25.0)); - // Reflect across the vertical axis through x = 50. - let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0)); - g.transform(flip, GradientKind::Linear); - assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}"); - } - - #[test] - fn non_uniform_scale_turns_a_circle_into_an_ellipse() { - let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None }; - g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); - assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}"); - assert!((g.aspect - 0.5).abs() < 1e-9); - // A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long. - let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None }; - v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); - assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}"); - } - - #[test] - fn samples_follow_shear_and_rotation() { - let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4); - for kind in [GradientKind::Linear, GradientKind::Radial] { - let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None }; - let mut m = g; - m.transform(shear, kind); - for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] { - let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p)); - assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}"); - } - } - } - - #[test] - fn radial_fit_uses_the_angle_and_param_honours_aspect() { - let b = Rect::new(0.0, 0.0, 100.0, 60.0); - let g = GradientGeom::fit(GradientKind::Radial, b, 90.0); - assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}"); - let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None }; - assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12); - assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12); - assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); - } - - fn g3() -> Gradient { - let mut g = Gradient::default(); - g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); - g - } - - #[test] - fn insert_samples_color_and_sorts() { - let g = Gradient::default(); - let (stops, i) = insert_stop(&g, 0.25); - assert_eq!(stops.len(), 3); - assert_eq!(i, 1); - let r = stops[1].color.to_rgb()[0]; - assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); - let (stops, i) = insert_stop(&g, 2.0); - assert_eq!(i, 2); - assert_eq!(stops[2].offset, 1.0); - } - - #[test] - fn remove_keeps_two() { - let g = g3(); - let v = remove_stop(&g.stops, 1).unwrap(); - assert_eq!(v.len(), 2); - assert!(remove_stop(&v, 0).is_none()); - assert!(remove_stop(&g.stops, 9).is_none()); - } - - #[test] - fn move_reorders_and_tracks_index() { - let g = g3(); - let (v, i) = move_stop(&g.stops, 0, 0.8); - assert_eq!(i, 1); - assert_eq!(v[1].color.to_hex(), "#ffffff"); - assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); - let (v, i) = move_stop(&g.stops, 1, -3.0); - // Clamped to 0 and placed after the existing stop at 0. - assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); - } - - #[test] - fn duplicate_copies_the_stop_to_a_new_offset() { - let g = g3(); - let (v, i) = duplicate_stop(&g.stops, 1, 0.9); - assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string())); - assert_eq!(v[1], g.stops[1], "the original stays"); - assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); - } - - #[test] - fn swap_exchanges_only_the_colours() { - let mut g = g3(); - g.stops[0].opacity = 0.25; - let v = swap_stop_colors(&g.stops, 0, 1); - assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string())); - assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0)); - assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops); - } - - #[test] - fn midpoint_math() { - let g = g3(); - assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); - assert_eq!(midpoint_pos(&g.stops, 2), None); - let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); - assert!((m - 0.2).abs() < 1e-5); - assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); - let v = set_midpoint(&g.stops, 0, 0.99); - assert_eq!(v[0].midpoint, 0.87); - } - - #[test] - fn rebase_keeps_relative_positions() { - let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None }; - g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); - assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); - // A box without height (a horizontal line) keeps the offset from its centre line. - let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None }; - h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); - assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); - // Unplaced paints have nothing to move. - let mut p = GradientPaint::new(Gradient::default()); - p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0)); - assert_eq!(p.geom, None); - } - - #[test] - fn pin_fixes_the_fit_once() { - let mut p = GradientPaint::new(Gradient::default()); - p.angle = 90.0; - let b = Rect::new(0.0, 0.0, 100.0, 50.0); - p.pin(b); - assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); - p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); - assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); - } - - /// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre. - fn off_centre() -> GradientGeom { - let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None }; - g.set_focal(Some(Point::new(160.0, 200.0))); - g - } - - #[test] - fn focal_round_trips_through_serde_and_defaults_to_none() { - let g = off_centre(); - let s = serde_json::to_string(&g).unwrap(); - assert!(s.contains("\"focal\""), "{s}"); - assert_eq!(serde_json::from_str::(&s).unwrap(), g); - // Placements saved before focal points existed load centred, and centred ones write none. - let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap(); - assert_eq!((old.focal, old.aspect), (None, 1.0)); - assert!(!serde_json::to_string(&old).unwrap().contains("focal")); - } - - #[test] - fn focal_follows_transforms_and_rebases() { - let mut g = off_centre(); - let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5); - let f = a * g.focal.unwrap(); - g.transform(a, GradientKind::Radial); - assert!(close(g.focal.unwrap(), f), "{g:?}"); - // Still at the same parameter: the first stop. - assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9); - let mut h = off_centre(); - h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0)); - assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}"); - } - - #[test] - fn param_runs_from_the_focal_point_to_the_ellipse() { - let g = off_centre(); - let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y)); - assert!(t(160.0, 200.0).abs() < 1e-9); - // The extent circle is the last stop all round. - for a in [0.0f64, 1.0, 2.5, 4.0] { - let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin()); - assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}"); - } - // The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point - // (0.4 of the radius off), with a radius of 2/7. - assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0)); - assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier"); - assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5); - } - - #[test] - fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() { - let mut g = off_centre(); - g.set_focal(Some(Point::new(500.0, 200.0))); - assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}"); - g.set_focal(Some(Point::new(200.0, 200.0))); - assert_eq!(g.focal, None, "on the centre: centred"); - // A new vector and aspect keep it in its place in the ellipse. - let before = off_centre(); - let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None }; - h.keep_focal_from(&before); - assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); - } - - #[test] - fn pixels_sample_the_ramp_at_each_cell_centre() { - // White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end. - let mut g = GradientPaint::new(Gradient::default()); - g.gradient.stops[0].midpoint = 0.3; - g.gradient.stops[1].opacity = 0.5; - let b = Rect::new(0.0, 0.0, 10.0, 2.0); - let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect(); - assert_eq!(px.len(), 20); - let ramp = Ramp::new(&g.gradient, &Color::to_rgb); - for (i, p) in px.iter().take(10).enumerate() { - let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8); - assert_eq!(*p, c, "pixel {i}"); - } - assert_eq!(px[3], px[13], "rows alike"); - // The midpoint stop: grey at 30%; padded past the ends. - assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3)); - assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]); - assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); - assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); - } - - fn blue_yellow(method: GradientMethod) -> Gradient { - let mut g = Gradient::new( - GradientKind::Linear, - vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], - ); - g.method = method; - g - } - - /// Illustrator 30's Perceptual method mixes colours in a perceptually even way - /// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). - /// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey - /// a display RGB mix gives, and its lightness climbs evenly. - #[test] - fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() { - let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb(); - assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}"); - let p = blue_yellow(GradientMethod::Perceptual); - let [r, g, b] = p.sample(0.5).0.to_rgb(); - assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]); - assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]); - let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0]; - let steps: Vec = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect(); - assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}"); - // Ends and opacity as before. - assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff"); - assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00"); - } - - /// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual - /// curve (the midpoint diamond among them), more where it bends, close enough that the linear - /// ramp between them stays within half a colour level of the curve; a segment that hardly - /// bends needs few. - #[test] - fn perceptual_segments_expand_into_stops_on_the_curve() { - let pairs = [ - (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)), - (Color::BLACK, Color::rgb(1.0, 0.0, 0.0)), - (Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)), - (Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE), - ]; - for (a, b) in pairs { - let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); - p.method = GradientMethod::Perceptual; - p.stops[0].midpoint = 0.3; - p.stops[1].opacity = 0.5; - let ex: Vec<_> = p.expanded().collect(); - assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len()); - assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); - assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint"); - assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6)); - let ramp = Ramp::new(&p, &Color::to_rgb); - for k in 0..=2000 { - let t = k as f32 / 2000.0; - let (c, o) = p.sample(t); - let [r, g, b] = c.to_rgb(); - let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); - assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}"); - } - } - let mut flat = Gradient::new( - GradientKind::Linear, - vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))], - ); - flat.method = GradientMethod::Perceptual; - assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint"); - } - - /// Classic gradients expand as they always did: their stops, and a stop at each off-centre - /// midpoint, sampled in display RGB (so the renderer draws the same pixels as before). - #[test] - fn classic_gradients_expand_as_before() { - let mut g = Gradient::new( - GradientKind::Linear, - vec![ - GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) }, - GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) }, - GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) }, - GradientStop::new(1.0, Color::gray(0.3)), - ], - ); - // The expansion gradients had before they had a method. - let before: Vec<_> = g - .stops - .iter() - .enumerate() - .flat_map(|(i, s)| { - let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { - let t = s.offset + (n.offset - s.offset) * s.midpoint; - let (c, o) = g.sample_with(t, Color::lerp); - (t, c, o, Some(s.midpoint)) - }); - std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) - }) - .collect(); - assert_eq!(g.expanded().collect::>(), before); - assert_eq!(before.len(), 6); - assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp)); - assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model)); - g.method = GradientMethod::Perceptual; - assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix"); - } - - /// The method is written only when Perceptual, so files from before it (and Classic - /// gradients) read as Classic and new Classic files stay as they were. - #[test] - fn method_is_saved_only_when_perceptual() { - let classic = serde_json::to_value(Gradient::default()).unwrap(); - assert!(classic.get("method").is_none(), "{classic}"); - let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); - assert_eq!(old.method, GradientMethod::Classic); - let p = blue_yellow(GradientMethod::Perceptual); - let v = serde_json::to_value(&p).unwrap(); - assert_eq!(v["method"], "Perceptual"); - assert_eq!(serde_json::from_value::(v).unwrap(), p); - assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual)); - assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic)); - assert_eq!(GradientMethod::parse("smooth"), None); - } - - /// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there, - /// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke, - /// dithered or not. - #[test] - fn perceptual_gradients_draw_as_pixels() { - for fill in [true, false] { - assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill)); - assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill)); - let mut radial = blue_yellow(GradientMethod::Perceptual); - radial.kind = GradientKind::Radial; - radial.dither = true; - assert!(GradientPaint::new(radial).draws_as_pixels(fill)); - } - } -} +mod tests; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs new file mode 100644 index 000000000..19e9f44fc --- /dev/null +++ b/crates/color/src/gradient/tests.rs @@ -0,0 +1,457 @@ +use super::*; + +#[test] +fn sample_endpoints_and_mid() { + let g = Gradient::default(); + assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff"); + assert_eq!(g.sample(1.0).0.to_hex(), "#000000"); + let mid = g.sample(0.5).0.to_rgb()[0]; + assert!((mid - 0.5).abs() < 1e-5); +} + +/// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order +/// and repeated stop offsets included. +#[test] +fn increasing_samples_match_single_samples() { + let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let g = Gradient::new( + GradientKind::Linear, + vec![stop(0.1, "#ff0000", 0.3), stop(0.4, "#00ff00", 0.7), stop(0.4, "#0000ff", 0.5), stop(0.2, "#ffff00", 0.5), stop(0.9, "#000000", 0.5)], + ); + let ts: Vec = (0..=40).map(|k| k as f32 / 40.0).collect(); + let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect(); + assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); +} + +#[test] +fn midpoint_shifts() { + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; + // At t = 0.25 we should be half way. + assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.expanded_stops().len(), 3); +} + +#[test] +fn fit_linear_horizontal() { + let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + assert_eq!(g.start, Point::new(0.0, 25.0)); + assert_eq!(g.end, Point::new(100.0, 25.0)); + assert!((g.angle_deg()).abs() < 1e-9); + let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0); + assert!((v.angle_deg() - 90.0).abs() < 1e-9); + assert!((v.length() - 50.0).abs() < 1e-9); +} + +#[test] +fn reverse_stops() { + let mut g = Gradient::default(); + g.reverse(); + assert_eq!(g.stops[0].color.to_hex(), "#000000"); + assert_eq!(g.stops[0].offset, 0.0); +} + +/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same +/// the other way round. +#[test] +fn reverse_mirrors_midpoints() { + let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]); + let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]); + for g in [two, three] { + let mut r = g.clone(); + r.reverse(); + let mids: Vec = r.stops.iter().map(|s| s.midpoint).collect(); + let want: Vec = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect(); + assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}"); + for i in 0..=20 { + let t = i as f32 / 20.0; + let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t)); + let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}"); + } + r.reverse(); + let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6); + assert!(back, "reversed twice, it is as it was: {:?}", r.stops); + } +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 +} + +fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { + GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None } +} + +#[test] +fn rotate_90_gives_vertical_vector() { + let mut g = linear((0.0, 0.0), (100.0, 0.0)); + g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}"); + assert!((g.angle_deg() + 90.0).abs() < 1e-9); +} + +#[test] +fn reflect_swaps_start_and_end() { + let mut g = linear((0.0, 25.0), (100.0, 25.0)); + // Reflect across the vertical axis through x = 50. + let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0)); + g.transform(flip, GradientKind::Linear); + assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}"); +} + +#[test] +fn non_uniform_scale_turns_a_circle_into_an_ellipse() { + let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None }; + g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}"); + assert!((g.aspect - 0.5).abs() < 1e-9); + // A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long. + let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None }; + v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}"); +} + +#[test] +fn samples_follow_shear_and_rotation() { + let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4); + for kind in [GradientKind::Linear, GradientKind::Radial] { + let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None }; + let mut m = g; + m.transform(shear, kind); + for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] { + let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p)); + assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}"); + } + } +} + +#[test] +fn radial_fit_uses_the_angle_and_param_honours_aspect() { + let b = Rect::new(0.0, 0.0, 100.0, 60.0); + let g = GradientGeom::fit(GradientKind::Radial, b, 90.0); + assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}"); + let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None }; + assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12); + assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12); + assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); +} + +fn g3() -> Gradient { + let mut g = Gradient::default(); + g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); + g +} + +#[test] +fn insert_samples_color_and_sorts() { + let g = Gradient::default(); + let (stops, i) = insert_stop(&g, 0.25); + assert_eq!(stops.len(), 3); + assert_eq!(i, 1); + let r = stops[1].color.to_rgb()[0]; + assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); + let (stops, i) = insert_stop(&g, 2.0); + assert_eq!(i, 2); + assert_eq!(stops[2].offset, 1.0); +} + +#[test] +fn remove_keeps_two() { + let g = g3(); + let v = remove_stop(&g.stops, 1).unwrap(); + assert_eq!(v.len(), 2); + assert!(remove_stop(&v, 0).is_none()); + assert!(remove_stop(&g.stops, 9).is_none()); +} + +#[test] +fn move_reorders_and_tracks_index() { + let g = g3(); + let (v, i) = move_stop(&g.stops, 0, 0.8); + assert_eq!(i, 1); + assert_eq!(v[1].color.to_hex(), "#ffffff"); + assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); + let (v, i) = move_stop(&g.stops, 1, -3.0); + // Clamped to 0 and placed after the existing stop at 0. + assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); +} + +#[test] +fn duplicate_copies_the_stop_to_a_new_offset() { + let g = g3(); + let (v, i) = duplicate_stop(&g.stops, 1, 0.9); + assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string())); + assert_eq!(v[1], g.stops[1], "the original stays"); + assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); +} + +#[test] +fn swap_exchanges_only_the_colours() { + let mut g = g3(); + g.stops[0].opacity = 0.25; + let v = swap_stop_colors(&g.stops, 0, 1); + assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string())); + assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0)); + assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops); +} + +#[test] +fn midpoint_math() { + let g = g3(); + assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); + assert_eq!(midpoint_pos(&g.stops, 2), None); + let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); + assert!((m - 0.2).abs() < 1e-5); + assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); + let v = set_midpoint(&g.stops, 0, 0.99); + assert_eq!(v[0].midpoint, 0.87); +} + +#[test] +fn rebase_keeps_relative_positions() { + let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None }; + g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); + assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); + // A box without height (a horizontal line) keeps the offset from its centre line. + let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None }; + h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); + assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); + // Unplaced paints have nothing to move. + let mut p = GradientPaint::new(Gradient::default()); + p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0)); + assert_eq!(p.geom, None); +} + +#[test] +fn pin_fixes_the_fit_once() { + let mut p = GradientPaint::new(Gradient::default()); + p.angle = 90.0; + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + p.pin(b); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); + p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); +} + +/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre. +fn off_centre() -> GradientGeom { + let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None }; + g.set_focal(Some(Point::new(160.0, 200.0))); + g +} + +#[test] +fn focal_round_trips_through_serde_and_defaults_to_none() { + let g = off_centre(); + let s = serde_json::to_string(&g).unwrap(); + assert!(s.contains("\"focal\""), "{s}"); + assert_eq!(serde_json::from_str::(&s).unwrap(), g); + // Placements saved before focal points existed load centred, and centred ones write none. + let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap(); + assert_eq!((old.focal, old.aspect), (None, 1.0)); + assert!(!serde_json::to_string(&old).unwrap().contains("focal")); +} + +#[test] +fn focal_follows_transforms_and_rebases() { + let mut g = off_centre(); + let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5); + let f = a * g.focal.unwrap(); + g.transform(a, GradientKind::Radial); + assert!(close(g.focal.unwrap(), f), "{g:?}"); + // Still at the same parameter: the first stop. + assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9); + let mut h = off_centre(); + h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}"); +} + +#[test] +fn param_runs_from_the_focal_point_to_the_ellipse() { + let g = off_centre(); + let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y)); + assert!(t(160.0, 200.0).abs() < 1e-9); + // The extent circle is the last stop all round. + for a in [0.0f64, 1.0, 2.5, 4.0] { + let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin()); + assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}"); + } + // The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point + // (0.4 of the radius off), with a radius of 2/7. + assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0)); + assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier"); + assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5); +} + +#[test] +fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() { + let mut g = off_centre(); + g.set_focal(Some(Point::new(500.0, 200.0))); + assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}"); + g.set_focal(Some(Point::new(200.0, 200.0))); + assert_eq!(g.focal, None, "on the centre: centred"); + // A new vector and aspect keep it in its place in the ellipse. + let before = off_centre(); + let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None }; + h.keep_focal_from(&before); + assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); +} + +#[test] +fn pixels_sample_the_ramp_at_each_cell_centre() { + // White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end. + let mut g = GradientPaint::new(Gradient::default()); + g.gradient.stops[0].midpoint = 0.3; + g.gradient.stops[1].opacity = 0.5; + let b = Rect::new(0.0, 0.0, 10.0, 2.0); + let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect(); + assert_eq!(px.len(), 20); + let ramp = Ramp::new(&g.gradient, &Color::to_rgb); + for (i, p) in px.iter().take(10).enumerate() { + let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8); + assert_eq!(*p, c, "pixel {i}"); + } + assert_eq!(px[3], px[13], "rows alike"); + // The midpoint stop: grey at 30%; padded past the ends. + assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3)); + assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]); + assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); + assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); +} + +fn blue_yellow(method: GradientMethod) -> Gradient { + let mut g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], + ); + g.method = method; + g +} + +/// Illustrator 30's Perceptual method mixes colours in a perceptually even way +/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). +/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey +/// a display RGB mix gives, and its lightness climbs evenly. +#[test] +fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() { + let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb(); + assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}"); + let p = blue_yellow(GradientMethod::Perceptual); + let [r, g, b] = p.sample(0.5).0.to_rgb(); + assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]); + assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]); + let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0]; + let steps: Vec = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect(); + assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}"); + // Ends and opacity as before. + assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff"); + assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00"); +} + +/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual +/// curve (the midpoint diamond among them), more where it bends, close enough that the linear +/// ramp between them stays within half a colour level of the curve; a segment that hardly +/// bends needs few. +#[test] +fn perceptual_segments_expand_into_stops_on_the_curve() { + let pairs = [ + (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)), + (Color::BLACK, Color::rgb(1.0, 0.0, 0.0)), + (Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)), + (Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE), + ]; + for (a, b) in pairs { + let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); + p.method = GradientMethod::Perceptual; + p.stops[0].midpoint = 0.3; + p.stops[1].opacity = 0.5; + let ex: Vec<_> = p.expanded().collect(); + assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len()); + assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); + assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint"); + assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6)); + let ramp = Ramp::new(&p, &Color::to_rgb); + for k in 0..=2000 { + let t = k as f32 / 2000.0; + let (c, o) = p.sample(t); + let [r, g, b] = c.to_rgb(); + let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}"); + } + } + let mut flat = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))], + ); + flat.method = GradientMethod::Perceptual; + assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint"); +} + +/// Classic gradients expand as they always did: their stops, and a stop at each off-centre +/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before). +#[test] +fn classic_gradients_expand_as_before() { + let mut g = Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) }, + GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) }, + GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) }, + GradientStop::new(1.0, Color::gray(0.3)), + ], + ); + // The expansion gradients had before they had a method. + let before: Vec<_> = g + .stops + .iter() + .enumerate() + .flat_map(|(i, s)| { + let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { + let t = s.offset + (n.offset - s.offset) * s.midpoint; + let (c, o) = g.sample_with(t, Color::lerp); + (t, c, o, Some(s.midpoint)) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + }) + .collect(); + assert_eq!(g.expanded().collect::>(), before); + assert_eq!(before.len(), 6); + assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp)); + assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model)); + g.method = GradientMethod::Perceptual; + assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix"); +} + +/// The method is written only when Perceptual, so files from before it (and Classic +/// gradients) read as Classic and new Classic files stay as they were. +#[test] +fn method_is_saved_only_when_perceptual() { + let classic = serde_json::to_value(Gradient::default()).unwrap(); + assert!(classic.get("method").is_none(), "{classic}"); + let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!(old.method, GradientMethod::Classic); + let p = blue_yellow(GradientMethod::Perceptual); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["method"], "Perceptual"); + assert_eq!(serde_json::from_value::(v).unwrap(), p); + assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual)); + assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic)); + assert_eq!(GradientMethod::parse("smooth"), None); +} + +/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there, +/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke, +/// dithered or not. +#[test] +fn perceptual_gradients_draw_as_pixels() { + for fill in [true, false] { + assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill)); + assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill)); + let mut radial = blue_yellow(GradientMethod::Perceptual); + radial.kind = GradientKind::Radial; + radial.dither = true; + assert!(GradientPaint::new(radial).draws_as_pixels(fill)); + } +} diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index b1f51213b..b331d43e6 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -668,524 +668,4 @@ impl UniqueNames { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Gradient, GradientPaint, GradientStop}; - use proptest::prelude::*; - - #[test] - fn gpl_blank_name_keeps_fallback() { - let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n"; - assert_eq!(read(blank, "fallback").unwrap().name, "fallback"); - let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n"; - assert_eq!(read(named, "fallback").unwrap().name, "Warm"); - } - - /// A color book's string: a `u32` count of UTF-16 code units, then the units. - fn acb_str(out: &mut Vec, s: &str) { - let units: Vec = s.encode_utf16().collect(); - out.extend((units.len() as u32).to_be_bytes()); - units.iter().for_each(|u| out.extend(u.to_be_bytes())); - } - - /// A color book in `space` (0 RGB, 2 CMYK, 7 Lab) with `colors` (name, components) and an - /// optional spot/process marker. - fn acb(space: u16, colors: &[(&str, &[u8])], marker: &[u8]) -> Vec { - let mut b = b"8BCB".to_vec(); - b.extend(1u16.to_be_bytes()); - b.extend(3000u16.to_be_bytes()); - for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] { - acb_str(&mut b, s); - } - b.extend((colors.len() as u16).to_be_bytes()); - b.extend(7u16.to_be_bytes()); - b.extend(0u16.to_be_bytes()); - b.extend(space.to_be_bytes()); - for (name, comps) in colors { - acb_str(&mut b, name); - b.extend(b"TST001"); - b.extend(*comps); - } - b.extend(marker); - b - } - - /// #831: color books (`.acb`) are read: their colors in RGB, CMYK or Lab, named with the book's - /// prefix and suffix, spot unless the book says process; page padding is left out. - #[test] - fn color_books_are_read() { - let lib = read_bytes(&acb(0, &[("185", &[228, 0, 43]), ("", &[0, 0, 0]), ("Red 032", &[239, 51, 64])], b"spflspot"), "fallback").unwrap(); - assert_eq!(lib.name, "Test Book®"); - let names: Vec<&str> = lib.swatches.iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, ["TEST 185 C", "TEST Red 032 C"]); - assert!(lib.swatches.iter().all(|s| s.spot && s.global)); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(228, 0, 43))); - // CMYK ink is stored inverted; a book marked process gives process colors. - let lib = read_bytes(&acb(2, &[("Cyan", &[0, 255, 255, 255])], b"spflproc"), "x").unwrap(); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); - assert!(!lib.swatches[0].spot && !lib.swatches[0].global); - // Lab: lightness 0..255 for 0..100, a and b offset by 128; no marker: spot. - let lib = read_bytes(&acb(7, &[("Grey", &[255, 128, 128])], b""), "x").unwrap(); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::lab(100.0, 0.0, 0.0))); - assert!(lib.swatches[0].spot); - assert!(sniff_bytes(&acb(0, &[], b""))); - // Unsupported versions and color spaces, and short files, are errors. - let mut v2 = acb(0, &[], b""); - v2[5] = 2; - assert!(read_bytes(&v2, "x").is_err()); - assert!(read_bytes(&acb(1, &[("x", &[1, 2, 3])], b""), "x").is_err()); - let full = acb(0, &[("185", &[228, 0, 43])], b""); - for cut in 4..full.len() { - assert!(read_bytes(&full[..cut], "x").is_err(), "{cut} bytes"); - } - let mut huge = b"8BCB\0\x01\0\x01".to_vec(); - huge.extend(u32::MAX.to_be_bytes()); - assert!(read_bytes(&huge, "x").is_err(), "a string longer than the file"); - } - - fn sample() -> SwatchLibrary { - let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( - GradientKind::Linear, - vec![ - GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, - GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, - ], - )))); - SwatchLibrary { - name: "Brand */ Colours".into(), - swatches: vec![ - Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true }, - Swatch { name: "Fade".into(), paint: grad, global: false, spot: false }, - Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false }, - ], - groups: vec![SwatchGroup { - name: "Neutrals".into(), - swatches: vec![ - Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false }, - Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false }, - ], - }], - } - } - - /// `lib` written as text format `f`. - fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String { - String::from_utf8(write(lib, f).unwrap()).unwrap() - } - - #[test] - fn native_round_trip_keeps_cmyk_spot_and_groups() { - let lib = sample(); - let text = written(&lib, PaletteFormat::Native); - assert!(sniff(&text)); - let back = read(&text, "fallback").unwrap(); - let mut expected = lib.clone(); - expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. })); - assert_eq!(back, expected, "everything but the pattern comes back exactly"); - assert!(back.swatches[0].spot && back.swatches[0].global); - assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); - assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); - } - - #[test] - fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { - let text = written(&sample(), PaletteFormat::Gpl); - assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); - assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); - assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); - let back = read(&text, "x").unwrap(); - assert_eq!(back.name, "Brand */ Colours"); - assert_eq!(back.swatches.len(), 1); - assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB"); - // Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names. - let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n"; - let lib = read(other, "file").unwrap(); - assert_eq!(lib.name, "Sunset"); - let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); - assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); - assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); - // Colors and groups share names; an unnamed group is "Color Group". - let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap(); - let groups: Vec<(&str, Vec<&str>)> = - lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); - assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]); - } - - #[test] - fn css_escapes_names_and_writes_gradients() { - assert_eq!(css_property("Sky Blue"), "--sky-blue"); - assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0"); - assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); - assert_eq!(css_property("Café 50%"), "--café-50\\%"); - assert_eq!(css_property(" "), "--swatch"); - let text = written(&sample(), PaletteFormat::Css); - assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); - assert!(text.contains(" --ink: #"), "{text}"); - assert!(text.contains("; /* spot */")); - assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}"); - assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}"); - assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n")); - assert!(!text.contains("tiles")); - // Names that collide once escaped are numbered. - let lib = SwatchLibrary { - name: "x".into(), - swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), - groups: vec![], - }; - let text = written(&lib, PaletteFormat::Css); - assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); - assert!(read(&text, "x").is_err(), "CSS is written only"); - } - - #[test] - fn css_preserves_fractional_gradient_stop_positions() { - for kind in [GradientKind::Linear, GradientKind::Radial] { - let lib = SwatchLibrary { - name: "Positions".into(), - swatches: vec![Swatch { - name: "Ramp".into(), - paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( - kind, - vec![ - GradientStop::new(0.0, Color::BLACK), - GradientStop::new(0.125, Color::rgb8(255, 0, 0)), - GradientStop::new(0.12890625, Color::WHITE), - GradientStop::new(1.0, Color::rgb8(0, 0, 255)), - ], - )))), - global: false, - spot: false, - }], - groups: vec![], - }; - let text = written(&lib, PaletteFormat::Css); - assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}"); - } - } - - #[test] - fn holds_counts_the_swatches_each_format_writes() { - // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. - let mut lib = sample(); - let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") }; - mesh.gradient.kind = GradientKind::Freeform; - lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false }); - lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }); - let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count(); - assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css"); - for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { - assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}"); - } - let css = written(&lib, PaletteFormat::Css); - assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}"); - } - - /// A swatch exchange file of `blocks` (type, body). - fn ase(blocks: &[(u16, Vec)]) -> Vec { - let mut out = b"ASEF\0\x01\0\0".to_vec(); - out.extend((blocks.len() as u32).to_be_bytes()); - for (kind, body) in blocks { - out.extend(kind.to_be_bytes()); - out.extend((body.len() as u32).to_be_bytes()); - out.extend(body); - } - out - } - - /// A name as `.ase` stores it. - fn ase_name(name: &str) -> Vec { - let units: Vec = name.encode_utf16().chain([0]).collect(); - let mut out = (units.len() as u16).to_be_bytes().to_vec(); - out.extend(units.iter().flat_map(|u| u.to_be_bytes())); - out - } - - /// A color block: name, model, components and color type. - fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec) { - let mut body = ase_name(name); - body.extend(model); - body.extend(values.iter().flat_map(|v| v.to_be_bytes())); - body.extend(kind.to_be_bytes()); - (ASE_COLOR, body) - } - - #[test] - fn ase_reads_color_models_kinds_and_groups() { - let bytes = ase(&[ - ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), - ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), - (ASE_GROUP_START, ase_name("Neutrals")), - ase_color("Mist", b"Gray", &[0.75], 2), - ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), - (ASE_GROUP_END, vec![]), - ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2), - ]); - assert!(sniff_bytes(&bytes)); - let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; - let expected = SwatchLibrary { - name: "Brand".into(), - swatches: vec![ - sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false), - sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true), - sw("Paper", Color::WHITE, false, false), - ], - groups: vec![SwatchGroup { - name: "Neutrals".into(), - swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)], - }], - }; - assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process"); - // A lightness above 1 is L* itself. - let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap(); - assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0))); - // Text libraries read through the same call, also when they aren't valid UTF-8. - assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0])); - assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1); - let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n"; - assert!(sniff_bytes(latin1)); - assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1); - } - - #[test] - fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() { - let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2); - untyped.1.truncate(untyped.1.len() - 2); - let bytes = ase(&[ - ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2), - ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2), - ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0), - ase_color("Red", b"Gray", &[0.0], 2), - untyped, - ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2), - (0x0042, vec![1, 2, 3]), - (ASE_GROUP_END, vec![]), - (ASE_GROUP_START, ase_name("")), - (ASE_GROUP_START, ase_name("Red")), - ase_color("Red", b"Gray", &[1.0], 2), - ]); - let lib = read_bytes(&bytes, "x").unwrap(); - let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); - assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped"); - assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0"); - assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process"); - // A group start ends the open group, and groups and colors share names. - let groups: Vec<(&str, Vec<&str>)> = - lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); - assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]); - assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white"); - } - - #[test] - fn damaged_ase_files_are_errors() { - let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]); - assert_eq!(read_bytes(&good, "x").unwrap().len(), 1); - for n in ASE_SIGNATURE.len()..good.len() { - assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}"); - } - let mut v2 = good.clone(); - v2[5] = 2; - assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0"); - // Too many colors, or too many groups (empty group starts take 6 bytes each). - let colors: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect(); - assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than")); - let groups: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect(); - assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than")); - assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit"); - } - - #[test] - fn ase_writes_each_model_kind_and_group() { - let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; - let tint = Swatch { - name: "Ink 40%".into(), - paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 }, - global: false, - spot: false, - }; - // `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals. - let mut lib = sample(); - lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false)); - lib.swatches.push(tint); - lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)]; - lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] }); - let bytes = write(&lib, PaletteFormat::Ase).unwrap(); - let expected = ase(&[ - ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), - ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), - ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2), - (ASE_GROUP_START, ase_name("Neutrals")), - ase_color("Mist", b"Gray", &[0.75], 2), - ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), - (ASE_GROUP_END, vec![]), - (ASE_GROUP_START, ase_name("Empty")), - (ASE_GROUP_END, vec![]), - ]); - assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows"); - let back = read_bytes(&bytes, "Brand").unwrap(); - assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays"); - assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase)); - assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary()); - } - - #[test] - fn ase_names_lose_nul_and_stop_at_the_reader_limit() { - let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))]; - let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect(); - let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] }; - let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); - let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect(); - // At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji. - assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]); - } - - #[test] - fn ase_clamps_components_and_writes_non_finite_ones_as_zero() { - let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }; - let swatches = vec![ - sw("Lab", Color::lab(100.5, 300.0, -300.0)), - sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)), - sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)), - sw("Gray", Color::gray(1.5)), - ]; - let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap(); - let expected = ase(&[ - ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2), - ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2), - ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2), - ase_color("Gray", b"Gray", &[0.0], 2), - ]); - assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0"); - // Lightness 100.5 is written as 1, which reads back as L* 100. - let back = read_bytes(&bytes, "x").unwrap(); - assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0))); - } - - #[test] - fn ase_write_refuses_more_entries_than_the_reader_takes() { - let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false }; - // The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it. - let mut lib = SwatchLibrary { - name: "x".into(), - swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(), - groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }], - }; - let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); - assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1)); - lib.swatches.push(gray(0)); - let e = write(&lib, PaletteFormat::Ase).unwrap_err(); - assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}"); - // A gradient is left out and is not counted. - lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone(); - assert!(write(&lib, PaletteFormat::Ase).is_ok()); - } - - /// A color in one of the four models, in the ranges `.ase` holds. - fn arb_color() -> impl Strategy { - let unit = || 0.0f32..=1.0; - prop_oneof![ - (unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)), - (unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)), - unit().prop_map(Color::gray), - (0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)), - ] - } - - /// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch - /// (both flags off) or a gradient. - fn arb_paint() -> impl Strategy { - prop_oneof![ - 4 => (arb_color(), any::(), any::()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)), - 1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)), - 1 => Just((sample().swatches[1].paint.clone(), false, false)), - ] - } - - /// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script - /// but NUL, trimmed, so swatches and groups have unique names, as in a document. - fn arb_library() -> impl Strategy { - let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12); - (swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| { - let mut n = 0; - let mut name = |prefix: &str, base: &str| { - n += 1; - format!("{prefix}{n} {base}").trim().to_string() - }; - let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() }; - for ((paint, global, spot), base) in loose { - lib.swatches.push(Swatch { name: name("", &base), paint, global, spot }); - } - for (base, list) in groups { - let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] }; - for ((paint, global, spot), base) in list { - g.swatches.push(Swatch { name: name("", &base), paint, global, spot }); - } - lib.groups.push(g); - } - lib - }) - } - - /// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and - /// gray read back within `f32` rounding. - fn close(got: Color, want: Color) -> bool { - let near = |a: f32, b: f32| (a - b).abs() < 1e-4; - match (got, want) { - (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2), - (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2), - (Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2), - (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2), - _ => false, - } - } - - /// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot - /// colors global. - fn ase_expected(list: &[Swatch]) -> Vec { - list.iter() - .filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot })) - .collect() - } - - /// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`? - fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool { - got.len() == want.len() - && got.iter().zip(want).all(|(g, w)| { - let color = match (&g.paint, w.paint.color()) { - (Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c), - _ => false, - }; - g.name == w.name && g.global == w.global && g.spot == w.spot && color - }) - } - - proptest! { - #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] - - /// A written `.ase` file reads back as the library's solid colors in their own model, with - /// their names, kinds and color groups. - #[test] - fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) { - let bytes = write(&lib, PaletteFormat::Ase).unwrap(); - prop_assert!(sniff_bytes(&bytes)); - let back = read_bytes(&bytes, &lib.name).unwrap(); - prop_assert_eq!(&back.name, &lib.name); - prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches); - prop_assert_eq!(back.groups.len(), lib.groups.len()); - for (g, want) in back.groups.iter().zip(&lib.groups) { - prop_assert_eq!(&g.name, &want.name); - prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches); - } - } - - /// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as - /// [`PaletteFormat::holds`] counts. - #[test] - fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) { - for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { - let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap(); - prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f); - } - } - } -} +mod tests; diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs new file mode 100644 index 000000000..b0342d7a5 --- /dev/null +++ b/crates/color/src/palette_io/tests.rs @@ -0,0 +1,517 @@ +use super::*; +use crate::{Gradient, GradientPaint, GradientStop}; +use proptest::prelude::*; + +#[test] +fn gpl_blank_name_keeps_fallback() { + let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n"; + assert_eq!(read(blank, "fallback").unwrap().name, "fallback"); + let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n"; + assert_eq!(read(named, "fallback").unwrap().name, "Warm"); +} + +/// A color book's string: a `u32` count of UTF-16 code units, then the units. +fn acb_str(out: &mut Vec, s: &str) { + let units: Vec = s.encode_utf16().collect(); + out.extend((units.len() as u32).to_be_bytes()); + units.iter().for_each(|u| out.extend(u.to_be_bytes())); +} + +/// A color book in `space` (0 RGB, 2 CMYK, 7 Lab) with `colors` (name, components) and an +/// optional spot/process marker. +fn acb(space: u16, colors: &[(&str, &[u8])], marker: &[u8]) -> Vec { + let mut b = b"8BCB".to_vec(); + b.extend(1u16.to_be_bytes()); + b.extend(3000u16.to_be_bytes()); + for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] { + acb_str(&mut b, s); + } + b.extend((colors.len() as u16).to_be_bytes()); + b.extend(7u16.to_be_bytes()); + b.extend(0u16.to_be_bytes()); + b.extend(space.to_be_bytes()); + for (name, comps) in colors { + acb_str(&mut b, name); + b.extend(b"TST001"); + b.extend(*comps); + } + b.extend(marker); + b +} + +/// #831: color books (`.acb`) are read: their colors in RGB, CMYK or Lab, named with the book's +/// prefix and suffix, spot unless the book says process; page padding is left out. +#[test] +fn color_books_are_read() { + let lib = read_bytes(&acb(0, &[("185", &[228, 0, 43]), ("", &[0, 0, 0]), ("Red 032", &[239, 51, 64])], b"spflspot"), "fallback").unwrap(); + assert_eq!(lib.name, "Test Book®"); + let names: Vec<&str> = lib.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["TEST 185 C", "TEST Red 032 C"]); + assert!(lib.swatches.iter().all(|s| s.spot && s.global)); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(228, 0, 43))); + // CMYK ink is stored inverted; a book marked process gives process colors. + let lib = read_bytes(&acb(2, &[("Cyan", &[0, 255, 255, 255])], b"spflproc"), "x").unwrap(); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); + assert!(!lib.swatches[0].spot && !lib.swatches[0].global); + // Lab: lightness 0..255 for 0..100, a and b offset by 128; no marker: spot. + let lib = read_bytes(&acb(7, &[("Grey", &[255, 128, 128])], b""), "x").unwrap(); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::lab(100.0, 0.0, 0.0))); + assert!(lib.swatches[0].spot); + assert!(sniff_bytes(&acb(0, &[], b""))); + // Unsupported versions and color spaces, and short files, are errors. + let mut v2 = acb(0, &[], b""); + v2[5] = 2; + assert!(read_bytes(&v2, "x").is_err()); + assert!(read_bytes(&acb(1, &[("x", &[1, 2, 3])], b""), "x").is_err()); + let full = acb(0, &[("185", &[228, 0, 43])], b""); + for cut in 4..full.len() { + assert!(read_bytes(&full[..cut], "x").is_err(), "{cut} bytes"); + } + let mut huge = b"8BCB\0\x01\0\x01".to_vec(); + huge.extend(u32::MAX.to_be_bytes()); + assert!(read_bytes(&huge, "x").is_err(), "a string longer than the file"); +} + +fn sample() -> SwatchLibrary { + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, + GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, + ], + )))); + SwatchLibrary { + name: "Brand */ Colours".into(), + swatches: vec![ + Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true }, + Swatch { name: "Fade".into(), paint: grad, global: false, spot: false }, + Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false }, + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![ + Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false }, + Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false }, + ], + }], + } +} + +/// `lib` written as text format `f`. +fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String { + String::from_utf8(write(lib, f).unwrap()).unwrap() +} + +#[test] +fn native_round_trip_keeps_cmyk_spot_and_groups() { + let lib = sample(); + let text = written(&lib, PaletteFormat::Native); + assert!(sniff(&text)); + let back = read(&text, "fallback").unwrap(); + let mut expected = lib.clone(); + expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. })); + assert_eq!(back, expected, "everything but the pattern comes back exactly"); + assert!(back.swatches[0].spot && back.swatches[0].global); + assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); +} + +#[test] +fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { + let text = written(&sample(), PaletteFormat::Gpl); + assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); + assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); + assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); + let back = read(&text, "x").unwrap(); + assert_eq!(back.name, "Brand */ Colours"); + assert_eq!(back.swatches.len(), 1); + assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB"); + // Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names. + let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n"; + let lib = read(other, "file").unwrap(); + assert_eq!(lib.name, "Sunset"); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); + assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); + // Colors and groups share names; an unnamed group is "Color Group". + let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap(); + let groups: Vec<(&str, Vec<&str>)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]); +} + +#[test] +fn css_escapes_names_and_writes_gradients() { + assert_eq!(css_property("Sky Blue"), "--sky-blue"); + assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0"); + assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); + assert_eq!(css_property("Café 50%"), "--café-50\\%"); + assert_eq!(css_property(" "), "--swatch"); + let text = written(&sample(), PaletteFormat::Css); + assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); + assert!(text.contains(" --ink: #"), "{text}"); + assert!(text.contains("; /* spot */")); + assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}"); + assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}"); + assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n")); + assert!(!text.contains("tiles")); + // Names that collide once escaped are numbered. + let lib = SwatchLibrary { + name: "x".into(), + swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); + assert!(read(&text, "x").is_err(), "CSS is written only"); +} + +#[test] +fn css_preserves_fractional_gradient_stop_positions() { + for kind in [GradientKind::Linear, GradientKind::Radial] { + let lib = SwatchLibrary { + name: "Positions".into(), + swatches: vec![Swatch { + name: "Ramp".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + kind, + vec![ + GradientStop::new(0.0, Color::BLACK), + GradientStop::new(0.125, Color::rgb8(255, 0, 0)), + GradientStop::new(0.12890625, Color::WHITE), + GradientStop::new(1.0, Color::rgb8(0, 0, 255)), + ], + )))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}"); + } +} + +#[test] +fn holds_counts_the_swatches_each_format_writes() { + // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. + let mut lib = sample(); + let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") }; + mesh.gradient.kind = GradientKind::Freeform; + lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false }); + lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }); + let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count(); + assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css"); + for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { + assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}"); + } + let css = written(&lib, PaletteFormat::Css); + assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}"); +} + +/// A swatch exchange file of `blocks` (type, body). +fn ase(blocks: &[(u16, Vec)]) -> Vec { + let mut out = b"ASEF\0\x01\0\0".to_vec(); + out.extend((blocks.len() as u32).to_be_bytes()); + for (kind, body) in blocks { + out.extend(kind.to_be_bytes()); + out.extend((body.len() as u32).to_be_bytes()); + out.extend(body); + } + out +} + +/// A name as `.ase` stores it. +fn ase_name(name: &str) -> Vec { + let units: Vec = name.encode_utf16().chain([0]).collect(); + let mut out = (units.len() as u16).to_be_bytes().to_vec(); + out.extend(units.iter().flat_map(|u| u.to_be_bytes())); + out +} + +/// A color block: name, model, components and color type. +fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec) { + let mut body = ase_name(name); + body.extend(model); + body.extend(values.iter().flat_map(|v| v.to_be_bytes())); + body.extend(kind.to_be_bytes()); + (ASE_COLOR, body) +} + +#[test] +fn ase_reads_color_models_kinds_and_groups() { + let bytes = ase(&[ + ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), + ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), + (ASE_GROUP_START, ase_name("Neutrals")), + ase_color("Mist", b"Gray", &[0.75], 2), + ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), + (ASE_GROUP_END, vec![]), + ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2), + ]); + assert!(sniff_bytes(&bytes)); + let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; + let expected = SwatchLibrary { + name: "Brand".into(), + swatches: vec![ + sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false), + sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true), + sw("Paper", Color::WHITE, false, false), + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)], + }], + }; + assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process"); + // A lightness above 1 is L* itself. + let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap(); + assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0))); + // Text libraries read through the same call, also when they aren't valid UTF-8. + assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0])); + assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1); + let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n"; + assert!(sniff_bytes(latin1)); + assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1); +} + +#[test] +fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() { + let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2); + untyped.1.truncate(untyped.1.len() - 2); + let bytes = ase(&[ + ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2), + ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2), + ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0), + ase_color("Red", b"Gray", &[0.0], 2), + untyped, + ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2), + (0x0042, vec![1, 2, 3]), + (ASE_GROUP_END, vec![]), + (ASE_GROUP_START, ase_name("")), + (ASE_GROUP_START, ase_name("Red")), + ase_color("Red", b"Gray", &[1.0], 2), + ]); + let lib = read_bytes(&bytes, "x").unwrap(); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped"); + assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0"); + assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process"); + // A group start ends the open group, and groups and colors share names. + let groups: Vec<(&str, Vec<&str>)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]); + assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white"); +} + +#[test] +fn damaged_ase_files_are_errors() { + let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]); + assert_eq!(read_bytes(&good, "x").unwrap().len(), 1); + for n in ASE_SIGNATURE.len()..good.len() { + assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}"); + } + let mut v2 = good.clone(); + v2[5] = 2; + assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0"); + // Too many colors, or too many groups (empty group starts take 6 bytes each). + let colors: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect(); + assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than")); + let groups: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect(); + assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than")); + assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit"); +} + +#[test] +fn ase_writes_each_model_kind_and_group() { + let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; + let tint = Swatch { + name: "Ink 40%".into(), + paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 }, + global: false, + spot: false, + }; + // `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals. + let mut lib = sample(); + lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false)); + lib.swatches.push(tint); + lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)]; + lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] }); + let bytes = write(&lib, PaletteFormat::Ase).unwrap(); + let expected = ase(&[ + ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), + ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), + ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2), + (ASE_GROUP_START, ase_name("Neutrals")), + ase_color("Mist", b"Gray", &[0.75], 2), + ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), + (ASE_GROUP_END, vec![]), + (ASE_GROUP_START, ase_name("Empty")), + (ASE_GROUP_END, vec![]), + ]); + assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows"); + let back = read_bytes(&bytes, "Brand").unwrap(); + assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays"); + assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase)); + assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary()); +} + +#[test] +fn ase_names_lose_nul_and_stop_at_the_reader_limit() { + let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))]; + let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect(); + let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] }; + let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); + let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect(); + // At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji. + assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]); +} + +#[test] +fn ase_clamps_components_and_writes_non_finite_ones_as_zero() { + let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }; + let swatches = vec![ + sw("Lab", Color::lab(100.5, 300.0, -300.0)), + sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)), + sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)), + sw("Gray", Color::gray(1.5)), + ]; + let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap(); + let expected = ase(&[ + ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2), + ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2), + ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2), + ase_color("Gray", b"Gray", &[0.0], 2), + ]); + assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0"); + // Lightness 100.5 is written as 1, which reads back as L* 100. + let back = read_bytes(&bytes, "x").unwrap(); + assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0))); +} + +#[test] +fn ase_write_refuses_more_entries_than_the_reader_takes() { + let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false }; + // The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it. + let mut lib = SwatchLibrary { + name: "x".into(), + swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(), + groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }], + }; + let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); + assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1)); + lib.swatches.push(gray(0)); + let e = write(&lib, PaletteFormat::Ase).unwrap_err(); + assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}"); + // A gradient is left out and is not counted. + lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone(); + assert!(write(&lib, PaletteFormat::Ase).is_ok()); +} + +/// A color in one of the four models, in the ranges `.ase` holds. +fn arb_color() -> impl Strategy { + let unit = || 0.0f32..=1.0; + prop_oneof![ + (unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)), + (unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)), + unit().prop_map(Color::gray), + (0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)), + ] +} + +/// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch +/// (both flags off) or a gradient. +fn arb_paint() -> impl Strategy { + prop_oneof![ + 4 => (arb_color(), any::(), any::()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)), + 1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)), + 1 => Just((sample().swatches[1].paint.clone(), false, false)), + ] +} + +/// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script +/// but NUL, trimmed, so swatches and groups have unique names, as in a document. +fn arb_library() -> impl Strategy { + let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12); + (swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| { + let mut n = 0; + let mut name = |prefix: &str, base: &str| { + n += 1; + format!("{prefix}{n} {base}").trim().to_string() + }; + let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() }; + for ((paint, global, spot), base) in loose { + lib.swatches.push(Swatch { name: name("", &base), paint, global, spot }); + } + for (base, list) in groups { + let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] }; + for ((paint, global, spot), base) in list { + g.swatches.push(Swatch { name: name("", &base), paint, global, spot }); + } + lib.groups.push(g); + } + lib + }) +} + +/// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and +/// gray read back within `f32` rounding. +fn close(got: Color, want: Color) -> bool { + let near = |a: f32, b: f32| (a - b).abs() < 1e-4; + match (got, want) { + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2), + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2), + (Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2), + (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2), + _ => false, + } +} + +/// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot +/// colors global. +fn ase_expected(list: &[Swatch]) -> Vec { + list.iter() + .filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot })) + .collect() +} + +/// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`? +fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool { + got.len() == want.len() + && got.iter().zip(want).all(|(g, w)| { + let color = match (&g.paint, w.paint.color()) { + (Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c), + _ => false, + }; + g.name == w.name && g.global == w.global && g.spot == w.spot && color + }) +} + +proptest! { + #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] + + /// A written `.ase` file reads back as the library's solid colors in their own model, with + /// their names, kinds and color groups. + #[test] + fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) { + let bytes = write(&lib, PaletteFormat::Ase).unwrap(); + prop_assert!(sniff_bytes(&bytes)); + let back = read_bytes(&bytes, &lib.name).unwrap(); + prop_assert_eq!(&back.name, &lib.name); + prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches); + prop_assert_eq!(back.groups.len(), lib.groups.len()); + for (g, want) in back.groups.iter().zip(&lib.groups) { + prop_assert_eq!(&g.name, &want.name); + prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches); + } + } + + /// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as + /// [`PaletteFormat::holds`] counts. + #[test] + fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) { + for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { + let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap(); + prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f); + } + } +} diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 9895cc7f9..4ac758ffa 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -4,658 +4,14 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{BlendMode, Color, Paint}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum LineCap { - #[default] - Butt, - Round, - Square, -} +mod layers; +mod profile; +mod stroke_style; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum LineJoin { - #[default] - Miter, - Round, - Bevel, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum StrokeAlign { - #[default] - Center, - Inside, - Outside, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct Dash { - /// Dash, gap, dash, gap… (up to 6 values in the Stroke panel). - pub pattern: Vec, - #[serde(default)] - pub offset: f64, - /// "Aligns dashes to corners and path ends, adjusting lengths to fit". - #[serde(default)] - pub align_corners: bool, -} - -impl Dash { - /// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a - /// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the - /// stroke is drawn solid. - pub fn is_dashed(&self) -> bool { - self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) - } - /// Scale the dash and gap lengths and the offset by `s`. - pub fn scale(&mut self, s: f64) { - self.pattern.iter_mut().for_each(|v| *v *= s); - self.offset *= s; - } -} - -/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Arrowhead { - Triangle, - TriangleOpen, - Circle, - CircleOpen, - Square, - SquareOpen, - Bar, - Diamond, - Arrow, - ArrowOpen, - Barbed, - /// One barb, on the left of the direction the head points. - HalfArrowLeft, - /// One barb, on the right of the direction the head points. - HalfArrowRight, - Concave, - DoubleBar, - Feather, - DotOnBar, - Chevron, - DoubleArrow, - Target, - Star, - Cross, - Plus, - Hexagon, - HexagonOpen, - Tag, - TagOpen, - HalfCircle, - Drop, - Slash, - DoubleSlash, - DiamondOpen, - TriangleReverse, - Swallowtail, - Bracket, - Fork, - Leaf, - Kite, - TriangleBar, - Oval, -} - -impl Arrowhead { - /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). - pub const ALL: [Arrowhead; 40] = [ - Arrowhead::Arrow, - Arrowhead::ArrowOpen, - Arrowhead::Barbed, - Arrowhead::Concave, - Arrowhead::DoubleArrow, - Arrowhead::HalfArrowLeft, - Arrowhead::HalfArrowRight, - Arrowhead::Chevron, - Arrowhead::Feather, - Arrowhead::Swallowtail, - Arrowhead::Triangle, - Arrowhead::TriangleOpen, - Arrowhead::TriangleReverse, - Arrowhead::TriangleBar, - Arrowhead::Kite, - Arrowhead::Leaf, - Arrowhead::Drop, - Arrowhead::Circle, - Arrowhead::CircleOpen, - Arrowhead::HalfCircle, - Arrowhead::Oval, - Arrowhead::Target, - Arrowhead::Square, - Arrowhead::SquareOpen, - Arrowhead::Tag, - Arrowhead::TagOpen, - Arrowhead::Diamond, - Arrowhead::DiamondOpen, - Arrowhead::Hexagon, - Arrowhead::HexagonOpen, - Arrowhead::Star, - Arrowhead::Cross, - Arrowhead::Plus, - Arrowhead::Bar, - Arrowhead::DoubleBar, - Arrowhead::DotOnBar, - Arrowhead::Slash, - Arrowhead::DoubleSlash, - Arrowhead::Bracket, - Arrowhead::Fork, - ]; - - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { - match self { - Arrowhead::Triangle => "Triangle", - Arrowhead::TriangleOpen => "Triangle (open)", - Arrowhead::Circle => "Circle", - Arrowhead::CircleOpen => "Circle (open)", - Arrowhead::Square => "Square", - Arrowhead::SquareOpen => "Square (open)", - Arrowhead::Bar => "Bar", - Arrowhead::Diamond => "Diamond", - Arrowhead::Arrow => "Arrow", - Arrowhead::ArrowOpen => "Arrow (open)", - Arrowhead::Barbed => "Barbed", - Arrowhead::HalfArrowLeft => "Half Arrow (left)", - Arrowhead::HalfArrowRight => "Half Arrow (right)", - Arrowhead::Concave => "Concave", - Arrowhead::DoubleBar => "Double Bar", - Arrowhead::Feather => "Feather", - Arrowhead::DotOnBar => "Dot on Bar", - Arrowhead::Chevron => "Chevron", - Arrowhead::DoubleArrow => "Double Arrow", - Arrowhead::Target => "Target", - Arrowhead::Star => "Star", - Arrowhead::Cross => "Cross", - Arrowhead::Plus => "Plus", - Arrowhead::Hexagon => "Hexagon", - Arrowhead::HexagonOpen => "Hexagon (open)", - Arrowhead::Tag => "Tag", - Arrowhead::TagOpen => "Tag (open)", - Arrowhead::HalfCircle => "Half Circle", - Arrowhead::Drop => "Drop", - Arrowhead::Slash => "Slash", - Arrowhead::DoubleSlash => "Double Slash", - Arrowhead::DiamondOpen => "Diamond (open)", - Arrowhead::TriangleReverse => "Reverse Triangle", - Arrowhead::Swallowtail => "Swallowtail", - Arrowhead::Bracket => "Bracket", - Arrowhead::Fork => "Fork", - Arrowhead::Leaf => "Leaf", - Arrowhead::Kite => "Kite", - Arrowhead::TriangleBar => "Triangle to Bar", - Arrowhead::Oval => "Oval", - } - } -} - -/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under -/// the head, so the line never shows through a hollow head or past its tip. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ArrowAlign { - /// The tip extends past the end point (the path keeps its length). - #[default] - Extend, - /// The tip sits on the end point (the stroke is shortened by the head). - Tip, -} - -/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), -/// in order along the path. Two points at the same position make a discontinuous point: the -/// first is the width just before it, the second the width just after it (a step). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct WidthProfile { - pub points: Vec<(f64, f64, f64)>, -} - -/// A built-in width profile (the Stroke panel's Profile list). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ProfilePreset { - /// Stable id used by `stroke.set {profile}`. - pub id: &'static str, - /// Menu label. - pub label: &'static str, - /// (t, left, right) width points. - pub points: &'static [(f64, f64, f64)], -} - -impl WidthProfile { - /// (left, right) width factors at `t`, linear between points. - pub fn at(&self, t: f64) -> (f64, f64) { - Self::at_points(&self.points, t) - } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). - pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { - if p.is_empty() { - return (1.0, 1.0); - } - if t <= p[0].0 { - return (p[0].1, p[0].2); - } - for w in p.windows(2) { - if t <= w[1].0 { - let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); - return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); - } - } - p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) - } - /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). - pub const PRESETS: [ProfilePreset; 7] = [ - ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, - ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, - ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, - ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, - // Full width at both ends, pinched to a quarter in the middle. - ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] }, - // A round head a fifth of the way along, then a long taper to the end. - ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] }, - // 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 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() }) - } - /// The id of the built-in profile these points match, if any. - pub fn preset_id(&self) -> Option<&'static str> { - Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id) - } - /// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset. - pub fn id_of(p: Option<&Self>) -> &'static str { - p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom") - } - /// The lens profile (thin ends, full width in the middle). - pub fn lens() -> Self { - Self::preset("lens").unwrap_or_default() - } - pub fn taper_end() -> Self { - Self::preset("taperEnd").unwrap_or_default() - } - pub fn taper_start() -> Self { - Self::preset("taperStart").unwrap_or_default() - } - /// The (left, right) factors just before and just after `t`: they differ only at a - /// discontinuous point (two points at `t`, within `1e-7`). - pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { - let p = &self.points; - if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { - let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; - if j > i { - return ((p[i].1, p[i].2), (p[j].1, p[j].2)); - } - } - let v = self.at(t); - (v, v) - } -} - -/// 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. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct PressureProfile { - pub points: Vec<(f64, f64)>, -} - -impl PressureProfile { - /// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to): - /// half way, so a Pressure brush draws its own value there. - pub const MID: f64 = 0.5; - /// The most samples a stroke keeps (after simplifying them). - pub const MAX_POINTS: usize = 512; - /// How far (in pressure) simplifying may move the recorded curve. - const TOLERANCE: f64 = 0.01; - - /// 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, - } - }; - if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID } - } - - /// A profile from (position, pressure) samples in order along the stroke: values are clamped - /// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to - /// [`Self::MAX_POINTS`]. `None` without a sample. - 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() }) - } - - /// 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; - } - 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() } - } - - /// A light–heavy–light stroke, for previews of pressure-sensitive brushes. - pub fn sample() -> Self { - Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] } - } -} - -/// 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(); - if n <= max || max < 2 { - return pts; - } - (0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect() -} - -/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through -/// them stays within `tol` (vertically) of every sample. The ends are always kept. -fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec { - let mut keep = vec![false; pts.len()]; - let Some(last) = pts.len().checked_sub(1) else { return keep }; - for i in [0, last] { - if let Some(k) = keep.get_mut(i) { - *k = true; - } - } - let mut stack = vec![(0, last)]; - while let Some((i, j)) = stack.pop() { - let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue }; - let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 }; - let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1)); - if let Some((k, d)) = far - && d > tol - { - if let Some(f) = keep.get_mut(k) { - *f = true; - } - stack.push((i, k)); - stack.push((k, j)); - } - } - keep -} - -/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the -/// preferences. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SavedProfile { - pub name: String, - pub profile: WidthProfile, -} - -/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Effect { - /// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`. - pub id: String, - #[serde(default)] - pub params: serde_json::Value, - #[serde(default = "yes")] - pub visible: bool, -} - -fn yes() -> bool { - true -} -fn one() -> f32 { - 1.0 -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct FillLayer { - pub paint: Paint, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] - pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub blend: BlendMode, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] - pub visible: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub effects: Vec, - /// Overprint Fill: on press the fill's inks print over the inks below instead of knocking - /// them out (Overprint Preview and Separations Preview show it). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint: bool, -} - -impl FillLayer { - pub fn new(paint: Paint) -> Self { - Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct StrokeLayer { - pub paint: Paint, - /// Weight in points. - pub width: f64, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub cap: LineCap, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub join: LineJoin, - #[serde(default = "ten", skip_serializing_if = "is_ten")] - pub miter_limit: f64, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub align: StrokeAlign, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub start_arrow: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub end_arrow: Option, - /// Arrowhead scale in percent (start, end). - #[serde(default = "hundreds", skip_serializing_if = "is_hundreds")] - pub arrow_scale: (f64, f64), - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - /// Brush applied to the stroke (by brush name). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub brush: Option, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] - pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub blend: BlendMode, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] - pub visible: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub effects: Vec, - /// Arrowhead placement at both ends. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub arrow_align: ArrowAlign, - /// Overprint Stroke (see [`FillLayer::overprint`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint: bool, - /// How a gradient paint maps onto the stroke (within, along or across it). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub gradient_mode: StrokeGradientMode, - /// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive - /// brushes vary with it. It belongs to this path's shape, so appearances copied to other - /// art leave it behind ([`Appearance::without_pressure`]). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pressure: Option, -} - -/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant -/// name: new modes are appended, never renamed. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum StrokeGradientMode { - /// Placed on the page like a fill's gradient and seen through the stroke. - #[default] - Within, - /// From the start of each subpath to its end, following the path. - Along, - /// From the stroke's left edge to its right edge (left of the path's direction), all along it. - Across, -} - -impl StrokeGradientMode { - pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across]; - /// The name commands use (`within`, `along`, `across`). - pub fn name(self) -> &'static str { - match self { - StrokeGradientMode::Within => "within", - StrokeGradientMode::Along => "along", - StrokeGradientMode::Across => "across", - } - } - /// Parse a mode name (any case). - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s)) - } -} - -/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are -/// cut square a little behind its 4 × 4 box). -pub(crate) const HEAD_REACH: f64 = 4.6; - -fn ten() -> f64 { - 10.0 -} -fn hundreds() -> (f64, f64) { - (100.0, 100.0) -} -fn is_ten(v: &f64) -> bool { - *v == 10.0 -} -fn is_hundreds(v: &(f64, f64)) -> bool { - *v == (100.0, 100.0) -} - -impl StrokeLayer { - pub fn new(paint: Paint, width: f64) -> Self { - Self { - paint, - width, - cap: LineCap::Butt, - join: LineJoin::Miter, - miter_limit: 10.0, - align: StrokeAlign::Center, - dash: None, - start_arrow: None, - end_arrow: None, - arrow_scale: (100.0, 100.0), - profile: None, - brush: None, - opacity: 1.0, - blend: BlendMode::Normal, - visible: true, - effects: vec![], - arrow_align: ArrowAlign::Extend, - overprint: false, - gradient_mode: StrokeGradientMode::Within, - pressure: None, - } - } - /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least - /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. - pub fn arrow_weight(&self, end: bool) -> f64 { - let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; - (self.width * pct / 100.0).max(0.25) - } - /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 - /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. - pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0) - } - /// How far the arrowheads can reach from the path's end points (0 without heads). - pub fn arrow_reach(&self) -> f64 { - self.head_reach(false).max(self.head_reach(true)) - } - /// The widest the width profile makes the stroke, as a factor of its weight (1 without one). - pub fn profile_max(&self) -> f64 { - self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0) - } - /// The stroke's body across a path that is `closed` or open: how far it reaches from the path - /// at full width (half the weight centred; the whole weight on one side when aligned inside or - /// outside a closed path, which open paths ignore), and for aligned strokes which side it - /// paints (`Some(true)`: inside). - pub fn body(&self, closed: bool) -> (f64, Option) { - match self.align { - StrokeAlign::Outside if closed => (self.width, Some(false)), - StrokeAlign::Inside if closed => (self.width, Some(true)), - _ => (self.width / 2.0, None), - } - } - /// How far the body paints outside a path that is `closed` or open, at the profile's widest. - pub fn side_reach(&self, closed: bool) -> f64 { - match self.body(closed) { - (_, Some(true)) => 0.0, - (half, _) => half * self.profile_max(), - } - } - /// The farthest this stroke can paint from any path: its body, miter spikes up to the miter - /// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]). - pub fn reach(&self) -> f64 { - let side = self.side_reach(true).max(self.side_reach(false)); - let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 }; - let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; - (side * miter.max(cap)).max(self.arrow_reach()) - } - /// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]). - pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { - stroke_paint_bounds(geometric, self.width) - } - /// The gradient this stroke lays along or across its path: its linear or radial gradient - /// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within). - pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> { - match &self.paint { - Paint::Gradient(g) - if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform => - { - Some(g) - } - _ => None, - } - } -} - -/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half -/// the weight (object strokes and type strokes alike, on screen and in exports). -pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect { - geometric.inflate(width / 2.0, width / 2.0) -} +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 stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -1150,319 +506,105 @@ impl Appearance { } } -#[cfg(test)] -mod tests { - use super::*; +/// The most fills, strokes or effects a stack may have to be matched against another's +/// ([`align`]); a larger stack shares nothing with the others. +const MAX_ALIGNED: usize = 1024; - #[test] - fn hidden_attributes_show_again() { - let mut a = Appearance::default_art(); - assert!(!a.has_hidden() && !a.show_all()); - a.stroke_mut().unwrap().visible = false; - a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false }); - assert!(a.has_hidden()); - assert!(a.show_all()); - assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible); +/// Where each entry of `first` is in `other`: the entries equal in both (`same`), matched in their +/// stacking order (a longest common subsequence, so the matches never cross), by index into +/// `other`; `None` where `other` has no equal entry left for it. +pub fn align(first: &[T], other: &[T], same: impl Fn(&T, &T) -> bool) -> Vec> { + let (n, m) = (first.len(), other.len()); + let mut out = vec![None; n]; + if n == 0 || m == 0 || n > MAX_ALIGNED || m > MAX_ALIGNED { + return out; } - - #[test] - fn basic_appearance() { - let a = Appearance::default_art(); - assert!(a.is_basic()); - assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE)); - assert_eq!(a.stroke_width(), 1.0); - } - - #[test] - fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() { - use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; - use vectorcraft_geom::{Point, Rect}; - let placed = |x: f64| { - let mut g = GradientPaint::new(Gradient::default()); - g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None }); - Paint::Gradient(Box::new(g)) - }; - let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); - a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0)); - let start = |p: Paint| match p { - Paint::Gradient(g) => g.geom.unwrap().start, - _ => panic!("a gradient"), - }; - // The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95). - assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0))); - } - - #[test] - fn items_by_index() { - let mut a = Appearance::default_art(); - a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); - // [Fill white, Stroke black, Fill none] - assert_eq!(a.fill_at(None).unwrap().paint, Paint::None); - assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE)); - assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none()); - assert_eq!(a.item_of_kind(Some(1), false), Some(1)); - assert_eq!(a.item_of_kind(Some(1), true), None); - assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None); - assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK))); - assert!(!a.set_stroke_at(Some(0), Paint::None)); - assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK)); - a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); - assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1); - assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none()); - assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); - } - - #[test] - fn basic_means_one_plain_fill_and_stroke() { - let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); - let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() }; - assert!(!a.is_basic(), "two strokes"); - a.items.pop(); - assert!(a.is_basic() && Appearance::default().is_basic()); - let mut b = Appearance::default_art(); - b.stroke_mut().unwrap().visible = false; - assert!(!b.is_basic(), "a hidden stroke"); - let mut c = Appearance::default_art(); - c.fill_mut().unwrap().opacity = 0.5; - assert!(!c.is_basic(), "fill opacity"); - let mut d = Appearance::default_art(); - d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); - assert!(!d.is_basic(), "two fills"); - let mut e = Appearance::default_art(); - e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); - assert!(!e.is_basic(), "an effect"); - } - - #[test] - fn set_creates_missing() { - let mut a = Appearance::default(); - a.set_fill(Paint::solid(Color::BLACK)); - a.set_stroke(Paint::solid(Color::WHITE)); - assert_eq!(a.items.len(), 2); - assert!(matches!(a.items[0], AppearanceItem::Fill(_))); - } - - /// #852: pen pressure is kept simplified along the stroke and read back by position. - #[test] - fn pressure_profile_samples_simplify_and_interpolate() { - // A steady ramp keeps only its ends; a bump keeps its peak. - let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap(); - assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]); - assert!((ramp.at(0.25) - 0.25).abs() < 1e-9); - let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap(); - assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}"); - // Junk is clamped or dropped; positions never go back; nothing at all is no profile. - let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap(); - assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]); - assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none()); - assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID); - // A long noisy stroke stays bounded. - let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap(); - assert!(noisy.points.len() <= PressureProfile::MAX_POINTS); - // Out-of-order points (a hand-edited file) still read within 0..1. - let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] }; - assert!((0.0..=1.0).contains(&odd.at(0.5))); - } - - #[test] - fn pressure_profile_extends_and_reverses() { - let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }; - let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }; - // A 30 pt path continued by 10 pt: its ramp fills the first three quarters. - let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap(); - assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}"); - // A side without pressure is half way. - let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap(); - assert_eq!(half.at(0.25), PressureProfile::MID); - assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none()); - assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone())); - assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]); - } - - /// 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()); - let st: StrokeLayer = serde_json::from_value(json).unwrap(); - assert_eq!(st.pressure, None); - let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st }; - let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap(); - assert_eq!(back, pressed); - 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); - } - - #[test] - fn profile_interp() { - let p = WidthProfile::lens(); - assert_eq!(p.at(0.5), (1.0, 1.0)); - assert_eq!(p.at(0.25), (0.5, 0.5)); - assert_eq!(p.at(2.0), (0.0, 0.0)); - } - - #[test] - fn profile_presets_round_trip_their_ids() { - for p in WidthProfile::PRESETS { - let prof = WidthProfile::preset(p.id).unwrap(); - assert_eq!(prof.preset_id(), Some(p.id)); - assert_eq!(WidthProfile::id_of(Some(&prof)), p.id); + // `len[i][j]`: the longest match of `first[i..]` and `other[j..]`. + let w = m + 1; + let mut len = vec![0u16; (n + 1) * w]; + for i in (0..n).rev() { + for j in (0..m).rev() { + let here = match (first.get(i), other.get(j)) { + (Some(a), Some(b)) if same(a, b) => len.get((i + 1) * w + j + 1).copied().unwrap_or(0).saturating_add(1), + _ => len.get((i + 1) * w + j).copied().unwrap_or(0).max(len.get(i * w + j + 1).copied().unwrap_or(0)), + }; + if let Some(c) = len.get_mut(i * w + j) { + *c = here; + } } - assert_eq!(WidthProfile::id_of(None), "uniform"); - assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); - assert!(WidthProfile::preset("nope").is_none()); - assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]); } + let (mut i, mut j) = (0, 0); + while i < n && j < m { + let at = |a: usize, b: usize| len.get(a * w + b).copied().unwrap_or(0); + match (first.get(i), other.get(j)) { + (Some(a), Some(b)) if same(a, b) && at(i, j) == at(i + 1, j + 1).saturating_add(1) => { + if let Some(o) = out.get_mut(i) { + *o = Some(j); + } + i += 1; + j += 1; + } + _ if at(i + 1, j) >= at(i, j + 1) => i += 1, + _ => j += 1, + } + } + out +} - #[test] - fn arrowhead_names_are_stable_and_every_one_is_listed_once() { - // Saved files store these names: they may only ever be appended to. - const SAVED: [&str; 40] = [ - "Triangle", - "TriangleOpen", - "Circle", - "CircleOpen", - "Square", - "SquareOpen", - "Bar", - "Diamond", - "Arrow", - "ArrowOpen", - "Barbed", - "HalfArrowLeft", - "HalfArrowRight", - "Concave", - "DoubleBar", - "Feather", - "DotOnBar", - "Chevron", - "DoubleArrow", - "Target", - "Star", - "Cross", - "Plus", - "Hexagon", - "HexagonOpen", - "Tag", - "TagOpen", - "HalfCircle", - "Drop", - "Slash", - "DoubleSlash", - "DiamondOpen", - "TriangleReverse", - "Swallowtail", - "Bracket", - "Fork", - "Leaf", - "Kite", - "TriangleBar", - "Oval", - ]; - for name in SAVED { - let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); - assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); +/// What several objects' appearances share, as the Appearance panel lists a mixed selection: the +/// fills and strokes, and the object-level effects, that every one of them has an equal copy of, in +/// stacking order ([`align`]), each with where it is in every object's own stack. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SharedStack { + /// Each shared fill or stroke, in the first object's paint order: its index in each object's + /// `items` (the first object's first). + pub items: Vec>, + /// Each shared object-level effect: its index in each object's `effects`. + pub effects: Vec>, +} + +impl SharedStack { + /// The rows `appearances` share (see [`SharedStack`]). One appearance shares all of itself. + pub fn of(appearances: &[&Appearance]) -> Self { + let Some((first, rest)) = appearances.split_first() else { return Self::default() }; + fn shared(first: &[T], rest: Vec<&[T]>, same: impl Fn(&T, &T) -> bool + Copy) -> Vec> { + let maps: Vec>> = rest.iter().map(|o| align(first, o, same)).collect(); + (0..first.len()) + .filter_map(|i| { + let mut at = vec![i]; + for m in &maps { + at.push((*m.get(i)?)?); + } + Some(at) + }) + .collect() + } + Self { + items: shared(&first.items, rest.iter().map(|a| a.items.as_slice()).collect(), |a, b| a == b), + effects: shared(&first.effects, rest.iter().map(|a| a.effects.as_slice()).collect(), |a, b| a == b), } - let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); - assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); } - #[test] - fn arrow_align_defaults_to_extend_and_round_trips() { - let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); - assert_eq!(st.arrow_align, ArrowAlign::Extend); - // The default is not written, so older readers see the same JSON as before. - assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align")); - let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); - assert_eq!(old.arrow_align, ArrowAlign::Extend); - st.arrow_align = ArrowAlign::Tip; - let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); - assert_eq!(back, st); + /// Where the first object's fill or stroke `first` is in object `k`'s stack, when it is shared. + pub fn item_in(&self, first: usize, k: usize) -> Option { + self.items.iter().find(|at| at.first() == Some(&first))?.get(k).copied() } - #[test] - fn outset_covers_arrowheads() { - let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); - a.stroke_mut().unwrap().join = LineJoin::Round; - assert_eq!(a.outset(), 2.0); - let st = a.stroke_mut().unwrap(); - st.end_arrow = Some(Arrowhead::Triangle); - st.arrow_scale = (100.0, 200.0); - assert_eq!(st.arrow_weight(false), 4.0); - assert_eq!(st.arrow_weight(true), 8.0); - assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); - // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. - assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); - a.stroke_mut().unwrap().width = 0.01; - assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); + /// Where the first object's object-level effect `first` is in object `k`'s effects, when shared. + pub fn effect_in(&self, first: usize, k: usize) -> Option { + self.effects.iter().find(|at| at.first() == Some(&first))?.get(k).copied() } - #[test] - fn outset_depends_on_align() { - let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); - a.stroke_mut().unwrap().join = LineJoin::Round; - assert_eq!(a.outset(), 5.0); - a.stroke_mut().unwrap().align = StrokeAlign::Outside; - assert_eq!(a.outset(), 10.0); - // Open paths stroke inside-aligned strokes centred. - a.stroke_mut().unwrap().align = StrokeAlign::Inside; - assert_eq!(a.outset(), 5.0); - assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true)))); + /// Is the first object's fill or stroke `i` shared? + pub fn has_item(&self, i: usize) -> bool { + self.items.iter().any(|at| at.first() == Some(&i)) } - #[test] - fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() { - let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); - // Miter joins can spike out to the limit × half the weight. - assert_eq!(a.outset(), 10.0); - let st = a.stroke_mut().unwrap(); - st.join = LineJoin::Bevel; - st.cap = LineCap::Square; - assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner"); - let st = a.stroke_mut().unwrap(); - st.cap = LineCap::Round; - st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] }); - assert_eq!(st.profile_max(), 2.5); - assert_eq!(a.outset(), 2.5); - a.stroke_mut().unwrap().paint = Paint::None; - assert_eq!(a.outset(), 0.0, "an unpainted stroke"); - } - - #[test] - fn contents_slot_follows_item_edits_and_round_trips() { - let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None)); - let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() }; - assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default"); - a.set_contents_at(1); - assert!(!a.is_basic()); - // [0 | 1 2]: removing below shifts the slot, removing above doesn't. - a.remove_item(2); - assert_eq!(a.contents_at(), 1); - a.remove_item(0); - assert_eq!((a.contents_at(), a.contents_index), (0, None)); - // Inserting next to the slot joins the item below it. - a.items.push(fill()); - a.set_contents_at(1); // [0 | 1] - a.insert_item(1, fill()); - assert_eq!(a.contents_at(), 2); - a.insert_item(3, fill()); // [0 1 | 2 3] - assert_eq!(a.contents_at(), 2); - // Moves keep their side when landing next to the slot, cross it otherwise. - assert_eq!(a.move_item(3, 2), Some(2)); - assert_eq!(a.contents_at(), 2, "stays above"); - a.move_item(3, 0); - assert_eq!(a.contents_at(), 3, "crossed below"); - a.move_item(0, 3); - assert_eq!(a.contents_at(), 2, "crossed above"); - assert_eq!(a.move_item(9, 0), None); - // Saved only when set; older files load with every item above. - let json = serde_json::to_string(&a).unwrap(); - assert!(json.contains("\"contents_index\":2")); - assert_eq!(serde_json::from_str::(&json).unwrap(), a); - let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap(); - assert_eq!(old.contents_index, None); - assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index")); + /// Is the first object's object-level effect `k` shared? + pub fn has_effect(&self, k: usize) -> bool { + self.effects.iter().any(|at| at.first() == Some(&k)) } } + +#[cfg(test)] +mod tests; diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs new file mode 100644 index 000000000..6103e7dfd --- /dev/null +++ b/crates/doc/src/appearance/layers.rs @@ -0,0 +1,240 @@ +use super::profile::{PressureProfile, WidthProfile}; +use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +use super::*; + +/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Effect { + /// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`. + pub id: String, + #[serde(default)] + pub params: serde_json::Value, + #[serde(default = "yes")] + pub visible: bool, +} + +fn yes() -> bool { + true +} + +fn one() -> f32 { + 1.0 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FillLayer { + pub paint: Paint, + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub blend: BlendMode, + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + pub visible: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, + /// Overprint Fill: on press the fill's inks print over the inks below instead of knocking + /// them out (Overprint Preview and Separations Preview show it). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint: bool, +} + +impl FillLayer { + pub fn new(paint: Paint) -> Self { + Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeLayer { + pub paint: Paint, + /// Weight in points. + pub width: f64, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub cap: LineCap, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub join: LineJoin, + #[serde(default = "ten", skip_serializing_if = "is_ten")] + pub miter_limit: f64, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub align: StrokeAlign, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_arrow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_arrow: Option, + /// Arrowhead scale in percent (start, end). + #[serde(default = "hundreds", skip_serializing_if = "is_hundreds")] + pub arrow_scale: (f64, f64), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + /// Brush applied to the stroke (by brush name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brush: Option, + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub blend: BlendMode, + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + pub visible: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, + /// Arrowhead placement at both ends. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub arrow_align: ArrowAlign, + /// Overprint Stroke (see [`FillLayer::overprint`]). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint: bool, + /// How a gradient paint maps onto the stroke (within, along or across it). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub gradient_mode: StrokeGradientMode, + /// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive + /// brushes vary with it. It belongs to this path's shape, so appearances copied to other + /// art leave it behind ([`Appearance::without_pressure`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pressure: Option, +} + +/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant +/// name: new modes are appended, never renamed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StrokeGradientMode { + /// Placed on the page like a fill's gradient and seen through the stroke. + #[default] + Within, + /// From the start of each subpath to its end, following the path. + Along, + /// From the stroke's left edge to its right edge (left of the path's direction), all along it. + Across, +} + +impl StrokeGradientMode { + pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across]; + /// The name commands use (`within`, `along`, `across`). + pub fn name(self) -> &'static str { + match self { + StrokeGradientMode::Within => "within", + StrokeGradientMode::Along => "along", + StrokeGradientMode::Across => "across", + } + } + /// Parse a mode name (any case). + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s)) + } +} + +/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are +/// cut square a little behind its 4 × 4 box). +pub(crate) const HEAD_REACH: f64 = 4.6; + +fn ten() -> f64 { + 10.0 +} + +fn hundreds() -> (f64, f64) { + (100.0, 100.0) +} + +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} + +fn is_hundreds(v: &(f64, f64)) -> bool { + *v == (100.0, 100.0) +} + +impl StrokeLayer { + pub fn new(paint: Paint, width: f64) -> Self { + Self { + paint, + width, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter_limit: 10.0, + align: StrokeAlign::Center, + dash: None, + start_arrow: None, + end_arrow: None, + arrow_scale: (100.0, 100.0), + profile: None, + brush: None, + opacity: 1.0, + blend: BlendMode::Normal, + visible: true, + effects: vec![], + arrow_align: ArrowAlign::Extend, + overprint: false, + gradient_mode: StrokeGradientMode::Within, + pressure: None, + } + } + /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least + /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. + pub fn arrow_weight(&self, end: bool) -> f64 { + let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; + (self.width * pct / 100.0).max(0.25) + } + /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 + /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. + pub fn head_reach(&self, end: bool) -> f64 { + let head = if end { self.end_arrow } else { self.start_arrow }; + head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0) + } + /// How far the arrowheads can reach from the path's end points (0 without heads). + pub fn arrow_reach(&self) -> f64 { + self.head_reach(false).max(self.head_reach(true)) + } + /// The widest the width profile makes the stroke, as a factor of its weight (1 without one). + pub fn profile_max(&self) -> f64 { + self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0) + } + /// The stroke's body across a path that is `closed` or open: how far it reaches from the path + /// at full width (half the weight centred; the whole weight on one side when aligned inside or + /// outside a closed path, which open paths ignore), and for aligned strokes which side it + /// paints (`Some(true)`: inside). + pub fn body(&self, closed: bool) -> (f64, Option) { + match self.align { + StrokeAlign::Outside if closed => (self.width, Some(false)), + StrokeAlign::Inside if closed => (self.width, Some(true)), + _ => (self.width / 2.0, None), + } + } + /// How far the body paints outside a path that is `closed` or open, at the profile's widest. + pub fn side_reach(&self, closed: bool) -> f64 { + match self.body(closed) { + (_, Some(true)) => 0.0, + (half, _) => half * self.profile_max(), + } + } + /// The farthest this stroke can paint from any path: its body, miter spikes up to the miter + /// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]). + pub fn reach(&self) -> f64 { + let side = self.side_reach(true).max(self.side_reach(false)); + let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 }; + let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + (side * miter.max(cap)).max(self.arrow_reach()) + } + /// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]). + pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { + stroke_paint_bounds(geometric, self.width) + } + /// The gradient this stroke lays along or across its path: its linear or radial gradient + /// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within). + pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> { + match &self.paint { + Paint::Gradient(g) + if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform => + { + Some(g) + } + _ => None, + } + } +} + +/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half +/// the weight (object strokes and type strokes alike, on screen and in exports). +pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect { + geometric.inflate(width / 2.0, width / 2.0) +} diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs new file mode 100644 index 000000000..56df51ef6 --- /dev/null +++ b/crates/doc/src/appearance/profile.rs @@ -0,0 +1,222 @@ +use super::*; + +/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), +/// in order along the path. Two points at the same position make a discontinuous point: the +/// first is the width just before it, the second the width just after it (a step). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WidthProfile { + pub points: Vec<(f64, f64, f64)>, +} + +/// A built-in width profile (the Stroke panel's Profile list). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProfilePreset { + /// Stable id used by `stroke.set {profile}`. + pub id: &'static str, + /// Menu label. + pub label: &'static str, + /// (t, left, right) width points. + pub points: &'static [(f64, f64, f64)], +} + +impl WidthProfile { + /// (left, right) width factors at `t`, linear between points. + pub fn at(&self, t: f64) -> (f64, f64) { + Self::at_points(&self.points, t) + } + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { + if p.is_empty() { + return (1.0, 1.0); + } + if t <= p[0].0 { + return (p[0].1, p[0].2); + } + for w in p.windows(2) { + if t <= w[1].0 { + let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); + return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); + } + } + p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) + } + /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). + pub const PRESETS: [ProfilePreset; 7] = [ + ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, + ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, + ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Full width at both ends, pinched to a quarter in the middle. + ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] }, + // A round head a fifth of the way along, then a long taper to the end. + ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // 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 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() }) + } + /// The id of the built-in profile these points match, if any. + pub fn preset_id(&self) -> Option<&'static str> { + Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id) + } + /// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset. + pub fn id_of(p: Option<&Self>) -> &'static str { + p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom") + } + /// The lens profile (thin ends, full width in the middle). + pub fn lens() -> Self { + Self::preset("lens").unwrap_or_default() + } + pub fn taper_end() -> Self { + Self::preset("taperEnd").unwrap_or_default() + } + pub fn taper_start() -> Self { + Self::preset("taperStart").unwrap_or_default() + } + /// The (left, right) factors just before and just after `t`: they differ only at a + /// discontinuous point (two points at `t`, within `1e-7`). + pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { + let p = &self.points; + if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { + let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; + if j > i { + return ((p[i].1, p[i].2), (p[j].1, p[j].2)); + } + } + let v = self.at(t); + (v, v) + } +} + +/// 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. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PressureProfile { + pub points: Vec<(f64, f64)>, +} + +impl PressureProfile { + /// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to): + /// half way, so a Pressure brush draws its own value there. + pub const MID: f64 = 0.5; + /// The most samples a stroke keeps (after simplifying them). + pub const MAX_POINTS: usize = 512; + /// How far (in pressure) simplifying may move the recorded curve. + const TOLERANCE: f64 = 0.01; + + /// 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, + } + }; + if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID } + } + + /// A profile from (position, pressure) samples in order along the stroke: values are clamped + /// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to + /// [`Self::MAX_POINTS`]. `None` without a sample. + 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() }) + } + + /// 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; + } + 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() } + } + + /// A light–heavy–light stroke, for previews of pressure-sensitive brushes. + pub fn sample() -> Self { + Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] } + } +} + +/// 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(); + if n <= max || max < 2 { + return pts; + } + (0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect() +} + +/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through +/// them stays within `tol` (vertically) of every sample. The ends are always kept. +fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec { + let mut keep = vec![false; pts.len()]; + let Some(last) = pts.len().checked_sub(1) else { return keep }; + for i in [0, last] { + if let Some(k) = keep.get_mut(i) { + *k = true; + } + } + let mut stack = vec![(0, last)]; + while let Some((i, j)) = stack.pop() { + let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue }; + let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 }; + let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1)); + if let Some((k, d)) = far + && d > tol + { + if let Some(f) = keep.get_mut(k) { + *f = true; + } + stack.push((i, k)); + stack.push((k, j)); + } + } + keep +} + +/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the +/// preferences. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedProfile { + pub name: String, + pub profile: WidthProfile, +} diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs new file mode 100644 index 000000000..3e6a29a32 --- /dev/null +++ b/crates/doc/src/appearance/stroke_style.rs @@ -0,0 +1,200 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineCap { + #[default] + Butt, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineJoin { + #[default] + Miter, + Round, + Bevel, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StrokeAlign { + #[default] + Center, + Inside, + Outside, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Dash { + /// Dash, gap, dash, gap… (up to 6 values in the Stroke panel). + pub pattern: Vec, + #[serde(default)] + pub offset: f64, + /// "Aligns dashes to corners and path ends, adjusting lengths to fit". + #[serde(default)] + pub align_corners: bool, +} + +impl Dash { + /// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a + /// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the + /// stroke is drawn solid. + pub fn is_dashed(&self) -> bool { + self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) + } + /// Scale the dash and gap lengths and the offset by `s`. + pub fn scale(&mut self, s: f64) { + self.pattern.iter_mut().for_each(|v| *v *= s); + self.offset *= s; + } +} + +/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Arrowhead { + Triangle, + TriangleOpen, + Circle, + CircleOpen, + Square, + SquareOpen, + Bar, + Diamond, + Arrow, + ArrowOpen, + Barbed, + /// One barb, on the left of the direction the head points. + HalfArrowLeft, + /// One barb, on the right of the direction the head points. + HalfArrowRight, + Concave, + DoubleBar, + Feather, + DotOnBar, + Chevron, + DoubleArrow, + Target, + Star, + Cross, + Plus, + Hexagon, + HexagonOpen, + Tag, + TagOpen, + HalfCircle, + Drop, + Slash, + DoubleSlash, + DiamondOpen, + TriangleReverse, + Swallowtail, + Bracket, + Fork, + Leaf, + Kite, + TriangleBar, + Oval, +} + +impl Arrowhead { + /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). + pub const ALL: [Arrowhead; 40] = [ + Arrowhead::Arrow, + Arrowhead::ArrowOpen, + Arrowhead::Barbed, + Arrowhead::Concave, + Arrowhead::DoubleArrow, + Arrowhead::HalfArrowLeft, + Arrowhead::HalfArrowRight, + Arrowhead::Chevron, + Arrowhead::Feather, + Arrowhead::Swallowtail, + Arrowhead::Triangle, + Arrowhead::TriangleOpen, + Arrowhead::TriangleReverse, + Arrowhead::TriangleBar, + Arrowhead::Kite, + Arrowhead::Leaf, + Arrowhead::Drop, + Arrowhead::Circle, + Arrowhead::CircleOpen, + Arrowhead::HalfCircle, + Arrowhead::Oval, + Arrowhead::Target, + Arrowhead::Square, + Arrowhead::SquareOpen, + Arrowhead::Tag, + Arrowhead::TagOpen, + Arrowhead::Diamond, + Arrowhead::DiamondOpen, + Arrowhead::Hexagon, + Arrowhead::HexagonOpen, + Arrowhead::Star, + Arrowhead::Cross, + Arrowhead::Plus, + Arrowhead::Bar, + Arrowhead::DoubleBar, + Arrowhead::DotOnBar, + Arrowhead::Slash, + Arrowhead::DoubleSlash, + Arrowhead::Bracket, + Arrowhead::Fork, + ]; + + /// The name the arrowhead menus show. + pub fn label(self) -> &'static str { + match self { + Arrowhead::Triangle => "Triangle", + Arrowhead::TriangleOpen => "Triangle (open)", + Arrowhead::Circle => "Circle", + Arrowhead::CircleOpen => "Circle (open)", + Arrowhead::Square => "Square", + Arrowhead::SquareOpen => "Square (open)", + Arrowhead::Bar => "Bar", + Arrowhead::Diamond => "Diamond", + Arrowhead::Arrow => "Arrow", + Arrowhead::ArrowOpen => "Arrow (open)", + Arrowhead::Barbed => "Barbed", + Arrowhead::HalfArrowLeft => "Half Arrow (left)", + Arrowhead::HalfArrowRight => "Half Arrow (right)", + Arrowhead::Concave => "Concave", + Arrowhead::DoubleBar => "Double Bar", + Arrowhead::Feather => "Feather", + Arrowhead::DotOnBar => "Dot on Bar", + Arrowhead::Chevron => "Chevron", + Arrowhead::DoubleArrow => "Double Arrow", + Arrowhead::Target => "Target", + Arrowhead::Star => "Star", + Arrowhead::Cross => "Cross", + Arrowhead::Plus => "Plus", + Arrowhead::Hexagon => "Hexagon", + Arrowhead::HexagonOpen => "Hexagon (open)", + Arrowhead::Tag => "Tag", + Arrowhead::TagOpen => "Tag (open)", + Arrowhead::HalfCircle => "Half Circle", + Arrowhead::Drop => "Drop", + Arrowhead::Slash => "Slash", + Arrowhead::DoubleSlash => "Double Slash", + Arrowhead::DiamondOpen => "Diamond (open)", + Arrowhead::TriangleReverse => "Reverse Triangle", + Arrowhead::Swallowtail => "Swallowtail", + Arrowhead::Bracket => "Bracket", + Arrowhead::Fork => "Fork", + Arrowhead::Leaf => "Leaf", + Arrowhead::Kite => "Kite", + Arrowhead::TriangleBar => "Triangle to Bar", + Arrowhead::Oval => "Oval", + } + } +} + +/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under +/// the head, so the line never shows through a hollow head or past its tip. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ArrowAlign { + /// The tip extends past the end point (the path keeps its length). + #[default] + Extend, + /// The tip sits on the end point (the stroke is shortened by the head). + Tip, +} diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs new file mode 100644 index 000000000..3e3d610f4 --- /dev/null +++ b/crates/doc/src/appearance/tests.rs @@ -0,0 +1,349 @@ +use super::*; + +#[test] +fn hidden_attributes_show_again() { + let mut a = Appearance::default_art(); + assert!(!a.has_hidden() && !a.show_all()); + a.stroke_mut().unwrap().visible = false; + a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false }); + assert!(a.has_hidden()); + assert!(a.show_all()); + assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible); +} + +#[test] +fn basic_appearance() { + let a = Appearance::default_art(); + assert!(a.is_basic()); + assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE)); + assert_eq!(a.stroke_width(), 1.0); +} + +#[test] +fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() { + use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; + use vectorcraft_geom::{Point, Rect}; + let placed = |x: f64| { + let mut g = GradientPaint::new(Gradient::default()); + g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None }); + Paint::Gradient(Box::new(g)) + }; + let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); + a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0)); + let start = |p: Paint| match p { + Paint::Gradient(g) => g.geom.unwrap().start, + _ => panic!("a gradient"), + }; + // The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95). + assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0))); +} + +#[test] +fn items_by_index() { + let mut a = Appearance::default_art(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + // [Fill white, Stroke black, Fill none] + assert_eq!(a.fill_at(None).unwrap().paint, Paint::None); + assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE)); + assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none()); + assert_eq!(a.item_of_kind(Some(1), false), Some(1)); + assert_eq!(a.item_of_kind(Some(1), true), None); + assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None); + assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK))); + assert!(!a.set_stroke_at(Some(0), Paint::None)); + assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK)); + a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1); + assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none()); + assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); +} + +#[test] +fn basic_means_one_plain_fill_and_stroke() { + let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); + let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() }; + assert!(!a.is_basic(), "two strokes"); + a.items.pop(); + assert!(a.is_basic() && Appearance::default().is_basic()); + let mut b = Appearance::default_art(); + b.stroke_mut().unwrap().visible = false; + assert!(!b.is_basic(), "a hidden stroke"); + let mut c = Appearance::default_art(); + c.fill_mut().unwrap().opacity = 0.5; + assert!(!c.is_basic(), "fill opacity"); + let mut d = Appearance::default_art(); + d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(!d.is_basic(), "two fills"); + let mut e = Appearance::default_art(); + e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert!(!e.is_basic(), "an effect"); +} + +#[test] +fn set_creates_missing() { + let mut a = Appearance::default(); + a.set_fill(Paint::solid(Color::BLACK)); + a.set_stroke(Paint::solid(Color::WHITE)); + assert_eq!(a.items.len(), 2); + assert!(matches!(a.items[0], AppearanceItem::Fill(_))); +} + +/// #852: pen pressure is kept simplified along the stroke and read back by position. +#[test] +fn pressure_profile_samples_simplify_and_interpolate() { + // A steady ramp keeps only its ends; a bump keeps its peak. + let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap(); + assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]); + assert!((ramp.at(0.25) - 0.25).abs() < 1e-9); + let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap(); + assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}"); + // Junk is clamped or dropped; positions never go back; nothing at all is no profile. + let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap(); + assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]); + assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none()); + assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID); + // A long noisy stroke stays bounded. + let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap(); + assert!(noisy.points.len() <= PressureProfile::MAX_POINTS); + // Out-of-order points (a hand-edited file) still read within 0..1. + let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] }; + assert!((0.0..=1.0).contains(&odd.at(0.5))); +} + +#[test] +fn pressure_profile_extends_and_reverses() { + let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }; + let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }; + // A 30 pt path continued by 10 pt: its ramp fills the first three quarters. + let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap(); + assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}"); + // A side without pressure is half way. + let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap(); + assert_eq!(half.at(0.25), PressureProfile::MID); + assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none()); + assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone())); + assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]); +} + +/// 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()); + let st: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(st.pressure, None); + let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st }; + let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap(); + assert_eq!(back, pressed); + 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); +} + +#[test] +fn profile_interp() { + let p = WidthProfile::lens(); + assert_eq!(p.at(0.5), (1.0, 1.0)); + assert_eq!(p.at(0.25), (0.5, 0.5)); + assert_eq!(p.at(2.0), (0.0, 0.0)); +} + +#[test] +fn profile_presets_round_trip_their_ids() { + for p in WidthProfile::PRESETS { + let prof = WidthProfile::preset(p.id).unwrap(); + assert_eq!(prof.preset_id(), Some(p.id)); + assert_eq!(WidthProfile::id_of(Some(&prof)), p.id); + } + assert_eq!(WidthProfile::id_of(None), "uniform"); + assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); + assert!(WidthProfile::preset("nope").is_none()); + assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]); +} + +#[test] +fn arrowhead_names_are_stable_and_every_one_is_listed_once() { + // Saved files store these names: they may only ever be appended to. + const SAVED: [&str; 40] = [ + "Triangle", + "TriangleOpen", + "Circle", + "CircleOpen", + "Square", + "SquareOpen", + "Bar", + "Diamond", + "Arrow", + "ArrowOpen", + "Barbed", + "HalfArrowLeft", + "HalfArrowRight", + "Concave", + "DoubleBar", + "Feather", + "DotOnBar", + "Chevron", + "DoubleArrow", + "Target", + "Star", + "Cross", + "Plus", + "Hexagon", + "HexagonOpen", + "Tag", + "TagOpen", + "HalfCircle", + "Drop", + "Slash", + "DoubleSlash", + "DiamondOpen", + "TriangleReverse", + "Swallowtail", + "Bracket", + "Fork", + "Leaf", + "Kite", + "TriangleBar", + "Oval", + ]; + for name in SAVED { + let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); + assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); + } + let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); + assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); +} + +#[test] +fn arrow_align_defaults_to_extend_and_round_trips() { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + assert_eq!(st.arrow_align, ArrowAlign::Extend); + // The default is not written, so older readers see the same JSON as before. + assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align")); + let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); + assert_eq!(old.arrow_align, ArrowAlign::Extend); + st.arrow_align = ArrowAlign::Tip; + let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(back, st); +} + +#[test] +fn outset_covers_arrowheads() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + a.stroke_mut().unwrap().join = LineJoin::Round; + assert_eq!(a.outset(), 2.0); + let st = a.stroke_mut().unwrap(); + st.end_arrow = Some(Arrowhead::Triangle); + st.arrow_scale = (100.0, 200.0); + assert_eq!(st.arrow_weight(false), 4.0); + assert_eq!(st.arrow_weight(true), 8.0); + assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); + // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. + assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); + a.stroke_mut().unwrap().width = 0.01; + assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); +} + +#[test] +fn outset_depends_on_align() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); + a.stroke_mut().unwrap().join = LineJoin::Round; + assert_eq!(a.outset(), 5.0); + a.stroke_mut().unwrap().align = StrokeAlign::Outside; + assert_eq!(a.outset(), 10.0); + // Open paths stroke inside-aligned strokes centred. + a.stroke_mut().unwrap().align = StrokeAlign::Inside; + assert_eq!(a.outset(), 5.0); + assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true)))); +} + +#[test] +fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + // Miter joins can spike out to the limit × half the weight. + assert_eq!(a.outset(), 10.0); + let st = a.stroke_mut().unwrap(); + st.join = LineJoin::Bevel; + st.cap = LineCap::Square; + assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner"); + let st = a.stroke_mut().unwrap(); + st.cap = LineCap::Round; + st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] }); + assert_eq!(st.profile_max(), 2.5); + assert_eq!(a.outset(), 2.5); + a.stroke_mut().unwrap().paint = Paint::None; + assert_eq!(a.outset(), 0.0, "an unpainted stroke"); +} + +#[test] +fn contents_slot_follows_item_edits_and_round_trips() { + let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None)); + let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() }; + assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default"); + a.set_contents_at(1); + assert!(!a.is_basic()); + // [0 | 1 2]: removing below shifts the slot, removing above doesn't. + a.remove_item(2); + assert_eq!(a.contents_at(), 1); + a.remove_item(0); + assert_eq!((a.contents_at(), a.contents_index), (0, None)); + // Inserting next to the slot joins the item below it. + a.items.push(fill()); + a.set_contents_at(1); // [0 | 1] + a.insert_item(1, fill()); + assert_eq!(a.contents_at(), 2); + a.insert_item(3, fill()); // [0 1 | 2 3] + assert_eq!(a.contents_at(), 2); + // Moves keep their side when landing next to the slot, cross it otherwise. + assert_eq!(a.move_item(3, 2), Some(2)); + assert_eq!(a.contents_at(), 2, "stays above"); + a.move_item(3, 0); + assert_eq!(a.contents_at(), 3, "crossed below"); + a.move_item(0, 3); + assert_eq!(a.contents_at(), 2, "crossed above"); + assert_eq!(a.move_item(9, 0), None); + // Saved only when set; older files load with every item above. + let json = serde_json::to_string(&a).unwrap(); + assert!(json.contains("\"contents_index\":2")); + assert_eq!(serde_json::from_str::(&json).unwrap(), a); + let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap(); + assert_eq!(old.contents_index, None); + assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index")); +} + +/// The issue's case (#4174): two circles with different fills, the same stroke and the same +/// effect share the stroke and the effect, not the fills; a shared stroke at another place in one +/// stack maps to its own index there; nothing shared shares nothing; equal stacks share all. +#[test] +fn mixed_appearances_share_their_equal_rows() { + let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 11.0)); + let fill = |c: Color| AppearanceItem::Fill(FillLayer::new(Paint::solid(c))); + let feather = Effect { id: "stylize.feather".into(), params: serde_json::json!({"radius": 5}), visible: true }; + let a = Appearance { items: vec![fill(Color::rgb(1.0, 1.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() }; + let b = Appearance { items: vec![fill(Color::rgb(1.0, 0.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() }; + let shared = SharedStack::of(&[&a, &b]); + assert_eq!(shared.items, vec![vec![1, 1]], "the stroke, not the fills"); + assert_eq!(shared.effects, vec![vec![0, 0]]); + // The stroke under an extra fill in the second object. + let c = + Appearance { items: vec![fill(Color::BLACK), fill(Color::WHITE), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() }; + let shared = SharedStack::of(&[&a, &c]); + assert_eq!(shared.items, vec![vec![1, 2]]); + assert_eq!(shared.item_in(1, 1), Some(2)); + assert_eq!(shared.item_in(0, 1), None); + // Nothing alike. + let d = Appearance { items: vec![fill(Color::WHITE)], ..Appearance::default() }; + let none = SharedStack::of(&[&a, &d]); + assert!(none.items.is_empty() && none.effects.is_empty()); + // Equal stacks share everything, each row where it is. + assert_eq!(SharedStack::of(&[&a, &a.clone(), &a.clone()]).items, vec![vec![0, 0, 0], vec![1, 1, 1]]); +} + +/// Matches keep their stacking order: two equal rows in the opposite order match once. +#[test] +fn aligned_rows_never_cross() { + assert_eq!(align(&[1, 2], &[2, 1], |a, b| a == b).iter().filter(|m| m.is_some()).count(), 1); + assert_eq!(align(&[1, 2, 3], &[9, 1, 3], |a, b| a == b), vec![Some(1), None, Some(2)]); + assert_eq!(align::(&[], &[1], |a, b| a == b), Vec::>::new()); +} diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs new file mode 100644 index 000000000..bdd20f4c8 --- /dev/null +++ b/crates/doc/src/artboard.rs @@ -0,0 +1,152 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Artboard { + pub id: u32, + pub name: String, + /// Artboard rectangle in document points. + pub rect: Rect, + /// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands + /// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be + /// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays + /// selected ([`Document::new_art`]). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub locked: bool, + /// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center. + /// Exports and print leave it out. Always written, as before it could be set. + #[serde(default)] + pub show_center_mark: bool, + /// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side + /// through the center. Exports and print leave them out. Always written, as before it could + /// be set. + #[serde(default)] + pub show_cross_hairs: bool, + /// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and + /// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written + /// only when on, so a document that never shows them saves as before. + #[serde(default, skip_serializing_if = "skip::is_default")] + pub show_video_safe_areas: bool, + /// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its + /// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many + /// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't + /// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is + /// brought into it as it opens. + #[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")] + 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times +/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and +/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare. +pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive = 0.1..=10.0; + +/// How far in from each edge of an artboard the video safe areas lie, as a share of its width +/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the +/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television +/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5). +pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05]; + +fn square_pixels() -> f64 { + 1.0 +} + +fn is_square_pixels(v: &f64) -> bool { + *v == 1.0 +} + +/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that +/// isn't a number (NaN, infinite) reads as square pixels. +fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = f64::deserialize(d)?; + let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end()); + Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 }) +} + +impl Artboard { + /// The action-safe and the title-safe area of the artboard, in that order, each inset from its + /// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas). + pub fn video_safe_areas(&self) -> [Rect; 2] { + let r = self.rect; + VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height())) + } +} + +/// A document's locked artboards ([`Document::locked_artboards`]). +pub struct LockedArtboards<'a> { + pub(crate) rects: Vec, + pub(crate) symbols: SymbolBoxes<'a>, + /// The new art, which isn't on them ([`Document::new_art`]). + pub(crate) new_art: Arc>, + /// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no + /// artboard. + pub(crate) pattern_art: std::collections::HashSet, + /// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a + /// group are each answered with one measurement of the group. + pub(crate) held: std::cell::RefCell>, +} + +impl LockedArtboards<'_> { + /// No artboard is locked. + pub fn is_empty(&self) -> bool { + self.rects.is_empty() + } + /// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its + /// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An + /// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or + /// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or + /// new art while it stays selected ([`Document::new_art`]). + pub fn holds(&self, n: &Node) -> bool { + !self.new_art.contains(&n.id) && self.overlaps(n) + } + /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it + /// is new art? + pub fn overlaps(&self, n: &Node) -> bool { + if self.rects.is_empty() + || !n.visible + || n.is_layer() + || matches!(n.kind, NodeKind::Path { guide: true, .. }) + || self.pattern_art.contains(&n.id) + { + return false; + } + if let Some(held) = self.held.borrow().get(&n.id) { + return *held; + } + let held = + n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0)); + self.held.borrow_mut().insert(n.id, held); + held + } + /// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id` + /// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])? + pub fn holds_id(&self, id: NodeId) -> bool { + !self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n)) + } +} + +/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it +/// holds. A clone shares the value with the document it was cloned from. +#[derive(Clone, Debug, Default)] +pub struct RetiredArtboardId(pub(crate) Arc); + +impl RetiredArtboardId { + pub fn get(&self) -> u32 { + self.0.load(std::sync::atomic::Ordering::Relaxed) + } + /// Raise the value to `id` when `id` is higher. + pub fn raise(&self, id: u32) { + self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed); + } +} + +impl PartialEq for RetiredArtboardId { + fn eq(&self, _: &Self) -> bool { + true + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs new file mode 100644 index 000000000..a17b430be --- /dev/null +++ b/crates/doc/src/document.rs @@ -0,0 +1,786 @@ +use super::artboard::{Artboard, LockedArtboards}; +use super::graphic_style::default_graphic_styles; +use super::guides::{GridPrefs, RulerMode}; +use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID}; +use super::units::{ColorMode, Unit}; +use super::*; + +impl Document { + /// Is everything but the layers the same in `other`, as the next version of the document after + /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it + /// doesn't change: quick for every frame of an animation. + pub fn same_outside_layers(&self, other: &Self) -> bool { + let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); + let same_pattern = |a: &PatternDef, b: &PatternDef| { + same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } + }; + let Self { + version, + title, + template, + units, + ruler_origin, + rulers, + artboard_ruler_origins, + color_mode, + artboards, + layers: _, + swatches, + swatch_groups, + graphic_styles, + char_styles, + para_styles, + text_threads, + symbols, + graph_designs, + guides, + views, + saved_selections, + variables, + grid, + raster_effects_ppi, + images, + patterns, + pattern_edit, + mask_edit, + next_id: _, + unknown, + page_isolate, + page_knockout, + spot_use_lab, + setup, + paste_remembers_layers, + metadata, + raster_effects, + last_view, + transparency_grid, + layers_open, + color_profiles, + export_settings, + extra, + slices, + slices_clip_to_artboard, + print_setup, + assets, + svg_scripts, + puppet, + new_art, + dimensions, + retired_artboard_id: _, + index: _, + } = self; + *version == other.version + && *title == other.title + && *template == other.template + && *units == other.units + && *ruler_origin == other.ruler_origin + && *rulers == other.rulers + && *artboard_ruler_origins == other.artboard_ruler_origins + && *color_mode == other.color_mode + && *artboards == other.artboards + && *swatches == other.swatches + && *swatch_groups == other.swatch_groups + && *graphic_styles == other.graphic_styles + && *char_styles == other.char_styles + && *para_styles == other.para_styles + && *text_threads == other.text_threads + && symbols.len() == other.symbols.len() + && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && graph_designs.len() == other.graph_designs.len() + && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && *guides == other.guides + && *views == other.views + && *saved_selections == other.saved_selections + && *variables == other.variables + && *grid == other.grid + && *raster_effects_ppi == other.raster_effects_ppi + && *images == other.images + && patterns.len() == other.patterns.len() + && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) + && match (pattern_edit, &other.pattern_edit) { + (Some(a), Some(b)) => { + a.pattern == b.pattern + && a.layer == b.layer + && match (&a.original, &b.original) { + (Some(a), Some(b)) => same_pattern(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + (a, b) => a.is_none() && b.is_none(), + } + && *mask_edit == other.mask_edit + && *unknown == other.unknown + && *page_isolate == other.page_isolate + && *page_knockout == other.page_knockout + && *spot_use_lab == other.spot_use_lab + && *setup == other.setup + && *paste_remembers_layers == other.paste_remembers_layers + && *metadata == other.metadata + && *raster_effects == other.raster_effects + && *last_view == other.last_view + && *transparency_grid == other.transparency_grid + && *layers_open == other.layers_open + && *color_profiles == other.color_profiles + && *export_settings == other.export_settings + && *extra == other.extra + && *slices == other.slices + && *slices_clip_to_artboard == other.slices_clip_to_artboard + && *print_setup == other.print_setup + && *assets == other.assets + && *svg_scripts == other.svg_scripts + && match (puppet, &other.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + && *new_art == other.new_art + && *dimensions == other.dimensions + } + + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value + /// isn't a number). + pub fn raster_ppi(&self) -> f64 { + Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) + } + + /// The ruler zero point, or zero on both axes when either stored coordinate is invalid. + /// Rulers, coordinate fields and guide snapping use the same supported range. + pub fn ruler_origin_or_zero(&self) -> Point { + let origin = self.ruler_origin; + if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO } + } + + /// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`] + /// with global rulers; with artboard rulers that artboard's top-left corner moved by its own + /// offset (the first artboard's when `index` is out of range, the global zero point when the + /// document has no artboard or the result is out of the supported range). + pub fn ruler_origin_for(&self, index: usize) -> Point { + if self.rulers == RulerMode::Global { + return self.ruler_origin_or_zero(); + } + let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() }; + let corner = Point::new(ab.rect.x0, ab.rect.y0); + let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO); + let origin = corner + offset; + if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { + origin + } else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD { + corner + } else { + Point::ZERO + } + } + + /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). + pub fn new(width: f64, height: f64) -> Self { + Self::new_with_mode(width, height, ColorMode::Rgb) + } + + /// [`Document::new`] in colour `mode`, with that mode's default swatches. + pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { + let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); + let mut d = Self { + version: FORMAT_VERSION, + title: "Untitled-1".into(), + template: false, + units: Unit::Points, + ruler_origin: Point::ZERO, + rulers: RulerMode::Artboard, + artboard_ruler_origins: BTreeMap::new(), + color_mode: mode, + artboards: vec![Artboard { + id: 1, + name: "Artboard 1".into(), + rect: Rect::new(0.0, 0.0, width, height), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }], + layers: vec![], + swatches: swatches.into(), + swatch_groups: swatch_groups.into(), + graphic_styles: default_graphic_styles().into(), + char_styles: Default::default(), + para_styles: Default::default(), + text_threads: vec![], + symbols: vec![], + graph_designs: vec![], + guides: vec![], + views: vec![], + saved_selections: Default::default(), + variables: Default::default(), + grid: GridPrefs::default(), + raster_effects_ppi: 72.0, + images: BTreeMap::new(), + patterns: vec![], + pattern_edit: None, + mask_edit: None, + next_id: 1, + unknown: Default::default(), + page_isolate: false, + page_knockout: false, + spot_use_lab: true, + setup: DocSetup::default(), + paste_remembers_layers: false, + metadata: DocMetadata::default(), + raster_effects: RasterEffectsSettings::default(), + last_view: None, + transparency_grid: false, + layers_open: None, + color_profiles: ColorProfiles::default(), + export_settings: Default::default(), + extra: Default::default(), + slices: vec![], + slices_clip_to_artboard: true, + print_setup: None, + assets: vec![], + svg_scripts: vec![], + puppet: None, + new_art: Default::default(), + dimensions: false, + retired_artboard_id: Default::default(), + index: Default::default(), + }; + let id = d.alloc_id(); + d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); + d + } + + /// Allocate a fresh node id. + pub fn alloc_id(&mut self) -> NodeId { + let id = NodeId(self.next_id); + // Never reached from a counter [`Self::fix_next_id`] has seen (it stays under + // [`MAX_NEXT_ID`]); saturating so that no counter can overflow. + self.next_id = self.next_id.saturating_add(1); + id + } + + pub fn peek_next_id(&self) -> u64 { + self.next_id + } + + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + pub fn fix_next_id(&mut self) { + // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a + // counter up there: new ids start under them and never reach them, where counting on from + // the largest would run out and hand out ids the document already uses. + let mut max = 0; + let mut see = |id: u64| { + if id < MAX_NEXT_ID { + max = max.max(id); + } + }; + for l in &self.layers { + l.walk(&mut |n| see(n.id.0)); + } + self.slices.iter().for_each(|s| see(s.id.0)); + self.assets.iter().for_each(|a| see(a.id)); + let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); + self.next_id = counter.max(max.saturating_add(1)); + } + + /// Find a node anywhere in the tree. + pub fn node(&self, id: NodeId) -> Option<&Node> { + self.index_path(id).and_then(|p| self.node_at(&p)) + } + + /// [`Document::node`] without building the lookup cache when there is none: for a look at a + /// document kept in the undo history ([`Document::drop_lookup_cache`]). + pub fn node_uncached(&self, id: NodeId) -> Option<&Node> { + match self.index.is_built() { + true => self.node(id), + false => self.walk_index_path(id).and_then(|p| self.node_at(&p)), + } + } + + /// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a + /// table per step would add up. Lookups still work; the first one builds the cache again. + pub fn drop_lookup_cache(&self) { + self.index.clear(); + } + + /// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]). + pub fn has_lookup_cache(&self) -> bool { + self.index.is_built() + } + + /// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in + /// tree order where `id` appears. + pub fn index_path(&self, id: NodeId) -> Option> { + // The cached path, while the node there still has the id ([`id_index`]). + let cached = self.index.get(); + match cached.as_ref().and_then(|m| m.get(&id)) { + Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p), + Some(None) => return self.walk_index_path(id), + _ => {} + } + // A stale path (the tree changed), an id the cache doesn't have (deleted or added since), + // or no cache yet: walk, and rebuild now and then. + let found = self.walk_index_path(id); + let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id)); + if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) { + self.index.rebuild(&self.layers); + } + found + } + + /// [`Document::index_path`] by walking the tree. + pub(crate) fn walk_index_path(&self, id: NodeId) -> Option> { + fn find(nodes: &[Arc], id: NodeId, path: &mut Vec) -> bool { + for (i, n) in nodes.iter().enumerate() { + path.push(i); + if n.id == id { + return true; + } + if let Some(ch) = n.children() + && find(ch, id, path) + { + return true; + } + path.pop(); + } + false + } + let mut p = vec![]; + find(&self.layers, id, &mut p).then_some(p) + } + + /// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an + /// edit of many objects looks them all up for the cost of one search instead of one each. Like + /// `index_path`, an id that appears twice maps to its first place in tree order. + pub fn index_paths(&self, ids: impl IntoIterator) -> std::collections::HashMap> { + use std::collections::{HashMap, HashSet}; + fn walk(nodes: &[Arc], want: &HashSet, path: &mut Vec, out: &mut HashMap>) -> bool { + for (i, n) in nodes.iter().enumerate() { + path.push(i); + if want.contains(&n.id) && !out.contains_key(&n.id) { + out.insert(n.id, path.clone()); + if out.len() == want.len() { + return true; + } + } + if let Some(ch) = n.children() + && walk(ch, want, path, out) + { + return true; + } + path.pop(); + } + false + } + let mut want: HashSet = ids.into_iter().collect(); + let mut out = HashMap::with_capacity(want.len()); + // Cached paths that still check out ([`id_index`]); the rest take one walk. For a good + // share of the document, one walk is quicker than checking each cached path. + if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) { + want.retain(|id| match cache.get(id) { + Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => { + out.insert(*id, p); + false + } + _ => true, + }); + } + if !want.is_empty() { + let mut rest = HashMap::with_capacity(want.len()); + walk(&self.layers, &want, &mut Vec::new(), &mut rest); + out.extend(rest); + } + out + } + + /// The node at index path `path` (as [`Document::index_path`] gives it). + pub fn node_at(&self, path: &[usize]) -> Option<&Node> { + let (first, rest) = path.split_first()?; + let mut cur: &Node = self.layers.get(*first)?; + for &i in rest { + cur = cur.children()?.get(i)?; + } + Some(cur) + } + + /// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as + /// [`Document::node_mut`] does. + pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> { + let (first, rest) = path.split_first()?; + let mut cur: &mut Arc = self.layers.get_mut(*first)?; + for &i in rest { + cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?; + } + Some(Arc::make_mut(cur)) + } + + /// [`Document::ancestry`] of the node at index path `path`. + pub fn ancestry_at(&self, path: &[usize]) -> Option> { + let mut out = Vec::with_capacity(path.len()); + let mut nodes = &self.layers; + for &i in path { + let n = nodes.get(i)?; + out.push(n.id); + if let Some(ch) = n.children() { + nodes = ch; + } + } + Some(out) + } + + /// Ids from the top-level layer down to `id` inclusive. + pub fn ancestry(&self, id: NodeId) -> Option> { + let path = self.index_path(id)?; + let mut out = Vec::with_capacity(path.len()); + let mut nodes = &self.layers; + for &i in &path { + let n = &nodes[i]; + out.push(n.id); + if let Some(ch) = n.children() { + nodes = ch; + } + } + Some(out) + } + + pub fn parent_of(&self, id: NodeId) -> Option { + let a = self.ancestry(id)?; + (a.len() >= 2).then(|| a[a.len() - 2]) + } + + /// The top-level layer that contains `id`. + pub fn layer_of(&self, id: NodeId) -> Option { + self.ancestry(id).and_then(|a| a.first().copied()) + } + + /// Mutable access to a node; clones the Arc path (copy-on-write). + pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let path = self.index_path(id)?; + let mut cur: &mut Arc = &mut self.layers[path[0]]; + for &i in &path[1..] { + cur = &mut Arc::make_mut(cur).children_mut()?[i]; + } + Some(Arc::make_mut(cur)) + } + + /// Children list of a container (or the top-level layer list for `None`). + pub fn children_mut(&mut self, parent: Option) -> Result<&mut Vec>, DocError> { + match parent { + None => Ok(&mut self.layers), + Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)), + } + } + + pub fn children(&self, parent: Option) -> Option<&Vec>> { + match parent { + None => Some(&self.layers), + Some(p) => self.node(p)?.children(), + } + } + + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { + let id = node.id; + let ch = self.children_mut(parent)?; + let i = index.min(ch.len()); + ch.insert(i, Arc::new(node)); + Ok(id) + } + + /// Remove a node and return it. + pub fn remove(&mut self, id: NodeId) -> Result, DocError> { + let parent = self.parent_of(id); + let path = self.index_path(id).ok_or(DocError::NoNode(id))?; + let idx = *path.last().ok_or(DocError::NoNode(id))?; + let ch = self.children_mut(parent)?; + if idx >= ch.len() { + return Err(DocError::NoNode(id)); + } + Ok(ch.remove(idx)) + } + + /// Move a node to `parent` at `index` (index measured after removal). + pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { + if let Some(p) = parent + && self.ancestry(p).is_some_and(|a| a.contains(&id)) + { + return Err(DocError::Invalid("cannot move an object into itself".into())); + } + let n = self.remove(id)?; + let ch = self.children_mut(parent)?; + let i = index.min(ch.len()); + ch.insert(i, n); + Ok(()) + } + + /// Position of `id` within its parent: (parent, index, sibling count). + pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { + let parent = self.parent_of(id); + let idx = *self.index_path(id)?.last()?; + let n = self.children(parent)?.len(); + Some((parent, idx, n)) + } + + /// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the + /// children of each parent. Each id is listed once and none lies inside another listed node, as + /// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies + /// inside another listed node, it fails with `NoNode` and leaves the document as it was. + pub fn remove_many(&mut self, ids: &[NodeId]) -> Result>, DocError> { + let places = self.places(ids)?; + let removed = self.take(places, ids.len())?; + self.index.clear(); + Ok(removed) + } + + /// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make` + /// builds from them (in the order of `ids`) in the parent of the last of them, at that node's + /// index less the number of listed nodes below it there. Returns the new node's id. + pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec>) -> Node) -> Result { + let places = self.places(ids)?; + let last = ids.len().checked_sub(1); + let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i))); + let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) }; + // The parent and each node it is in move down by the listed nodes before them. + let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index)); + let to: Vec = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect(); + let at = i.saturating_sub(below(&up, i)); + let node = make(self.take(places, ids.len())?); + let id = node.id; + let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?; + let at = at.min(children.len()); + children.insert(at, Arc::new(node)); + self.index.clear(); + Ok(id) + } + + /// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in + /// list order, in one pass over the children of each parent. Each anchor is listed once and none + /// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside + /// another listed anchor, it fails with `NoNode` and leaves the document as it was. + pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec)>, replace: bool) -> Result<(), DocError> { + let anchors: Vec = items.iter().map(|(id, _)| *id).collect(); + let places = self.places(&anchors)?; + let mut lists: Vec> = items.into_iter().map(|(_, nodes)| nodes).collect(); + self.edit_children(places, |k, anchor, children| { + if !replace { + children.push(anchor); + } + let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default(); + children.extend(nodes.into_iter().map(Arc::new)); + })?; + self.index.clear(); + Ok(()) + } + + /// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree, + /// is listed twice or lies inside another listed node. + fn places(&self, ids: &[NodeId]) -> Result { + let paths = self.index_paths(ids.iter().copied()); + let mut places = Places::new(); + for (k, id) in ids.iter().enumerate() { + let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?; + match places.get_mut(up) { + Some(kids) => kids.push((i, k)), + None => { + places.insert(up.to_vec(), vec![(i, k)]); + } + } + } + for kids in places.values_mut() { + kids.sort_unstable(); + } + // An id listed twice has one path, so its parent lists the same child twice. A listed node + // lies inside another when its parent's path, or a part of it from the top, is the path of a + // listed node. + let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1); + let listed = |path: &[usize]| { + path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok())) + }; + let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l]))); + let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k); + if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) { + return Err(DocError::NoNode(*id)); + } + Ok(places) + } + + /// Take the nodes `places` lists out of their parents and return them in the order of the list, + /// which is `len` long. + fn take(&mut self, places: Places, len: usize) -> Result>, DocError> { + let mut taken = vec![None; len]; + self.edit_children(places, |k, node, _| { + if let Some(t) = taken.get_mut(k) { + *t = Some(node); + } + })?; + Ok(taken.into_iter().flatten().collect()) + } + + /// Rebuild the children of each parent in `places` once, the parents later in tree order + /// first. `each` gets each listed child, its place in the list and the parent's new children, + /// onto which it pushes what takes the child's place. + fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc, &mut Vec>)) -> Result<(), DocError> { + for (up, kids) in places.into_iter().rev() { + let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?; + let old = std::mem::take(children); + children.reserve(old.len()); + let mut kids = kids.into_iter().peekable(); + for (i, node) in old.into_iter().enumerate() { + match kids.next_if(|(j, _)| *j == i) { + Some((_, k)) => each(k, node, children), + None => children.push(node), + } + } + } + Ok(()) + } + + /// The children of the node at index path `path`, or the top-level layers for an empty path. + fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec>> { + match path { + [] => Some(&mut self.layers), + _ => self.node_mut_at(path)?.children_mut(), + } + } + + /// Default target layer: the topmost visible, unlocked layer. + pub fn default_layer(&self) -> Option { + self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id) + } + + /// Add a new top-level layer above all others. + pub fn add_layer(&mut self, name: Option<&str>) -> NodeId { + let id = self.alloc_id(); + let n = self.layers.len(); + let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name()); + self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)))); + id + } + + /// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the + /// numbering: it starts after the number of layers there are and skips any layer's name. + pub fn next_layer_name(&self) -> String { + let (mut count, mut taken) = (0, std::collections::HashSet::new()); + self.walk(|n| { + if n.is_layer() { + count += 1; + taken.extend(n.name.as_deref()); + } + }); + (count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default() + } + + /// Visit every node depth first in paint order (bottom to top). + pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) { + for l in &self.layers { + l.walk(&mut f); + } + } + + pub fn node_count(&self) -> usize { + self.layers.iter().map(|l| l.count()).sum() + } + + /// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard + /// ([`Document::on_locked_artboard`])? + pub fn is_editable(&self, id: NodeId) -> bool { + self.is_editable_with(&self.locked_artboards(), id) + } + + /// [`Document::is_editable`] with the locked artboards `locks` of this document, measured + /// once for many ids. + pub fn is_editable_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.visible && !n.locked))) && !locks.holds_id(id) + } + + /// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once + /// for all of them, a group once for all its members. + pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ { + let locks = self.locked_artboards(); + move |id| self.is_editable_with(&locks, id) + } + + /// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]). + pub fn locked_artboards(&self) -> LockedArtboards<'_> { + self.locked_artboards_except(usize::MAX) + } + + /// [`Document::locked_artboards`] leaving out artboard `index`. + pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> { + // Art flush with an edge from outside can overlap it by a rounding error, as in + // `art_bounds_on` (#1295). + let rects: Vec = + self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect(); + // The objects of pattern editing mode's layer, a sublayer's objects included. + fn objects(n: &Node, out: &mut std::collections::HashSet) { + for c in n.children().into_iter().flatten() { + if c.is_layer() { + objects(c, out); + } else { + out.insert(c.id); + } + } + } + let mut pattern_art = std::collections::HashSet::new(); + if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) { + objects(layer, &mut pattern_art); + } + LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() } + } + + /// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none. + pub fn on_locked_artboard(&self, id: NodeId) -> bool { + self.locked_artboards().holds_id(id) + } + + /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else + /// the group or other object around it right inside a layer. None for a layer or sublayer. + pub fn layer_object(&self, id: NodeId) -> Option<&Node> { + self.layer_object_at(&self.index_path(id)?) + } + + /// [`Document::layer_object`] for the node at index path `path`. + pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> { + // Down the path to the first node that isn't a layer. + let mut nodes = &self.layers; + for &i in path { + let n = nodes.get(i)?; + if !n.is_layer() { + return Some(n.as_ref()); + } + nodes = n.children()?; + } + None + } + + pub fn is_visible(&self, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) + } + + /// The innermost layer or sublayer containing `id` (`id` itself when it is a layer). + pub fn layer_containing(&self, id: NodeId) -> Option { + let a = self.ancestry(id)?; + a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer)) + } + + /// Every art object the Selection tool can reach (Select All): the visible, unlocked objects + /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first), + /// except those on a locked artboard. + pub fn selectable_art(&self) -> Vec { + let locks = self.locked_artboards(); + self.layers + .iter() + .filter(|l| l.visible && !l.locked && !l.is_template()) + .flat_map(|l| l.layer_art(true)) + .filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n))) + .collect() + } + + /// Colour of the innermost layer or sublayer containing `id` (selection highlight colour). + pub fn layer_color(&self, id: NodeId) -> [u8; 3] { + let l = self.layer_containing(id).and_then(|l| self.node(l)); + match l.map(|n| &n.kind) { + Some(NodeKind::Layer { color, .. }) => color.rgb(), + _ => LAYER_COLORS[0].1, + } + } +} diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs new file mode 100644 index 000000000..37fd869c3 --- /dev/null +++ b/crates/doc/src/document_ext.rs @@ -0,0 +1,389 @@ +use super::graphic_style::GraphicStyle; +use super::limits::MAX_NEXT_ID; +use super::records::{ImageBlob, SavedSelection, SavedView}; +use super::*; + +impl Document { + /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. + pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> { + im.pixels.as_deref().or_else(|| self.images.get(&im.key)) + } + + /// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art + /// made outside a document) into [`Self::images`]: under the image's key, or a free one when + /// the key shows other pixels. + pub fn adopt_pixels(&mut self, n: &mut Node) { + // Only what holds such pixels is copied out of the art it shares. + fn holds(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some()) + || n.extras().mask.as_ref().is_some_and(|m| holds(&m.art)) + || n.children().into_iter().flatten().any(|c| holds(c)) + } + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + && holds(&m.art) + { + self.adopt_pixels(Arc::make_mut(&mut m.art)); + } + for c in n.children_mut().into_iter().flatten() { + if holds(c) { + self.adopt_pixels(Arc::make_mut(c)); + } + } + } + + pub(crate) fn adopt_image_pixels(&mut self, im: &mut ImageObject) { + let Some(blob) = im.pixels.take() else { return }; + let mut key = im.key.clone(); + let mut i = 1; + while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) { + i += 1; + key = format!("{}-{i}", im.key); + } + self.images.entry(key.clone()).or_insert_with(|| (*blob).clone()); + im.key = key; + } + + /// Saved selections as a file gives them, made safe to list and recall: at most + /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document + /// has, each once. (An id it doesn't have would select whatever object takes that id later.) + pub fn tidy_saved_selections(&mut self) { + if self.saved_selections.is_empty() { + return; + } + let mut ids = std::collections::HashSet::new(); + self.walk(|n| { + ids.insert(n.id); + }); + let mut kept: Vec = vec![]; + for s in std::mem::take(&mut self.saved_selections) { + if kept.len() >= SavedSelection::MAX { + break; + } + let Some(name) = SavedSelection::clean_name(&s.name) else { continue }; + if kept.iter().any(|k| k.name == name) { + continue; + } + let mut seen = std::collections::HashSet::new(); + let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect(); + kept.push(SavedSelection { name, objects }); + } + self.saved_selections = kept.into(); + } + + /// Saved views as a file gives them, made safe to list and go to: at most [`SavedView::MAX`], + /// each with a clean, unique name, a finite centre and a finite, positive zoom (a rotation + /// that isn't finite becomes 0°). + pub fn tidy_views(&mut self) { + if self.views.is_empty() { + return; + } + let mut kept: Vec = vec![]; + for v in std::mem::take(&mut self.views) { + if kept.len() >= SavedView::MAX { + break; + } + let Some(name) = SavedView::clean_name(&v.name) else { continue }; + if !v.is_usable() || kept.iter().any(|k| k.name == name) { + continue; + } + let rotation = if v.rotation.is_finite() { v.rotation } else { 0.0 }; + kept.push(SavedView { name, rotation, ..v }); + } + 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). + pub fn drop_edit_modes(&mut self) { + if let Some(me) = self.mask_edit.take() { + self.layers.retain(|l| l.id != me.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.) + pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> { + if self.mask_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(area) = n.extras_if_any().and_then(|e| e.intertwine.as_deref_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. +impl Document { + /// Index of the graphic style named `name`. + pub fn graphic_style_index(&self, name: &str) -> Option { + self.graphic_styles.iter().position(|g| g.name == name) + } + + /// The graphic style named `name`. + pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> { + self.graphic_styles.iter().find(|g| g.name == name) + } + + /// The graphic style with id `id` (0 never matches). + pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> { + self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id) + } + + /// An id no graphic style has. + pub fn next_graphic_style_id(&self) -> u32 { + free_id(self.graphic_styles.iter().map(|g| g.id)) + } + + /// The id of graphic style `index`, assigning one if it has none yet. + pub fn graphic_style_id(&mut self, index: usize) -> u32 { + if self.graphic_styles[index].id == 0 { + self.graphic_styles[index].id = self.next_graphic_style_id(); + } + self.graphic_styles[index].id + } + + /// The default name of a new graphic style: the first free "Graphic Style N", N counting on + /// from the number of styles. + pub fn new_graphic_style_name(&self) -> String { + (self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default() + } + + /// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name. + pub fn unique_symbol_name(&self, base: &str) -> String { + let taken = |n: &str| self.symbols.iter().any(|s| s.name == n); + if !taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) + } + + /// Symbol Options › Registration for `name` (default: centre). + pub fn symbol_registration(&self, name: &str) -> SymbolRegistration { + self.unknown + .get(SYMBOL_REGISTRATION) + .and_then(|m| m.get(name)) + .and_then(|v| v.as_u64()) + .and_then(SymbolRegistration::from_u64) + .unwrap_or_default() + } + + /// Set or clear (when centre) the registration point stored for `name`. + pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) { + let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({})); + let Some(o) = m.as_object_mut() else { return }; + if reg.is_center() { + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_REGISTRATION); + } + } else { + o.insert(name.into(), serde_json::json!(reg.0)); + } + } + + /// Move the stored registration when a symbol is renamed. + pub fn rename_symbol_registration(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let reg = self.symbol_registration(from); + self.set_symbol_registration(from, SymbolRegistration::CENTER); + self.set_symbol_registration(to, reg); + } + + /// Symbol Options › Export Type for `name` (default: Movie Clip). + pub fn symbol_export_type(&self, name: &str) -> SymbolExportType { + self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default() + } + + /// Set or clear (when Movie Clip, the default) the export type stored for `name`. + pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) { + if kind.is_default() { + self.clear_symbol_export_type(name); + return; + } + let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({})); + let Some(o) = m.as_object_mut() else { return }; + o.insert(name.into(), serde_json::json!(kind.id())); + } + + /// Drop any stored export type for `name` (Movie Clip default). + pub fn clear_symbol_export_type(&mut self, name: &str) { + let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return }; + let Some(o) = m.as_object_mut() else { return }; + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_EXPORT_TYPE); + } + } + + /// Move the stored export type when a symbol is renamed. + pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let kind = self.symbol_export_type(from); + self.clear_symbol_export_type(from); + self.set_symbol_export_type(to, kind); + } + + /// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default). + pub fn symbol_nine_slice(&self, name: &str) -> Option { + self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value) + } + + /// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`. + pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option) { + let Some(g) = guides else { + let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return }; + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_NINE_SLICE); + } + return; + }; + let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({})); + if let Some(o) = m.as_object_mut() { + o.insert(name.into(), g.to_value()); + } + } + + /// Move the stored 9-slice guides when a symbol is renamed. + pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let g = self.symbol_nine_slice(from); + self.set_symbol_nine_slice(from, None); + self.set_symbol_nine_slice(to, g); + } +} + +/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there +/// is (a damaged file), the smallest one free. +pub(crate) fn free_id(used: impl Iterator) -> u32 { + let mut used: Vec = used.collect(); + if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) { + return next; + } + used.sort_unstable(); + used.dedup(); + // Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them. + let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count(); + u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1)) +} + +impl Document { + /// Make the next id allocated at least `next`: ids handed out outside the layer tree (such + /// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see. + pub fn reserve_ids(&mut self, next: u64) { + self.next_id = self.next_id.max(next.min(MAX_NEXT_ID)); + } +} diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs new file mode 100644 index 000000000..96af5c75b --- /dev/null +++ b/crates/doc/src/document_guides.rs @@ -0,0 +1,313 @@ +use super::document_ext::free_id; +use super::guides::Guide; +use super::*; + +impl Document { + /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol + /// instances measured by their art ([`Node::geometric_bounds_with`]). + pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { + let symbols = self.symbol_boxes(); + ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| { + vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) }) + }) + } + + /// Visual bounds of all art, with symbol instances measured by their art. + pub fn art_bounds(&self) -> Option { + let symbols = self.symbol_boxes(); + self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols))) + } + + /// Artboard index containing point `p` (topmost = last), its edges included + /// ([`Document::artboard_holding`]). + pub fn artboard_at(&self, p: Point) -> Option { + self.artboard_holding(p, true) + } + + /// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it + /// inside or on its left or top edge, else the first (the last) that holds it by + /// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an + /// edge. On an edge two artboards share, the artboard to the right of it or below it holds the + /// point, except at the end of the edge that lies on that artboard's bottom or right edge, such + /// as the point where both artboards' bottom edges meet; there the first (the last) holds it. + pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option { + let pick = |on: &dyn Fn(Rect) -> bool| { + let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect)); + let mut all = 0..self.artboards.len(); + if topmost { all.rfind(on) } else { all.find(on) } + }; + pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p))) + } + + /// The art that moves with an artboard at `rect`: top-level objects (children of layers and + /// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and + /// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display › + /// Move Locked and Hidden Artwork with Artboard). + pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec { + // Art drawn flush with the artboard can end up a rounding error outside it (a background + // 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible. + let rect = rect.inflate(1e-6, 1e-6); + fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec) { + for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { + if c.is_layer() { + collect(c, s, rect, all, out); + } else if let Some(b) = c.geometric_bounds_with(s) + // Inside it, edges included: art flush with the right or bottom edge is on it too + // (`Rect::contains` leaves those out, #890). + && b.x0 >= rect.x0 + && b.y0 >= rect.y0 + && b.x1 <= rect.x1 + && b.y1 <= rect.y1 + { + out.push(c.id); + } + } + } + let symbols = self.symbol_boxes(); + let mut art = vec![]; + for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) { + collect(l, &symbols, rect, locked_and_hidden, &mut art); + } + art + } + + /// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers, + /// hidden and locked ones and those on template layers included, whose visual bounds (symbol + /// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip + /// group) counts by its members, so one whose members sit on two other artboards leaves the one + /// between them empty. A clip group or clipping layer counts by its members inside its clip (and + /// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork + /// (Delete Empty Artboards). + pub fn artboard_has_art(&self, rect: Rect) -> bool { + // As in `art_on_artboard`: a millionth of a point covers rounding at the edges. + let rect = rect.inflate(1e-6, 1e-6); + fn meets(b: Option, r: Rect) -> bool { + b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1) + } + fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool { + match &c.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + let Some((clip, rest)) = children.split_first() else { return false }; + let paint = clip.clip_paint(); + if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) { + return true; + } + // The members show only inside the clip's box. + let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false }; + let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1)); + rest.iter().any(|m| has(m, s, inside)) + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)), + NodeKind::Path { guide: true, .. } => false, + _ => meets(c.visual_bounds_with(s), rect), + } + } + let symbols = self.symbol_boxes(); + self.layers.iter().any(|l| has(l, &symbols, rect)) + } + + /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] + /// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as + /// locked art does. + pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec { + let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] }; + let mut art = self.art_on_artboard(rect, locked_and_hidden); + let locks = self.locked_artboards_except(index); + if !locked_and_hidden && !locks.is_empty() { + art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n))); + } + art + } + + /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its + /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose + /// artboard is gone. + pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> { + let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; + Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) + } + + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. + fn guide_layer(&self, g: &Guide) -> Option { + g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) + } + + /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is + /// visible? A guide whose layer is gone shows as one on no layer. + pub fn guide_shown(&self, g: &Guide) -> bool { + self.guide_layer(g).is_none_or(|l| self.is_visible(l)) + } + + /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? + pub fn guide_editable(&self, g: &Guide) -> bool { + self.guide_layer(g).is_none_or(|l| self.is_editable(l)) + } + + /// Put the ruler guides on no layer this document has (from files saved before guides had + /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. + pub fn rehome_guides(&mut self, layer: NodeId) -> usize { + let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); + let mut n = 0; + for (g, lost) in self.guides.iter_mut().zip(lost) { + if lost { + g.layer = Some(layer); + n += 1; + } + } + n + } + + /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that + /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. + pub fn adopt_guides(&mut self) -> usize { + self.default_layer().map_or(0, |home| self.rehome_guides(home)) + } + + /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in + /// step. Guides on no layer stay. Returns how many went. + pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { + let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); + self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) + } + + /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made + /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies + /// the objects on it. Returns how many. + pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { + let layers = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if c.is_layer() { + out.push(c.id); + } + }); + out + }; + let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); + let copies: Vec = self + .guides + .iter() + .filter_map(|g| { + let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; + Some(Guide { layer: Some(to), ..g.clone() }) + }) + .collect(); + let n = copies.len(); + self.guides.extend(copies); + n + } + + /// The ruler guides on layer `layer` (not its sublayers'), by index. + pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { + self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + } + + /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? + pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { + let along = if g.vertical { p.y } else { p.x }; + self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) + } + + /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their + /// place among the rest (the selected art stays selected). Returns how many went. + pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { + let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); + // Where each kept guide ends up. + let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); + for k in &kept { + to.push(k.then_some(n)); + n += usize::from(*k); + } + sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); + sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); + let before = self.guides.len(); + let mut flags = kept.into_iter(); + self.guides.retain(|_| flags.next().unwrap_or(true)); + before - self.guides.len() + } + + /// Move the guides of artboard `id` by `d` (along with their artboard). + pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { + for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { + *g = g.moved(d); + } + } + + /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork + /// with Artboard). + pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { + for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { + g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; + } + } + + /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their + /// artboard). + pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { + let copies: Vec = + self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); + self.guides.extend(copies); + } + + /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of + /// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts + /// as given out, so an artboard made after an undo does not take the id of one the undo took + /// away. + pub fn next_artboard_id(&self) -> u32 { + let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()])); + self.retired_artboard_id.raise(id); + id + } + + /// Record that the artboard with `id` was deleted: no later artboard takes its id. + pub fn retire_artboard_id(&self, id: u32) { + self.retired_artboard_id.raise(id); + } + + pub fn pattern(&self, name: &str) -> Option<&PatternDef> { + self.patterns.iter().find(|p| p.name == name) + } + + pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { + self.patterns.iter_mut().find(|p| p.name == name) + } + + /// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken + /// in ([`Self::adopt_pixels`]). + pub fn reid(&mut self, node: &Node) -> Node { + let mut n = node.clone(); + n.id = self.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } + // A copied annotation must never bind its source ids to unrelated art in a different + // document. Keep the measured geometry; new measurements can be linked after pasting. + if let Some(e) = n.extras_if_any() + && let Some(dimension) = &mut e.dimension + { + dimension.source = None; + } + // A copy of a threaded frame is in no thread, so its first paragraph starts in it and its + // layout caches are dropped. A caller that keeps the copy as an object fills them again. + if let NodeKind::Text(t) = &mut n.kind + && t.first_para_continues + { + t.first_para_continues = false; + t.cached_bounds = None; + t.cached_baselines.clear(); + } + let mut renamed = std::collections::HashMap::new(); + if let Some(ch) = n.children_mut() { + let old: Vec> = std::mem::take(ch); + *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); + renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); + } + // An Objects on Path group names its path and its objects by id: the copy names the copies. + if let Some(e) = n.extras_if_any() + && let Some(on_path) = &mut e.objects_on_path + { + on_path.rename(&renamed); + } + n + } +} diff --git a/crates/doc/src/graphic_style.rs b/crates/doc/src/graphic_style.rs new file mode 100644 index 000000000..95c61b6eb --- /dev/null +++ b/crates/doc/src/graphic_style.rs @@ -0,0 +1,92 @@ +use super::*; + +/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GraphicStyle { + pub name: String, + pub appearance: Appearance, + /// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles + /// from older files get one when an object is first linked to them). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub id: u32, + #[serde(default = "one", skip_serializing_if = "skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub blend: vectorcraft_color::BlendMode, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub isolate: bool, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub knockout: Knockout, + /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style + /// places them on each object's own bounds. Styles saved before this kept document + /// coordinates (false). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unit_box: bool, +} + +fn one() -> f32 { + 1.0 +} + +/// The name of the style new documents list first (Sort by Name keeps it first). +pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; + +impl GraphicStyle { + /// The box a style's placed gradients are stored relative to. + pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); + + /// A style with `appearance` (its placed gradients, if any, in unit-box space) and default + /// transparency (no id yet). + pub fn new(name: impl Into, appearance: Appearance) -> Self { + Self { + name: name.into(), + appearance, + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Knockout::Neutral, + unit_box: true, + } + } + /// A style capturing `n`'s transparency and `appearance` (in unit-box space). + pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { + Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } + } + /// The style's appearance as object `n` takes it: placed gradients stored in unit-box space + /// land at the same place relative to `n`'s geometric bounds. + pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> { + let placed = self.unit_box && self.appearance.has_placed_gradient(); + match n.geometric_bounds().filter(|_| placed) { + Some(b) => { + let mut ap = self.appearance.clone(); + ap.rebase_gradients(Self::UNIT_BOX, b); + std::borrow::Cow::Owned(ap) + } + None => std::borrow::Cow::Borrowed(&self.appearance), + } + } + /// Give `n` this style's transparency. + pub fn apply_transparency(&self, n: &mut Node) { + (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); + } + /// Does `n` have this style's transparency? + pub fn transparency_matches(&self, n: &Node) -> bool { + (n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout) + } +} + +pub(crate) fn default_graphic_styles() -> Vec { + use vectorcraft_color::{Color, Paint}; + let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)); + [ + GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()), + GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)), + GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)), + ] + .into_iter() + .zip(1..) + .map(|(g, id)| GraphicStyle { id, ..g }) + .collect() +} diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs new file mode 100644 index 000000000..0c10efff2 --- /dev/null +++ b/crates/doc/src/guides.rs @@ -0,0 +1,58 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Guide { + /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). + pub vertical: bool, + pub pos: f64, + /// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only + /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artboard: Option, + /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed + /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides + /// from before guides had layers, and from files that don't say), always there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer: Option, +} + +impl Guide { + /// A canvas guide. + pub fn new(vertical: bool, pos: f64) -> Self { + Self { vertical, pos, artboard: None, layer: None } + } + + /// Moved by `d` (a vertical guide across, a horizontal one down). + pub fn moved(&self, d: Vec2) -> Self { + Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GridPrefs { + pub spacing: f64, + pub subdivisions: u32, +} + +impl Default for GridPrefs { + fn default() -> Self { + Self { spacing: 72.0, subdivisions: 8 } + } +} + +/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a +/// new document, or one origin for the whole document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RulerMode { + #[default] + Artboard, + Global, +} + +impl RulerMode { + /// The mode of a document saved before the choice existed: global, as its rulers measured. + pub fn legacy() -> Self { + Self::Global + } +} diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 6f142a584..d93f923d3 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -731,843 +731,4 @@ fn marquee_in_clip(clip: &Node, r: Rect) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Appearance, Node}; - use std::sync::Arc; - use vectorcraft_color::{Color, Paint}; - use vectorcraft_geom::shapes; - - #[test] - fn hits_topmost() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let b = d.alloc_id(); - d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap(); - let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap(); - assert_eq!(h.leaf, b); - assert_eq!(h.kind, HitKind::Fill); - let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!(h.leaf, a); - assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none()); - // Outline mode: interior misses. - assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none()); - assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some()); - } - - #[test] - fn unfilled_interior_misses() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert( - Some(l), - 9, - Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), - ) - .unwrap(); - assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); - assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some()); - } - - /// #1103: a marquee drawn over a background picks what is on it, not the background, whose - /// outline it doesn't touch. - #[test] - fn a_marquee_inside_a_background_leaves_the_background() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (back, small) = (d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap(); - d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); - assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]); - // Reaching the background's edge takes it too. - assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]); - } - - #[test] - fn group_top_object() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - let g = d.alloc_id(); - let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); - d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap(); - let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!(h.leaf, a); - assert_eq!(h.top_object(None), g); - assert_eq!(h.top_object(Some(g)), a); - assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]); - assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]); - assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); - } - - /// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and - /// the art, in that order. - fn half_clipped(d: &mut Document) -> (Node, Node) { - let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); - let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art()); - (mask, art) - } - - /// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away - /// half alone it selects nothing, and around a rectangle drawn beside the group's mask it - /// takes that rectangle alone; reaching into the mask takes the group. The same holds for - /// Group Selection's leaves and in isolation mode. - #[test] - fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - let (mask, art) = half_clipped(&mut d); - let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id()); - let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); - clip.set_clips(true); - d.insert(Some(l), 0, clip).unwrap(); - d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); - // This marquee crosses only the hidden art's top and right edges. - let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); - assert!(marquee(&d, hidden, None, false).is_empty()); - assert!(marquee(&d, hidden, None, true).is_empty()); - assert!(marquee(&d, hidden, Some(g), false).is_empty()); - assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); - // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. - assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); - // This marquee crosses the shown half's top edge and goes on into the hidden half. - let shown = Rect::new(50.0, -10.0, 160.0, 10.0); - assert_eq!(marquee(&d, shown, None, false), vec![g]); - assert!(marquee(&d, shown, None, true).contains(&art_id)); - assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art"); - } - - /// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the - /// clipped art's corner leaves the clip group. An open mask clips to its area closed by a - /// straight line, and a marquee across that line takes the art it reaches inside. - #[test] - fn a_marquee_must_meet_the_clipping_path_itself() { - // A clip group of a mask through `pts` (closed or open) over the art `art`. - let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let mut mask = vectorcraft_geom::BezPath::new(); - mask.move_to(pts[0]); - for &p in &pts[1..] { - mask.line_to(p); - } - if closed { - mask.close_path(); - } - let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default()); - let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art()); - let g = d.alloc_id(); - let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); - clip.set_clips(true); - d.insert(Some(l), 0, clip).unwrap(); - (d, g) - }; - let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0)); - assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty()); - assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point"); - // The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it. - let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0)); - assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]); - } - - /// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the - /// mask shows them, as in a clip group. - #[test] - fn a_marquee_leaves_art_a_layer_clips_away() { - for sublayer in [false, true] { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - let beside = d.alloc_id(); - d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); - let masked = d.alloc_id(); - let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2)); - layer.set_clips(true); - d.insert(sublayer.then_some(l), 9, layer).unwrap(); - let (mask, art) = half_clipped(&mut d); - let (mask_id, art_id) = (mask.id, art.id); - d.insert(Some(masked), 0, mask).unwrap(); - d.insert(Some(masked), 1, art).unwrap(); - assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}"); - assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}"); - assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}"); - } - } - - /// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over - /// the original shape of a face the group erased it selects nothing, with Group Selection's - /// leaves too, and over a face it shows it takes the group. With the original shapes isolated - /// (Construction mode) it takes the original shape it reaches. - #[test] - fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - // Two original squares, 0..100 and 50..150; the group shows only the first one's face. - let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); - let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); - let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); - let b_id = b.id; - let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); - sources.name = Some(crate::shaper::SOURCES.into()); - sources.opacity = 0.0; - let sources_id = sources.id; - let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); - face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); - let face_id = face.id; - let g = d.alloc_id(); - let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]); - group.extras_mut().shaper = Some(Box::default()); - d.insert(Some(l), 0, group).unwrap(); - // This marquee crosses only the right edge of the second square, whose face is erased. - let erased = Rect::new(140.0, 40.0, 160.0, 60.0); - assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses"); - assert!(marquee(&d, erased, None, false).is_empty()); - assert!(marquee(&d, erased, None, true).is_empty()); - assert!(marquee(&d, erased, Some(g), false).is_empty()); - // This marquee crosses the shown face's left edge. - let shown = Rect::new(-10.0, 40.0, 10.0, 60.0); - assert_eq!(marquee(&d, shown, None, false), vec![g]); - assert_eq!(marquee(&d, shown, None, true), vec![face_id]); - assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]); - // Construction mode: the original shapes are the objects. - assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]); - } - - /// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and - /// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing. - /// Across a member's edge it selects the group, and with the leaves that Direct and Group - /// Selection take, or in isolation mode, it selects the member. - #[test] - fn a_marquee_meets_an_intertwine_group_by_its_members() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - // Two squares, 0..100 and 150..250, with a gap between them inside the group's box. - let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); - let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); - a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0)))); - let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art()); - let (a_id, g) = (a.id, d.alloc_id()); - d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap(); - let gap = Rect::new(110.0, 40.0, 140.0, 60.0); - assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses"); - assert!(marquee(&d, gap, None, false).is_empty()); - assert!(marquee(&d, gap, None, true).is_empty()); - assert!(marquee(&d, gap, Some(g), false).is_empty()); - assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill"); - // This marquee crosses the first square's left edge. - let edge = Rect::new(-10.0, 40.0, 10.0, 60.0); - assert_eq!(marquee(&d, edge, None, false), vec![g]); - assert_eq!(marquee(&d, edge, None, true), vec![a_id]); - assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]); - } - - /// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a - /// grid repeat it selects nothing, and across a copy's edge it takes the repeat. - #[test] - fn a_marquee_meets_a_repeat_by_its_copies() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); - // Copies at 0..20, 40..60 and 80..100. - let kind = crate::pattern::RepeatKind::Grid { - h_spacing: 20.0, - v_spacing: 20.0, - rows: 1, - cols: 3, - grid_type: crate::pattern::TileType::Grid, - flip_rows: false, - flip_cols: false, - }; - let r = d.alloc_id(); - d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap(); - let gap = Rect::new(25.0, 5.0, 35.0, 15.0); - assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses"); - assert!(marquee(&d, gap, None, false).is_empty()); - assert!(marquee(&d, gap, None, true).is_empty()); - assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge"); - assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]); - } - - /// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there - /// too: inside the box, away from every key object's and the content's outline, it takes them. - #[test] - fn a_marquee_meets_blends_and_envelopes_by_their_box() { - let mut d = Document::new(300.0, 300.0); - let l = d.layers[0].id; - let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())); - let blend = d.alloc_id(); - let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)]; - d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap(); - let envelope = d.alloc_id(); - let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))]; - let e = Node::new( - envelope, - NodeKind::Envelope { - content, - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), - fidelity: 100.0, - editing: false, - options: Default::default(), - frame: Box::new(vectorcraft_geom::Affine::IDENTITY), - }, - ); - d.insert(Some(l), 1, e).unwrap(); - for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] { - assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id)); - assert_eq!(marquee(&d, r, None, false), vec![id]); - assert_eq!(marquee(&d, r, None, true), vec![id]); - } - } - - /// 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. - fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); - if let NodeKind::Path { clipping, .. } = &mut path.kind { - *clipping = true; - } - let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art()); - if layer { - d.insert(Some(l), 0, path).unwrap(); - d.insert(Some(l), 1, picture).unwrap(); - d.node_mut(l).unwrap().set_clips(true); - (d, l, clip, art) - } else { - d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap(); - (d, g, clip, art) - } - } - - const SELECTING: HitOptions = - HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None }; - - /// For the selection tools a clip group's clipping path is hit on its outline, within the - /// tolerance, before the art it clips and just outside it too; inside, the art is hit. - #[test] - fn a_clipping_path_is_hit_on_its_outline_before_its_art() { - let (d, g, clip, art) = masked_square(false); - for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] { - let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap(); - assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}"); - } - let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap(); - assert_eq!((h.leaf, h.top_object(None)), (art, g)); - // Beyond the tolerance outside it, the clipped art is not there to hit. - assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none()); - } - - /// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling - /// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints - /// nothing there. - #[test] - fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() { - let (d, _, clip, art) = masked_square(false); - let p = Point::new(138.5, 90.0); - assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); - assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); - assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art)); - assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); - // A hidden or locked clipping path is passed over. - for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] { - let mut d = d.clone(); - change(d.node_mut(clip).unwrap()); - assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); - } - } - - /// A layer's clipping mask is hit on its outline too, and Select Behind lists what the - /// repeated hit test finds: the mask on and just outside its edge, then the art inside it; - /// well inside, the art alone (the mask paints nothing there). - #[test] - fn select_behind_takes_a_layer_masks_outline_first() { - let (d, _, clip, art) = masked_square(true); - for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] { - let p = Point::new(x, 90.0); - assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}"); - assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}"); - } - // Without clip_outlines, as before. - assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); - } - - /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the - /// sublayer; hidden, locked and template sublayers block both. - #[test] - fn sublayers_are_not_objects() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let sub = d.alloc_id(); - d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap(); - let inner = d.alloc_id(); - d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap(); - let a = d.alloc_id(); - d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let g = d.alloc_id(); - let b = d.alloc_id(); - let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art()); - d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap(); - let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3)); - let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b)); - let all = Rect::new(-5.0, -5.0, 200.0, 200.0); - assert_eq!(marquee(&d, all, None, false), vec![a, g]); - assert_eq!(d.layer_containing(a), Some(inner)); - assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour"); - assert_eq!(d.selectable_art(), vec![a, g]); - for f in [ - |n: &mut Node| n.visible = false, - |n: &mut Node| n.locked = true, - |n: &mut Node| { - if let NodeKind::Layer { template, .. } = &mut n.kind { - *template = true; - } - }, - ] { - let mut d2 = d.clone(); - f(d2.node_mut(inner).unwrap()); - assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none()); - assert_eq!(marquee(&d2, all, None, false), vec![g]); - assert_eq!(d2.selectable_art(), vec![g]); - } - // A sublayer whose Preview is off is clicked in outline: its fill no longer hits. - if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind { - *preview = false; - } - assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); - assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some()); - } - - /// Object Selection by Path Only: a filled path or compound path hits on its outline only. - #[test] - fn path_only_leaves_out_fills() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (a, c) = (d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default()); - let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero }); - compound.appearance = Appearance::default_art().into(); - d.insert(Some(l), 9, compound).unwrap(); - let path_only = HitOptions { path_only: true, ..Default::default() }; - for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] { - assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits"); - assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit"); - assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does"); - } - } - - /// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so - /// a path under it can be picked through it. - #[test] - fn path_only_picks_paths_through_images() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (path, image) = (d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); - // 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path. - let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0); - let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None }; - d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); - let path_only = HitOptions { path_only: true, ..Default::default() }; - let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf); - let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5)); - assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path"); - assert_eq!(hit(inside, HitOptions::default()), Some(image)); - assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image"); - assert_eq!(hit(inside, path_only), None, "path only: not inside the image"); - assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); - } - - /// Type Object Selection by Path Only: point type is picked on its baseline, area type on its - /// frame and type on a path on its path, no longer anywhere in its bounds. - #[test] - fn type_path_only_picks_type_on_its_path() { - use crate::{CharStyle, TextKind, TextObject}; - let mut d = Document::new(400.0, 400.0); - let l = d.layers[0].id; - let mut add = |t: TextObject| { - let id = d.alloc_id(); - d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); - id - }; - // Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide. - let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() }); - point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); - let point = add(point); - let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default()); - area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; - let area = add(area); - let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default()); - on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None }; - let on_path = add(on_path); - let path_only = HitOptions { type_path_only: true, ..Default::default() }; - let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind)); - // Off: anywhere in the bounds. - assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds))); - assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds))); - assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds))); - // On: the glyphs and the inside of the frame or path no longer pick the type. - assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs"); - assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame"); - assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path"); - // The baseline (within the tolerance, across the layout's width), frame and path do. - assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline"); - assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout"); - assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame"); - assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path"); - } - - /// Type Object Selection by Path Only with the layout's baselines: every line's for point type, - /// area type's besides its frame, a vertical column's centre line, and type in perspective - /// where it is drawn. - #[test] - fn type_path_only_picks_every_baseline() { - use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject}; - use vectorcraft_geom::{Affine, Homography}; - let mut d = Document::new(400.0, 400.0); - let l = d.layers[0].id; - let mut add = |t: TextObject, perspective: Option| { - let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - n.extras_mut().perspective = perspective.map(|a| { - Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) - }); - let id = n.id; - d.insert(Some(l), 9, n).unwrap(); - id - }; - let style = CharStyle { size: 20.0, ..CharStyle::default() }; - // Two lines of point type at (10, 100), 24 pt apart. - let mut point = TextObject::point( - Point::new(10.0, 100.0), - "Hello -world", - style.clone(), - ); - point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0)); - point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))]; - let point = add(point, None); - // Area type: a line inside its frame. - let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone()); - area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; - area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))]; - let area = add(area, None); - // Vertical point type at (300, 200) with no baselines cached: down its first column. - let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone()); - vertical.vertical = true; - vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0)); - let vertical = add(vertical, None); - // Point type drawn 100 to the right of where it is laid out. - let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style); - moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); - let moved = add(moved, Some(Affine::translate((100.0, 0.0)))); - let path_only = HitOptions { type_path_only: true, ..Default::default() }; - let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf); - assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline"); - assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's"); - assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines"); - assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line"); - assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline"); - assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines"); - assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column"); - assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it"); - assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn"); - assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out"); - } - - /// `objects_at`: every object under the point, topmost first; a group counts once. - #[test] - fn objects_at_lists_the_stack() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let rect = - |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); - let a = rect(&mut d, 0.0); - let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0)); - let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id()); - d.insert(Some(l), 0, a).unwrap(); - d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap(); - let far = rect(&mut d, 120.0); - d.insert(Some(l), 9, far).unwrap(); - let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); - assert_eq!(at(30.0, None), vec![g, a_id]); - assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects"); - // Isolated, the art around the group is locked: clicks pass through it (#833). - assert!(at(5.0, Some(g)).is_empty()); - assert!(at(130.0, Some(g)).is_empty()); - assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none()); - assert_eq!(at(5.0, None), vec![a_id]); - assert!(at(90.0, None).is_empty()); - } - - /// Many overlapping paths: Select Behind must list each exactly once, front to back. - /// This is quadratic with repeated full-document hit tests, but linear in tree size with - /// the per-object walk. - #[test] - fn objects_at_large_overlapping_stack() { - let mut d = Document::new(100.0, 100.0); - let layer = d.layers[0].id; - let mut ids = Vec::new(); - for _ in 0..256 { - let id = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - ids.push(id); - } - ids.reverse(); - assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids); - } - - /// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping, - /// invisibility, and path/outline-only settings. - #[test] - fn objects_at_one_pass_matches_repeated_hits() { - let mut d = Document::new(200.0, 200.0); - let layer = d.layers[0].id; - let add = |d: &mut Document, parent, bounds| { - let id = d.alloc_id(); - d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap(); - id - }; - add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0)); - let sub = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap(); - add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0)); - let group = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap(); - add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0)); - add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0)); - let clip = d.alloc_id(); - let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art()); - let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art()); - let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]); - clipping.set_clips(true); - d.insert(Some(layer), usize::MAX, clipping).unwrap(); - for (point, opt) in [ - (Point::new(25.0, 25.0), HitOptions::default()), - (Point::new(3.0, 25.0), HitOptions::default()), - (Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }), - (Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }), - (Point::new(100.0, 100.0), HitOptions::default()), - ] { - assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}"); - } - if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind { - *preview = false; - } - let p = Point::new(25.0, 25.0); - let opt = HitOptions::default(); - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); - d.node_mut(group).unwrap().locked = true; - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); - } - - /// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends - /// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects - /// in the same order as the hit test repeated over what is left. - #[test] - fn objects_at_matches_repeated_hits_on_random_documents() { - let mut seed = 0x9e37_79b9_u32; - let mut next = move |n: u32| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - seed % n - }; - fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) { - for _ in 0..2 + next(4) { - let id = d.alloc_id(); - let (x, y) = (f64::from(next(60)), f64::from(next(60))); - let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50))); - let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() }; - let mut n = match next(if depth < 3 { 8 } else { 4 }) { - 4 | 5 => Node::group(id, Vec::new()), - 6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)), - 7 => { - let mut g = Node::group(id, Vec::new()); - g.set_clips(true); - g - } - _ => Node::path(id, shapes::rectangle(rect), appearance), - }; - n.visible = next(8) != 0; - n.locked = next(8) == 0; - if let NodeKind::Layer { preview, .. } = &mut n.kind { - *preview = next(3) != 0; - } - let container = n.is_container(); - // A sublayer belongs in a layer; in a group it is a group like another to the hit test. - d.insert(Some(parent), usize::MAX, n).unwrap(); - if container { - fill(d, id, depth + 1, next); - } - } - } - for round in 0..200 { - let mut d = Document::new(120.0, 120.0); - let layer = d.layers[0].id; - fill(&mut d, layer, 0, &mut next); - // A clipping mask on the layer, or on a sublayer, now and then. - let sublayers: Vec = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect(); - for id in std::iter::once(layer).chain(sublayers) { - if next(3) == 0 { - d.node_mut(id).unwrap().set_clips(true); - } - } - for _ in 0..12 { - let p = Point::new(f64::from(next(120)), f64::from(next(120))); - let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() }; - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}"); - } - } - } - - /// Select Behind finds a symbol instance by its symbol's art, as a click does: art from - /// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by - /// one instance and 100 pt lower by an instance in a group. - #[test] - 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) }); - 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(); - d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap(); - let opt = HitOptions::default(); - for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] { - let p = Point::new(x, y); - assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}"); - assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}"); - } - } - - /// Edit Contents can expose descendants of a top-level group: preserve the original - /// selection order by falling back to the general hit traversal. - #[test] - fn objects_at_edit_contents_uses_general_order() { - let mut d = Document::new(100.0, 100.0); - let layer = d.layers[0].id; - let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())); - let (a, b) = (d.alloc_id(), d.alloc_id()); - let e = Node::new( - d.alloc_id(), - NodeKind::Envelope { - content: vec![inner(a), inner(b)], - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), - fidelity: 100.0, - editing: true, - options: Default::default(), - frame: Box::new(vectorcraft_geom::Affine::IDENTITY), - }, - ); - d.insert(Some(layer), usize::MAX, e).unwrap(); - let p = Point::new(25.0, 25.0); - let opt = HitOptions::default(); - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); - assert_eq!(objects_at(&d, p, opt, None), vec![b, a]); - } - - #[test] - fn locked_and_hidden_skip() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - d.node_mut(a).unwrap().locked = true; - assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); - d.node_mut(a).unwrap().locked = false; - d.node_mut(l).unwrap().visible = false; - assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); - } - - /// Insert `n` locked at the top of `parent`. - fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { - let id = n.id; - n.locked = true; - d.insert(Some(parent), usize::MAX, n).unwrap(); - id - } - - /// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is - /// listed rather than its contents, and a locked object in an unlocked group is listed itself. - /// Hidden objects, locked layers and the art outside an isolated group are left out. - #[test] - fn locked_objects_at_lists_the_locked_stack() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let rect = - |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); - let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); - let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); - let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); - inner.locked = true; - let inner_id = inner.id; - let mut g = Node::group(locked_group, vec![Arc::new(inner)]); - g.locked = true; - d.insert(Some(l), usize::MAX, g).unwrap(); - let open_group = d.alloc_id(); - let mut child = rect(&mut d, 25.0); - child.locked = true; - let child_id = child.id; - let free = rect(&mut d, 25.0); - d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap(); - let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() }); - assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]); - assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds"); - assert_eq!(at(&d, 5.0, None), [a]); - assert!(at(&d, 150.0, None).is_empty()); - // Isolated, only the group's own objects are looked at. - assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]); - // A hidden object is left out, and nothing on a locked or hidden layer is listed. - d.node_mut(b).unwrap().visible = false; - assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]); - d.node_mut(l).unwrap().locked = true; - assert!(at(&d, 30.0, None).is_empty()); - d.node_mut(l).unwrap().locked = false; - d.node_mut(l).unwrap().visible = false; - assert!(at(&d, 30.0, None).is_empty()); - } - - /// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones. - #[test] - fn locked_objects_at_stops_at_its_limit() { - let mut d = Document::new(100.0, 100.0); - let l = d.layers[0].id; - let ids: Vec = (0..MAX_LOCKED_AT + 5) - .map(|_| { - let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); - insert_locked(&mut d, l, n) - }) - .collect(); - let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default()); - assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::>()); - } - - #[test] - fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let p = Point::new(10.0, 10.0); - for lock in [a, l] { - d.node_mut(lock).unwrap().locked = true; - assert!(hit_test(&d, p, HitOptions::default()).is_none()); - assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a); - assert!(d.node(lock).unwrap().locked); - d.node_mut(lock).unwrap().locked = false; - } - d.node_mut(a).unwrap().visible = false; - assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); - d.node_mut(a).unwrap().visible = true; - d.node_mut(l).unwrap().visible = false; - assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); - } -} +mod tests; diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs new file mode 100644 index 000000000..43db8ee16 --- /dev/null +++ b/crates/doc/src/hit/tests.rs @@ -0,0 +1,835 @@ +use super::*; +use crate::{Appearance, Node}; +use std::sync::Arc; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_geom::shapes; + +#[test] +fn hits_topmost() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap(); + let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, b); + assert_eq!(h.kind, HitKind::Fill); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, a); + assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none()); + // Outline mode: interior misses. + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none()); + assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some()); +} + +#[test] +fn unfilled_interior_misses() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert( + Some(l), + 9, + Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + ) + .unwrap(); + assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); + assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some()); +} + +/// #1103: a marquee drawn over a background picks what is on it, not the background, whose +/// outline it doesn't touch. +#[test] +fn a_marquee_inside_a_background_leaves_the_background() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (back, small) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap(); + d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]); + // Reaching the background's edge takes it too. + assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]); +} + +#[test] +fn group_top_object() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + let g = d.alloc_id(); + let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap(); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, a); + assert_eq!(h.top_object(None), g); + assert_eq!(h.top_object(Some(g)), a); + assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]); + assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]); + assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); +} + +/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and +/// the art, in that order. +fn half_clipped(d: &mut Document) -> (Node, Node) { + let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art()); + (mask, art) +} + +/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away +/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it +/// takes that rectangle alone; reaching into the mask takes the group. The same holds for +/// Group Selection's leaves and in isolation mode. +#[test] +fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id()); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); + // This marquee crosses only the hidden art's top and right edges. + let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); + assert!(marquee(&d, hidden, None, false).is_empty()); + assert!(marquee(&d, hidden, None, true).is_empty()); + assert!(marquee(&d, hidden, Some(g), false).is_empty()); + assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); + // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. + assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); + // This marquee crosses the shown half's top edge and goes on into the hidden half. + let shown = Rect::new(50.0, -10.0, 160.0, 10.0); + assert_eq!(marquee(&d, shown, None, false), vec![g]); + assert!(marquee(&d, shown, None, true).contains(&art_id)); + assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art"); +} + +/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the +/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a +/// straight line, and a marquee across that line takes the art it reaches inside. +#[test] +fn a_marquee_must_meet_the_clipping_path_itself() { + // A clip group of a mask through `pts` (closed or open) over the art `art`. + let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let mut mask = vectorcraft_geom::BezPath::new(); + mask.move_to(pts[0]); + for &p in &pts[1..] { + mask.line_to(p); + } + if closed { + mask.close_path(); + } + let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default()); + let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art()); + let g = d.alloc_id(); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + (d, g) + }; + let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty()); + assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point"); + // The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it. + let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0)); + assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]); +} + +/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the +/// mask shows them, as in a clip group. +#[test] +fn a_marquee_leaves_art_a_layer_clips_away() { + for sublayer in [false, true] { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let beside = d.alloc_id(); + d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); + let masked = d.alloc_id(); + let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2)); + layer.set_clips(true); + d.insert(sublayer.then_some(l), 9, layer).unwrap(); + let (mask, art) = half_clipped(&mut d); + let (mask_id, art_id) = (mask.id, art.id); + d.insert(Some(masked), 0, mask).unwrap(); + d.insert(Some(masked), 1, art).unwrap(); + assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}"); + assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}"); + assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}"); + } +} + +/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over +/// the original shape of a face the group erased it selects nothing, with Group Selection's +/// leaves too, and over a face it shows it takes the group. With the original shapes isolated +/// (Construction mode) it takes the original shape it reaches. +#[test] +fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // Two original squares, 0..100 and 50..150; the group shows only the first one's face. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let b_id = b.id; + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let sources_id = sources.id; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let face_id = face.id; + let g = d.alloc_id(); + let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + d.insert(Some(l), 0, group).unwrap(); + // This marquee crosses only the right edge of the second square, whose face is erased. + let erased = Rect::new(140.0, 40.0, 160.0, 60.0); + assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses"); + assert!(marquee(&d, erased, None, false).is_empty()); + assert!(marquee(&d, erased, None, true).is_empty()); + assert!(marquee(&d, erased, Some(g), false).is_empty()); + // This marquee crosses the shown face's left edge. + let shown = Rect::new(-10.0, 40.0, 10.0, 60.0); + assert_eq!(marquee(&d, shown, None, false), vec![g]); + assert_eq!(marquee(&d, shown, None, true), vec![face_id]); + assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]); + // Construction mode: the original shapes are the objects. + assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]); +} + +/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and +/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing. +/// Across a member's edge it selects the group, and with the leaves that Direct and Group +/// Selection take, or in isolation mode, it selects the member. +#[test] +fn a_marquee_meets_an_intertwine_group_by_its_members() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // Two squares, 0..100 and 150..250, with a gap between them inside the group's box. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0)))); + let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art()); + let (a_id, g) = (a.id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap(); + let gap = Rect::new(110.0, 40.0, 140.0, 60.0); + assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses"); + assert!(marquee(&d, gap, None, false).is_empty()); + assert!(marquee(&d, gap, None, true).is_empty()); + assert!(marquee(&d, gap, Some(g), false).is_empty()); + assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill"); + // This marquee crosses the first square's left edge. + let edge = Rect::new(-10.0, 40.0, 10.0, 60.0); + assert_eq!(marquee(&d, edge, None, false), vec![g]); + assert_eq!(marquee(&d, edge, None, true), vec![a_id]); + assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]); +} + +/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a +/// grid repeat it selects nothing, and across a copy's edge it takes the repeat. +#[test] +fn a_marquee_meets_a_repeat_by_its_copies() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + // Copies at 0..20, 40..60 and 80..100. + let kind = crate::pattern::RepeatKind::Grid { + h_spacing: 20.0, + v_spacing: 20.0, + rows: 1, + cols: 3, + grid_type: crate::pattern::TileType::Grid, + flip_rows: false, + flip_cols: false, + }; + let r = d.alloc_id(); + d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap(); + let gap = Rect::new(25.0, 5.0, 35.0, 15.0); + assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses"); + assert!(marquee(&d, gap, None, false).is_empty()); + assert!(marquee(&d, gap, None, true).is_empty()); + assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge"); + assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]); +} + +/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there +/// too: inside the box, away from every key object's and the content's outline, it takes them. +#[test] +fn a_marquee_meets_blends_and_envelopes_by_their_box() { + let mut d = Document::new(300.0, 300.0); + let l = d.layers[0].id; + let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())); + let blend = d.alloc_id(); + let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)]; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap(); + let envelope = d.alloc_id(); + let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))]; + let e = Node::new( + envelope, + NodeKind::Envelope { + content, + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + fidelity: 100.0, + editing: false, + options: Default::default(), + frame: Box::new(vectorcraft_geom::Affine::IDENTITY), + }, + ); + d.insert(Some(l), 1, e).unwrap(); + for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] { + assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id)); + assert_eq!(marquee(&d, r, None, false), vec![id]); + assert_eq!(marquee(&d, r, None, true), vec![id]); + } +} + +/// 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. +fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art()); + if layer { + d.insert(Some(l), 0, path).unwrap(); + d.insert(Some(l), 1, picture).unwrap(); + d.node_mut(l).unwrap().set_clips(true); + (d, l, clip, art) + } else { + d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap(); + (d, g, clip, art) + } +} + +const SELECTING: HitOptions = + HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None }; + +/// For the selection tools a clip group's clipping path is hit on its outline, within the +/// tolerance, before the art it clips and just outside it too; inside, the art is hit. +#[test] +fn a_clipping_path_is_hit_on_its_outline_before_its_art() { + let (d, g, clip, art) = masked_square(false); + for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] { + let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap(); + assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}"); + } + let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap(); + assert_eq!((h.leaf, h.top_object(None)), (art, g)); + // Beyond the tolerance outside it, the clipped art is not there to hit. + assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none()); +} + +/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling +/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints +/// nothing there. +#[test] +fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() { + let (d, _, clip, art) = masked_square(false); + let p = Point::new(138.5, 90.0); + assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); + // A hidden or locked clipping path is passed over. + for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] { + let mut d = d.clone(); + change(d.node_mut(clip).unwrap()); + assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); + } +} + +/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the +/// repeated hit test finds: the mask on and just outside its edge, then the art inside it; +/// well inside, the art alone (the mask paints nothing there). +#[test] +fn select_behind_takes_a_layer_masks_outline_first() { + let (d, _, clip, art) = masked_square(true); + for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] { + let p = Point::new(x, 90.0); + assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}"); + assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}"); + } + // Without clip_outlines, as before. + assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); +} + +/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the +/// sublayer; hidden, locked and template sublayers block both. +#[test] +fn sublayers_are_not_objects() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap(); + let inner = d.alloc_id(); + d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap(); + let a = d.alloc_id(); + d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let g = d.alloc_id(); + let b = d.alloc_id(); + let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art()); + d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap(); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3)); + let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b)); + let all = Rect::new(-5.0, -5.0, 200.0, 200.0); + assert_eq!(marquee(&d, all, None, false), vec![a, g]); + assert_eq!(d.layer_containing(a), Some(inner)); + assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour"); + assert_eq!(d.selectable_art(), vec![a, g]); + for f in [ + |n: &mut Node| n.visible = false, + |n: &mut Node| n.locked = true, + |n: &mut Node| { + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + }, + ] { + let mut d2 = d.clone(); + f(d2.node_mut(inner).unwrap()); + assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + assert_eq!(marquee(&d2, all, None, false), vec![g]); + assert_eq!(d2.selectable_art(), vec![g]); + } + // A sublayer whose Preview is off is clicked in outline: its fill no longer hits. + if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind { + *preview = false; + } + assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); + assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some()); +} + +/// Object Selection by Path Only: a filled path or compound path hits on its outline only. +#[test] +fn path_only_leaves_out_fills() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (a, c) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default()); + let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance = Appearance::default_art().into(); + d.insert(Some(l), 9, compound).unwrap(); + let path_only = HitOptions { path_only: true, ..Default::default() }; + for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] { + assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits"); + assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit"); + assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does"); + } +} + +/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so +/// a path under it can be picked through it. +#[test] +fn path_only_picks_paths_through_images() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (path, image) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + // 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path. + let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0); + let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + let path_only = HitOptions { path_only: true, ..Default::default() }; + let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf); + let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5)); + assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path"); + assert_eq!(hit(inside, HitOptions::default()), Some(image)); + assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image"); + assert_eq!(hit(inside, path_only), None, "path only: not inside the image"); + assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); +} + +/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its +/// frame and type on a path on its path, no longer anywhere in its bounds. +#[test] +fn type_path_only_picks_type_on_its_path() { + use crate::{CharStyle, TextKind, TextObject}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let mut add = |t: TextObject| { + let id = d.alloc_id(); + d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + id + }; + // Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide. + let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() }); + point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); + let point = add(point); + let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + let area = add(area); + let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default()); + on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None }; + let on_path = add(on_path); + let path_only = HitOptions { type_path_only: true, ..Default::default() }; + let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind)); + // Off: anywhere in the bounds. + assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds))); + assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds))); + assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds))); + // On: the glyphs and the inside of the frame or path no longer pick the type. + assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs"); + assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame"); + assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path"); + // The baseline (within the tolerance, across the layout's width), frame and path do. + assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline"); + assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout"); + assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame"); + assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path"); +} + +/// Type Object Selection by Path Only with the layout's baselines: every line's for point type, +/// area type's besides its frame, a vertical column's centre line, and type in perspective +/// where it is drawn. +#[test] +fn type_path_only_picks_every_baseline() { + use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject}; + use vectorcraft_geom::{Affine, Homography}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let mut add = |t: TextObject, perspective: Option| { + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + n.extras_mut().perspective = perspective.map(|a| { + Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) + }); + let id = n.id; + d.insert(Some(l), 9, n).unwrap(); + id + }; + let style = CharStyle { size: 20.0, ..CharStyle::default() }; + // Two lines of point type at (10, 100), 24 pt apart. + let mut point = TextObject::point( + Point::new(10.0, 100.0), + "Hello +world", + style.clone(), + ); + point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0)); + point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))]; + let point = add(point, None); + // Area type: a line inside its frame. + let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))]; + let area = add(area, None); + // Vertical point type at (300, 200) with no baselines cached: down its first column. + let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone()); + vertical.vertical = true; + vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0)); + let vertical = add(vertical, None); + // Point type drawn 100 to the right of where it is laid out. + let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style); + moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); + let moved = add(moved, Some(Affine::translate((100.0, 0.0)))); + let path_only = HitOptions { type_path_only: true, ..Default::default() }; + let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf); + assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline"); + assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's"); + assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines"); + assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line"); + assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline"); + assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines"); + assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column"); + assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it"); + assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn"); + assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out"); +} + +/// `objects_at`: every object under the point, topmost first; a group counts once. +#[test] +fn objects_at_lists_the_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let a = rect(&mut d, 0.0); + let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0)); + let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id()); + d.insert(Some(l), 0, a).unwrap(); + d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap(); + let far = rect(&mut d, 120.0); + d.insert(Some(l), 9, far).unwrap(); + let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); + assert_eq!(at(30.0, None), vec![g, a_id]); + assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects"); + // Isolated, the art around the group is locked: clicks pass through it (#833). + assert!(at(5.0, Some(g)).is_empty()); + assert!(at(130.0, Some(g)).is_empty()); + assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none()); + assert_eq!(at(5.0, None), vec![a_id]); + assert!(at(90.0, None).is_empty()); +} + +/// Many overlapping paths: Select Behind must list each exactly once, front to back. +/// This is quadratic with repeated full-document hit tests, but linear in tree size with +/// the per-object walk. +#[test] +fn objects_at_large_overlapping_stack() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let mut ids = Vec::new(); + for _ in 0..256 { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + ids.push(id); + } + ids.reverse(); + assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids); +} + +/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping, +/// invisibility, and path/outline-only settings. +#[test] +fn objects_at_one_pass_matches_repeated_hits() { + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let add = |d: &mut Document, parent, bounds| { + let id = d.alloc_id(); + d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap(); + id + }; + add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0)); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap(); + add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0)); + let group = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap(); + add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0)); + add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0)); + let clip = d.alloc_id(); + let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art()); + let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art()); + let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]); + clipping.set_clips(true); + d.insert(Some(layer), usize::MAX, clipping).unwrap(); + for (point, opt) in [ + (Point::new(25.0, 25.0), HitOptions::default()), + (Point::new(3.0, 25.0), HitOptions::default()), + (Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }), + (Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }), + (Point::new(100.0, 100.0), HitOptions::default()), + ] { + assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}"); + } + if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind { + *preview = false; + } + let p = Point::new(25.0, 25.0); + let opt = HitOptions::default(); + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + d.node_mut(group).unwrap().locked = true; + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); +} + +/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends +/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects +/// in the same order as the hit test repeated over what is left. +#[test] +fn objects_at_matches_repeated_hits_on_random_documents() { + let mut seed = 0x9e37_79b9_u32; + let mut next = move |n: u32| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed % n + }; + fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) { + for _ in 0..2 + next(4) { + let id = d.alloc_id(); + let (x, y) = (f64::from(next(60)), f64::from(next(60))); + let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50))); + let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() }; + let mut n = match next(if depth < 3 { 8 } else { 4 }) { + 4 | 5 => Node::group(id, Vec::new()), + 6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)), + 7 => { + let mut g = Node::group(id, Vec::new()); + g.set_clips(true); + g + } + _ => Node::path(id, shapes::rectangle(rect), appearance), + }; + n.visible = next(8) != 0; + n.locked = next(8) == 0; + if let NodeKind::Layer { preview, .. } = &mut n.kind { + *preview = next(3) != 0; + } + let container = n.is_container(); + // A sublayer belongs in a layer; in a group it is a group like another to the hit test. + d.insert(Some(parent), usize::MAX, n).unwrap(); + if container { + fill(d, id, depth + 1, next); + } + } + } + for round in 0..200 { + let mut d = Document::new(120.0, 120.0); + let layer = d.layers[0].id; + fill(&mut d, layer, 0, &mut next); + // A clipping mask on the layer, or on a sublayer, now and then. + let sublayers: Vec = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect(); + for id in std::iter::once(layer).chain(sublayers) { + if next(3) == 0 { + d.node_mut(id).unwrap().set_clips(true); + } + } + for _ in 0..12 { + let p = Point::new(f64::from(next(120)), f64::from(next(120))); + let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() }; + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}"); + } + } +} + +/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from +/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by +/// one instance and 100 pt lower by an instance in a group. +#[test] +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) }); + 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(); + d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap(); + let opt = HitOptions::default(); + for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] { + let p = Point::new(x, y); + assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}"); + assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}"); + } +} + +/// Edit Contents can expose descendants of a top-level group: preserve the original +/// selection order by falling back to the general hit traversal. +#[test] +fn objects_at_edit_contents_uses_general_order() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())); + let (a, b) = (d.alloc_id(), d.alloc_id()); + let e = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![inner(a), inner(b)], + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + fidelity: 100.0, + editing: true, + options: Default::default(), + frame: Box::new(vectorcraft_geom::Affine::IDENTITY), + }, + ); + d.insert(Some(layer), usize::MAX, e).unwrap(); + let p = Point::new(25.0, 25.0); + let opt = HitOptions::default(); + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + assert_eq!(objects_at(&d, p, opt, None), vec![b, a]); +} + +#[test] +fn locked_and_hidden_skip() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + d.node_mut(a).unwrap().locked = true; + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + d.node_mut(a).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); +} + +/// Insert `n` locked at the top of `parent`. +fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { + let id = n.id; + n.locked = true; + d.insert(Some(parent), usize::MAX, n).unwrap(); + id +} + +/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is +/// listed rather than its contents, and a locked object in an unlocked group is listed itself. +/// Hidden objects, locked layers and the art outside an isolated group are left out. +#[test] +fn locked_objects_at_lists_the_locked_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); + let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); + let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); + inner.locked = true; + let inner_id = inner.id; + let mut g = Node::group(locked_group, vec![Arc::new(inner)]); + g.locked = true; + d.insert(Some(l), usize::MAX, g).unwrap(); + let open_group = d.alloc_id(); + let mut child = rect(&mut d, 25.0); + child.locked = true; + let child_id = child.id; + let free = rect(&mut d, 25.0); + d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap(); + let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() }); + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]); + assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds"); + assert_eq!(at(&d, 5.0, None), [a]); + assert!(at(&d, 150.0, None).is_empty()); + // Isolated, only the group's own objects are looked at. + assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]); + // A hidden object is left out, and nothing on a locked or hidden layer is listed. + d.node_mut(b).unwrap().visible = false; + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]); + d.node_mut(l).unwrap().locked = true; + assert!(at(&d, 30.0, None).is_empty()); + d.node_mut(l).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(at(&d, 30.0, None).is_empty()); +} + +/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones. +#[test] +fn locked_objects_at_stops_at_its_limit() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let ids: Vec = (0..MAX_LOCKED_AT + 5) + .map(|_| { + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + insert_locked(&mut d, l, n) + }) + .collect(); + let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default()); + assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::>()); +} + +#[test] +fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let p = Point::new(10.0, 10.0); + for lock in [a, l] { + d.node_mut(lock).unwrap().locked = true; + assert!(hit_test(&d, p, HitOptions::default()).is_none()); + assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a); + assert!(d.node(lock).unwrap().locked); + d.node_mut(lock).unwrap().locked = false; + } + d.node_mut(a).unwrap().visible = false; + assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); + d.node_mut(a).unwrap().visible = true; + d.node_mut(l).unwrap().visible = false; + assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); +} diff --git a/crates/doc/src/id_index.rs b/crates/doc/src/id_index.rs index 582e4c25d..65a6962f1 100644 --- a/crates/doc/src/id_index.rs +++ b/crates/doc/src/id_index.rs @@ -20,8 +20,9 @@ use std::sync::{Arc, RwLock}; use crate::{Node, NodeId}; -/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the -/// default SipHash dominated the cost of building the cache. +/// Hasher for node ids: one multiply, by a number chosen at random once per run (a document's ids +/// are its author's, and a fixed multiplier lets them be picked to collide). The default SipHash +/// dominated the cost of building the cache. #[derive(Default)] pub struct IdHasher(u64); @@ -32,13 +33,19 @@ impl Hasher for IdHasher { } } fn write_u64(&mut self, v: u64) { - self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self.0 = (self.0 ^ v).wrapping_mul(multiplier()); } fn finish(&self) -> u64 { self.0 ^ (self.0 >> 29) } } +/// The odd multiplier of [`IdHasher`], random per run. +fn multiplier() -> u64 { + static KEY: std::sync::OnceLock = std::sync::OnceLock::new(); + *KEY.get_or_init(|| std::hash::BuildHasher::hash_one(&std::hash::RandomState::new(), 0x9e37_79b9_7f4a_7c15_u64) | 1) +} + /// A map keyed by node id with [`IdHasher`]. pub type IdMap = HashMap>; /// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 417c401cf..18873d6d9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -68,23 +68,7 @@ use std::collections::BTreeMap; use std::sync::Arc; /// `skip_serializing_if` predicates: fields equal to their serde default are not written. -pub(crate) mod skip { - pub fn is_default(v: &T) -> bool { - *v == T::default() - } - pub fn is_true(v: &bool) -> bool { - *v - } - pub fn is_one(v: &f32) -> bool { - *v == 1.0 - } - pub fn is_empty_vec(v: &[T]) -> bool { - v.is_empty() - } - pub fn is_empty_map(v: &serde_json::Map) -> bool { - v.is_empty() - } -} +pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ @@ -132,672 +116,26 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Swatch, SwatchGroup}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -/// Coordinates beyond this many points exceed the supported document range. -pub const MAX_COORD: f64 = 4.0e6; +mod artboard; +mod document; +mod document_ext; +mod document_guides; +mod graphic_style; +mod guides; +mod limits; +mod records; +mod units; -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum DocError { - #[error("no such object {0}")] - NoNode(NodeId), - #[error("object {0} cannot have children")] - NotContainer(NodeId), - #[error("{0}")] - Invalid(String), -} - -/// Measurement units (display only; the model is always in points). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Unit { - #[default] - Points, - Picas, - Inches, - Millimeters, - Centimeters, - Pixels, - FeetInches, - Meters, - Yards, - Feet, -} - -impl Unit { - pub const ALL: [Unit; 10] = [ - Unit::Points, - Unit::Picas, - Unit::Inches, - Unit::Millimeters, - Unit::Centimeters, - Unit::Pixels, - Unit::FeetInches, - Unit::Meters, - Unit::Yards, - Unit::Feet, - ]; - /// Points per unit. - pub fn points(self) -> f64 { - match self { - Unit::Points | Unit::Pixels => 1.0, - Unit::Picas => 12.0, - Unit::Inches => 72.0, - Unit::Millimeters => 72.0 / 25.4, - Unit::Centimeters => 72.0 / 2.54, - Unit::Meters => 72.0 / 0.0254, - Unit::Yards => 72.0 * 36.0, - Unit::Feet | Unit::FeetInches => 72.0 * 12.0, - } - } - pub fn suffix(self) -> &'static str { - match self { - Unit::Points => "pt", - Unit::Picas => "p", - Unit::Inches => "in", - Unit::Millimeters => "mm", - Unit::Centimeters => "cm", - Unit::Pixels => "px", - Unit::FeetInches | Unit::Feet => "ft", - Unit::Meters => "m", - Unit::Yards => "yd", - } - } - pub fn label(self) -> &'static str { - match self { - Unit::Points => "Points", - Unit::Picas => "Picas", - Unit::Inches => "Inches", - Unit::Millimeters => "Millimeters", - Unit::Centimeters => "Centimeters", - Unit::Pixels => "Pixels", - Unit::FeetInches => "Feet & Inches", - Unit::Meters => "Meters", - Unit::Yards => "Yards", - Unit::Feet => "Feet", - } - } - /// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of - /// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it. - pub fn ruler_step(self, zoom: f64) -> f64 { - const DECIMAL: [f64; 19] = - [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; - // An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler - // carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a - // sixteenth; zooming in past that leaves the labels where they are and only the ticks - // between them grow denser, down to a hundred and twenty-eighth - // ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which - // the left ruler has room for when it spells one down the ruler a character at a time. - // From an inch up the ladder is the shared one. - const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0]; - let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL }; - steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0) - } - /// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric - /// units, points, pixels and picas are read in tenths. - fn ruler_halves(self) -> bool { - matches!(self, Unit::Inches) - } - /// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of - /// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about - /// as dense as the tenths elsewhere; a tenth of it otherwise. - pub fn ruler_minor(self, step: f64) -> f64 { - if self.ruler_halves() { step / 8.0 } else { step / 10.0 } - } - /// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]). - pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 { - let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points(); - (v / tick).round() * tick - } - pub fn from_pt(self, v: f64) -> f64 { - v / self.points() - } - pub fn to_pt(self, v: f64) -> f64 { - v * self.points() - } - /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). - pub fn format(self, pt: f64) -> String { - format!("{} {}", self.number(pt), self.suffix()) - } - /// [`Unit::format`] without the suffix (`12.5`), for narrow fields. - 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 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. - /// - /// 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 - /// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away - /// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A - /// percentage on its own (`50%`) reads as nothing. - pub fn parse(self, s: &str) -> Option { - parse_measure(s, self).filter(|v| v.is_finite()) - } - /// The value naming this unit in the Units preferences (`points`, `millimeters`, - /// `feetInches`). - pub fn key(self) -> &'static str { - match self { - Unit::Points => "points", - Unit::Picas => "picas", - Unit::Inches => "inches", - Unit::Millimeters => "millimeters", - Unit::Centimeters => "centimeters", - Unit::Pixels => "pixels", - Unit::FeetInches => "feetInches", - Unit::Meters => "meters", - Unit::Yards => "yards", - Unit::Feet => "feet", - } - } - /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and - /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). - pub fn named(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()))) - } - /// 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()) - } -} - -fn parse_measure(s: &str, default: Unit) -> Option { - let s = s.trim(); - // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). - // `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last - // of its operators (left to right). - for ops in [&['+', '-'][..], &['*', '/'][..]] { - // Past the first character, so a leading sign isn't an operator. - let skip = s.chars().next().map_or(0, char::len_utf8); - // A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`). - let is_sign = |i: usize| { - s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-')) - && s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c)) - }; - let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i)); - if let Some(i) = found.map(|(i, _)| i) { - let (l, r) = (&s[..i], &s[i + 1..]); - if l.trim().is_empty() { - continue; - } - let Some(op) = s[i..].chars().next() else { continue }; - return match op { - '+' | '-' => { - let a = parse_measure(l, default)?; - // A percentage added or taken away is a share of everything before it: - // `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt. - let b = match percent(r) { - Some(k) => a * k, - None => parse_measure(r, default)?, - }; - Some(if op == '+' { a + b } else { a - b }) - } - _ => { - // A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm); - // a unit after it is the expression's. - let (k, unit) = match percent(r) { - Some(k) => (k, None), - None => number_unit(r)?, - }; - let a = parse_measure(l, unit.unwrap_or(default))?; - if op == '*' { - Some(a * k) - } else if k == 0.0 { - None - } else { - Some(a / k) - } - } - }; - } - } - // Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`). - let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::().is_ok(); - if let Some((p, pt)) = s.split_once('p') - && !p.is_empty() - && plain(p) - && (pt.is_empty() || plain(pt)) - && !s.ends_with("pt") - && !s.ends_with("px") - { - // The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points). - let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 }; - let picas = p.trim().parse::().ok()?.abs(); - return Some(sign * (picas * 12.0 + pt.trim().parse::().unwrap_or(0.0))); - } - let (v, unit) = number_unit(s)?; - Some(unit.unwrap_or(default).to_pt(v)) -} - -/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). -fn number_unit(s: &str) -> Option<(f64, Option)> { - let s = s.trim(); - // A leading `+` is a sign (`+5`), like `-`. - let sign = usize::from(s.starts_with('+')); - let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign); - let v: f64 = s.get(..num_end)?.trim().parse().ok()?; - let unit = match s.get(num_end..)?.trim() { - "" => None, - "pt" => Some(Unit::Points), - "px" => Some(Unit::Pixels), - "in" | "\"" => Some(Unit::Inches), - "mm" => Some(Unit::Millimeters), - "cm" => Some(Unit::Centimeters), - "m" => Some(Unit::Meters), - "ft" | "'" => Some(Unit::Feet), - "yd" => Some(Unit::Yards), - "p" | "pc" => Some(Unit::Picas), - _ => return None, - }; - Some((v, unit)) -} - -/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`). -fn percent(s: &str) -> Option { - match number_unit(s.trim().strip_suffix('%')?)? { - (p, None) => Some(p / 100.0), - _ => None, - } -} - -/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as -/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing. -pub fn parse_number(s: &str) -> Option { - if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) { - return None; - } - // A leading `+` is a sign (`+5`), as a plain number read it. - let s = s.trim(); - Unit::Points.parse(s.strip_prefix('+').unwrap_or(s)) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum ColorMode { - #[default] - Rgb, - Cmyk, -} - -impl ColorMode { - /// The colour model new colours take in a document of this mode. - pub fn model(self) -> vectorcraft_color::cms::Model { - match self { - ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, - ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Artboard { - pub id: u32, - pub name: String, - /// Artboard rectangle in document points. - pub rect: Rect, - /// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands - /// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be - /// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays - /// selected ([`Document::new_art`]). - #[serde(default, skip_serializing_if = "skip::is_default")] - pub locked: bool, - /// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center. - /// Exports and print leave it out. Always written, as before it could be set. - #[serde(default)] - pub show_center_mark: bool, - /// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side - /// through the center. Exports and print leave them out. Always written, as before it could - /// be set. - #[serde(default)] - pub show_cross_hairs: bool, - /// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and - /// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written - /// only when on, so a document that never shows them saves as before. - #[serde(default, skip_serializing_if = "skip::is_default")] - pub show_video_safe_areas: bool, - /// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its - /// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many - /// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't - /// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is - /// brought into it as it opens. - #[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")] - 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. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, -} - -/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times -/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and -/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare. -pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive = 0.1..=10.0; - -/// How far in from each edge of an artboard the video safe areas lie, as a share of its width -/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the -/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television -/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5). -pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05]; - -fn square_pixels() -> f64 { - 1.0 -} - -fn is_square_pixels(v: &f64) -> bool { - *v == 1.0 -} - -/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that -/// isn't a number (NaN, infinite) reads as square pixels. -fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let v = f64::deserialize(d)?; - let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end()); - Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 }) -} - -impl Artboard { - /// The action-safe and the title-safe area of the artboard, in that order, each inset from its - /// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas). - pub fn video_safe_areas(&self) -> [Rect; 2] { - let r = self.rect; - VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height())) - } -} - -/// A document's locked artboards ([`Document::locked_artboards`]). -pub struct LockedArtboards<'a> { - rects: Vec, - symbols: SymbolBoxes<'a>, - /// The new art, which isn't on them ([`Document::new_art`]). - new_art: Arc>, - /// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no - /// artboard. - pattern_art: std::collections::HashSet, - /// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a - /// group are each answered with one measurement of the group. - held: std::cell::RefCell>, -} - -impl LockedArtboards<'_> { - /// No artboard is locked. - pub fn is_empty(&self) -> bool { - self.rects.is_empty() - } - /// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its - /// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An - /// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or - /// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or - /// new art while it stays selected ([`Document::new_art`]). - pub fn holds(&self, n: &Node) -> bool { - !self.new_art.contains(&n.id) && self.overlaps(n) - } - /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it - /// is new art? - pub fn overlaps(&self, n: &Node) -> bool { - if self.rects.is_empty() - || !n.visible - || n.is_layer() - || matches!(n.kind, NodeKind::Path { guide: true, .. }) - || self.pattern_art.contains(&n.id) - { - return false; - } - if let Some(held) = self.held.borrow().get(&n.id) { - return *held; - } - let held = - n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0)); - self.held.borrow_mut().insert(n.id, held); - held - } - /// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id` - /// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])? - pub fn holds_id(&self, id: NodeId) -> bool { - !self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n)) - } -} - -/// 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. -#[derive(Default)] -pub struct NewArtIds(std::sync::RwLock>>); - -impl NewArtIds { - /// The objects. - pub fn get(&self) -> Arc> { - self.0.read().map(|g| g.clone()).unwrap_or_default() - } - /// Make `ids` the objects. - pub fn set(&self, ids: Arc>) { - if let Ok(mut g) = self.0.write() { - *g = ids; - } - } -} - -impl Clone for NewArtIds { - fn clone(&self) -> Self { - NewArtIds(std::sync::RwLock::new(self.get())) - } -} - -/// Editing state, not document content: two documents are equal whatever new art they name. -impl PartialEq for NewArtIds { - fn eq(&self, _: &Self) -> bool { - true - } -} - -impl std::fmt::Debug for NewArtIds { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("NewArtIds").field(&self.get()).finish() - } -} - -/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct MaskEdit { - pub object: NodeId, - pub layer: NodeId, -} - -/// 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 { - pub name: String, - pub center: Point, - pub zoom: f64, - #[serde(default)] - pub rotation: f64, -} - -/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at -/// the bottom of the Select menu. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SavedSelection { - pub name: String, - pub objects: Vec, -} - -impl SavedSelection { - /// Most saved selections a document keeps (the Select menu lists every one). - pub const MAX: usize = 25; - /// Longest name, in characters. - pub const MAX_NAME: usize = 255; - - /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). - pub fn clean_name(name: &str) -> Option { - let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); - (!name.is_empty()).then_some(name) - } - - /// The first "Selection N" none of `saved` is named. - pub fn default_name(saved: &[SavedSelection]) -> String { - (1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Guide { - /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). - pub vertical: bool, - pub pos: f64, - /// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only - /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artboard: Option, - /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed - /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides - /// from before guides had layers, and from files that don't say), always there. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer: Option, -} - -impl Guide { - /// A canvas guide. - pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None, layer: None } - } - - /// Moved by `d` (a vertical guide across, a horizontal one down). - pub fn moved(&self, d: Vec2) -> Self { - Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GridPrefs { - pub spacing: f64, - pub subdivisions: u32, -} - -impl Default for GridPrefs { - fn default() -> Self { - Self { spacing: 72.0, subdivisions: 8 } - } -} - -/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GraphicStyle { - pub name: String, - pub appearance: Appearance, - /// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles - /// from older files get one when an object is first linked to them). - #[serde(default, skip_serializing_if = "skip::is_default")] - pub id: u32, - #[serde(default = "one", skip_serializing_if = "skip::is_one")] - pub opacity: f32, - #[serde(default, skip_serializing_if = "skip::is_default")] - pub blend: vectorcraft_color::BlendMode, - #[serde(default, skip_serializing_if = "skip::is_default")] - pub isolate: bool, - #[serde(default, skip_serializing_if = "skip::is_default")] - pub knockout: Knockout, - /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style - /// places them on each object's own bounds. Styles saved before this kept document - /// coordinates (false). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub unit_box: bool, -} - -fn one() -> f32 { - 1.0 -} - -/// The name of the style new documents list first (Sort by Name keeps it first). -pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; - -impl GraphicStyle { - /// The box a style's placed gradients are stored relative to. - pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); - - /// A style with `appearance` (its placed gradients, if any, in unit-box space) and default - /// transparency (no id yet). - pub fn new(name: impl Into, appearance: Appearance) -> Self { - Self { - name: name.into(), - appearance, - id: 0, - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Knockout::Neutral, - unit_box: true, - } - } - /// A style capturing `n`'s transparency and `appearance` (in unit-box space). - pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { - Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } - } - /// The style's appearance as object `n` takes it: placed gradients stored in unit-box space - /// land at the same place relative to `n`'s geometric bounds. - pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> { - let placed = self.unit_box && self.appearance.has_placed_gradient(); - match n.geometric_bounds().filter(|_| placed) { - Some(b) => { - let mut ap = self.appearance.clone(); - ap.rebase_gradients(Self::UNIT_BOX, b); - std::borrow::Cow::Owned(ap) - } - None => std::borrow::Cow::Borrowed(&self.appearance), - } - } - /// Give `n` this style's transparency. - pub fn apply_transparency(&self, n: &mut Node) { - (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); - } - /// Does `n` have this style's transparency? - pub fn transparency_matches(&self, n: &Node) -> bool { - (n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout) - } -} - -/// A symbol definition. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Symbol { - pub name: String, - pub art: Arc, -} - -/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ImageBlob { - pub mime: String, - #[serde(skip)] - pub bytes: Arc>, - /// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only - /// linked images show this blob, and what `bytes` hold while the linked file can't be read. - #[serde(skip)] - pub proxy: Option>>, -} - -/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a -/// new document, or one origin for the whole document. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum RulerMode { - #[default] - Artboard, - Global, -} - -impl RulerMode { - /// The mode of a document saved before the choice existed: global, as its rulers measured. - pub fn legacy() -> Self { - Self::Global - } -} +pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +#[cfg(test)] +use document_ext::free_id; +pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle}; +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 units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { @@ -851,7 +189,7 @@ pub struct Document { pub graph_designs: Vec, #[serde(default)] pub guides: Vec, - /// View → New View… (up to 25, like Illustrator). + /// View → New View… (at most [`SavedView::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. @@ -976,27 +314,6 @@ pub struct Document { index: id_index::IdIndex, } -/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it -/// holds. A clone shares the value with the document it was cloned from. -#[derive(Clone, Debug, Default)] -pub struct RetiredArtboardId(Arc); - -impl RetiredArtboardId { - pub fn get(&self) -> u32 { - self.0.load(std::sync::atomic::Ordering::Relaxed) - } - /// Raise the value to `id` when `id` is higher. - pub fn raise(&self, id: u32) { - self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed); - } -} - -impl PartialEq for RetiredArtboardId { - fn eq(&self, _: &Self) -> bool { - true - } -} - fn ppi72() -> f64 { 72.0 } @@ -1017,1820 +334,5 @@ pub fn artboard_holds(rect: Rect, p: Point) -> bool { /// of each listed child in it, in order, with the child's place in the list. type Places = BTreeMap, Vec<(usize, usize)>>; -pub const FORMAT_VERSION: u32 = 1; - -impl Document { - /// Is everything but the layers the same in `other`, as the next version of the document after - /// an edit of its art? The id counter and the id index are left out, and art outside the layers - /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it - /// doesn't change: quick for every frame of an animation. - pub fn same_outside_layers(&self, other: &Self) -> bool { - let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); - let same_pattern = |a: &PatternDef, b: &PatternDef| { - same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } - }; - let Self { - version, - title, - template, - units, - ruler_origin, - rulers, - artboard_ruler_origins, - color_mode, - artboards, - layers: _, - swatches, - swatch_groups, - graphic_styles, - char_styles, - para_styles, - text_threads, - symbols, - graph_designs, - guides, - views, - saved_selections, - variables, - grid, - raster_effects_ppi, - images, - patterns, - pattern_edit, - mask_edit, - next_id: _, - unknown, - page_isolate, - page_knockout, - spot_use_lab, - setup, - paste_remembers_layers, - metadata, - raster_effects, - last_view, - transparency_grid, - layers_open, - color_profiles, - export_settings, - extra, - slices, - slices_clip_to_artboard, - print_setup, - assets, - svg_scripts, - puppet, - new_art, - dimensions, - retired_artboard_id: _, - index: _, - } = self; - *version == other.version - && *title == other.title - && *template == other.template - && *units == other.units - && *ruler_origin == other.ruler_origin - && *rulers == other.rulers - && *artboard_ruler_origins == other.artboard_ruler_origins - && *color_mode == other.color_mode - && *artboards == other.artboards - && *swatches == other.swatches - && *swatch_groups == other.swatch_groups - && *graphic_styles == other.graphic_styles - && *char_styles == other.char_styles - && *para_styles == other.para_styles - && *text_threads == other.text_threads - && symbols.len() == other.symbols.len() - && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && graph_designs.len() == other.graph_designs.len() - && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && *guides == other.guides - && *views == other.views - && *saved_selections == other.saved_selections - && *variables == other.variables - && *grid == other.grid - && *raster_effects_ppi == other.raster_effects_ppi - && *images == other.images - && patterns.len() == other.patterns.len() - && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) - && match (pattern_edit, &other.pattern_edit) { - (Some(a), Some(b)) => { - a.pattern == b.pattern - && a.layer == b.layer - && match (&a.original, &b.original) { - (Some(a), Some(b)) => same_pattern(a, b), - (a, b) => a.is_none() && b.is_none(), - } - } - (a, b) => a.is_none() && b.is_none(), - } - && *mask_edit == other.mask_edit - && *unknown == other.unknown - && *page_isolate == other.page_isolate - && *page_knockout == other.page_knockout - && *spot_use_lab == other.spot_use_lab - && *setup == other.setup - && *paste_remembers_layers == other.paste_remembers_layers - && *metadata == other.metadata - && *raster_effects == other.raster_effects - && *last_view == other.last_view - && *transparency_grid == other.transparency_grid - && *layers_open == other.layers_open - && *color_profiles == other.color_profiles - && *export_settings == other.export_settings - && *extra == other.extra - && *slices == other.slices - && *slices_clip_to_artboard == other.slices_clip_to_artboard - && *print_setup == other.print_setup - && *assets == other.assets - && *svg_scripts == other.svg_scripts - && match (puppet, &other.puppet) { - (Some(a), Some(b)) => Arc::ptr_eq(a, b), - (a, b) => a.is_none() && b.is_none(), - } - && *new_art == other.new_art - && *dimensions == other.dimensions - } - - /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value - /// isn't a number). - pub fn raster_ppi(&self) -> f64 { - Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) - } - - /// The ruler zero point, or zero on both axes when either stored coordinate is invalid. - /// Rulers, coordinate fields and guide snapping use the same supported range. - pub fn ruler_origin_or_zero(&self) -> Point { - let origin = self.ruler_origin; - if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO } - } - - /// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`] - /// with global rulers; with artboard rulers that artboard's top-left corner moved by its own - /// offset (the first artboard's when `index` is out of range, the global zero point when the - /// document has no artboard or the result is out of the supported range). - pub fn ruler_origin_for(&self, index: usize) -> Point { - if self.rulers == RulerMode::Global { - return self.ruler_origin_or_zero(); - } - let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() }; - let corner = Point::new(ab.rect.x0, ab.rect.y0); - let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO); - let origin = corner + offset; - if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { - origin - } else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD { - corner - } else { - Point::ZERO - } - } - - /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). - pub fn new(width: f64, height: f64) -> Self { - Self::new_with_mode(width, height, ColorMode::Rgb) - } - - /// [`Document::new`] in colour `mode`, with that mode's default swatches. - pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { - let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); - let mut d = Self { - version: FORMAT_VERSION, - title: "Untitled-1".into(), - template: false, - units: Unit::Points, - ruler_origin: Point::ZERO, - rulers: RulerMode::Artboard, - artboard_ruler_origins: BTreeMap::new(), - color_mode: mode, - artboards: vec![Artboard { - id: 1, - name: "Artboard 1".into(), - rect: Rect::new(0.0, 0.0, width, height), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }], - layers: vec![], - swatches: swatches.into(), - swatch_groups: swatch_groups.into(), - graphic_styles: default_graphic_styles().into(), - char_styles: Default::default(), - para_styles: Default::default(), - text_threads: vec![], - symbols: vec![], - graph_designs: vec![], - guides: vec![], - views: vec![], - saved_selections: Default::default(), - variables: Default::default(), - grid: GridPrefs::default(), - raster_effects_ppi: 72.0, - images: BTreeMap::new(), - patterns: vec![], - pattern_edit: None, - mask_edit: None, - next_id: 1, - unknown: Default::default(), - page_isolate: false, - page_knockout: false, - spot_use_lab: true, - setup: DocSetup::default(), - paste_remembers_layers: false, - metadata: DocMetadata::default(), - raster_effects: RasterEffectsSettings::default(), - last_view: None, - transparency_grid: false, - layers_open: None, - color_profiles: ColorProfiles::default(), - export_settings: Default::default(), - extra: Default::default(), - slices: vec![], - slices_clip_to_artboard: true, - print_setup: None, - assets: vec![], - svg_scripts: vec![], - puppet: None, - new_art: Default::default(), - dimensions: false, - retired_artboard_id: Default::default(), - index: Default::default(), - }; - let id = d.alloc_id(); - d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); - d - } - - /// Allocate a fresh node id. - pub fn alloc_id(&mut self) -> NodeId { - let id = NodeId(self.next_id); - // Never reached from a counter [`Self::fix_next_id`] has seen (it stays under - // [`MAX_NEXT_ID`]); saturating so that no counter can overflow. - self.next_id = self.next_id.saturating_add(1); - id - } - pub fn peek_next_id(&self) -> u64 { - self.next_id - } - /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). - pub fn fix_next_id(&mut self) { - // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a - // counter up there: new ids start under them and never reach them, where counting on from - // the largest would run out and hand out ids the document already uses. - let mut max = 0; - let mut see = |id: u64| { - if id < MAX_NEXT_ID { - max = max.max(id); - } - }; - for l in &self.layers { - l.walk(&mut |n| see(n.id.0)); - } - self.slices.iter().for_each(|s| see(s.id.0)); - self.assets.iter().for_each(|a| see(a.id)); - let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); - self.next_id = counter.max(max.saturating_add(1)); - } - - /// Find a node anywhere in the tree. - pub fn node(&self, id: NodeId) -> Option<&Node> { - self.index_path(id).and_then(|p| self.node_at(&p)) - } - - /// [`Document::node`] without building the lookup cache when there is none: for a look at a - /// document kept in the undo history ([`Document::drop_lookup_cache`]). - pub fn node_uncached(&self, id: NodeId) -> Option<&Node> { - match self.index.is_built() { - true => self.node(id), - false => self.walk_index_path(id).and_then(|p| self.node_at(&p)), - } - } - - /// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a - /// table per step would add up. Lookups still work; the first one builds the cache again. - pub fn drop_lookup_cache(&self) { - self.index.clear(); - } - - /// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]). - pub fn has_lookup_cache(&self) -> bool { - self.index.is_built() - } - - /// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in - /// tree order where `id` appears. - pub fn index_path(&self, id: NodeId) -> Option> { - // The cached path, while the node there still has the id ([`id_index`]). - let cached = self.index.get(); - match cached.as_ref().and_then(|m| m.get(&id)) { - Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p), - Some(None) => return self.walk_index_path(id), - _ => {} - } - // A stale path (the tree changed), an id the cache doesn't have (deleted or added since), - // or no cache yet: walk, and rebuild now and then. - let found = self.walk_index_path(id); - let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id)); - if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) { - self.index.rebuild(&self.layers); - } - found - } - - /// [`Document::index_path`] by walking the tree. - fn walk_index_path(&self, id: NodeId) -> Option> { - fn find(nodes: &[Arc], id: NodeId, path: &mut Vec) -> bool { - for (i, n) in nodes.iter().enumerate() { - path.push(i); - if n.id == id { - return true; - } - if let Some(ch) = n.children() - && find(ch, id, path) - { - return true; - } - path.pop(); - } - false - } - let mut p = vec![]; - find(&self.layers, id, &mut p).then_some(p) - } - - /// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an - /// edit of many objects looks them all up for the cost of one search instead of one each. Like - /// `index_path`, an id that appears twice maps to its first place in tree order. - pub fn index_paths(&self, ids: impl IntoIterator) -> std::collections::HashMap> { - use std::collections::{HashMap, HashSet}; - fn walk(nodes: &[Arc], want: &HashSet, path: &mut Vec, out: &mut HashMap>) -> bool { - for (i, n) in nodes.iter().enumerate() { - path.push(i); - if want.contains(&n.id) && !out.contains_key(&n.id) { - out.insert(n.id, path.clone()); - if out.len() == want.len() { - return true; - } - } - if let Some(ch) = n.children() - && walk(ch, want, path, out) - { - return true; - } - path.pop(); - } - false - } - let mut want: HashSet = ids.into_iter().collect(); - let mut out = HashMap::with_capacity(want.len()); - // Cached paths that still check out ([`id_index`]); the rest take one walk. For a good - // share of the document, one walk is quicker than checking each cached path. - if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) { - want.retain(|id| match cache.get(id) { - Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => { - out.insert(*id, p); - false - } - _ => true, - }); - } - if !want.is_empty() { - let mut rest = HashMap::with_capacity(want.len()); - walk(&self.layers, &want, &mut Vec::new(), &mut rest); - out.extend(rest); - } - out - } - - /// The node at index path `path` (as [`Document::index_path`] gives it). - pub fn node_at(&self, path: &[usize]) -> Option<&Node> { - let (first, rest) = path.split_first()?; - let mut cur: &Node = self.layers.get(*first)?; - for &i in rest { - cur = cur.children()?.get(i)?; - } - Some(cur) - } - - /// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as - /// [`Document::node_mut`] does. - pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> { - let (first, rest) = path.split_first()?; - let mut cur: &mut Arc = self.layers.get_mut(*first)?; - for &i in rest { - cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?; - } - Some(Arc::make_mut(cur)) - } - - /// [`Document::ancestry`] of the node at index path `path`. - pub fn ancestry_at(&self, path: &[usize]) -> Option> { - let mut out = Vec::with_capacity(path.len()); - let mut nodes = &self.layers; - for &i in path { - let n = nodes.get(i)?; - out.push(n.id); - if let Some(ch) = n.children() { - nodes = ch; - } - } - Some(out) - } - - /// Ids from the top-level layer down to `id` inclusive. - pub fn ancestry(&self, id: NodeId) -> Option> { - let path = self.index_path(id)?; - let mut out = Vec::with_capacity(path.len()); - let mut nodes = &self.layers; - for &i in &path { - let n = &nodes[i]; - out.push(n.id); - if let Some(ch) = n.children() { - nodes = ch; - } - } - Some(out) - } - - pub fn parent_of(&self, id: NodeId) -> Option { - let a = self.ancestry(id)?; - (a.len() >= 2).then(|| a[a.len() - 2]) - } - - /// The top-level layer that contains `id`. - pub fn layer_of(&self, id: NodeId) -> Option { - self.ancestry(id).and_then(|a| a.first().copied()) - } - - /// Mutable access to a node; clones the Arc path (copy-on-write). - pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { - let path = self.index_path(id)?; - let mut cur: &mut Arc = &mut self.layers[path[0]]; - for &i in &path[1..] { - cur = &mut Arc::make_mut(cur).children_mut()?[i]; - } - Some(Arc::make_mut(cur)) - } - - /// Children list of a container (or the top-level layer list for `None`). - pub fn children_mut(&mut self, parent: Option) -> Result<&mut Vec>, DocError> { - match parent { - None => Ok(&mut self.layers), - Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)), - } - } - pub fn children(&self, parent: Option) -> Option<&Vec>> { - match parent { - None => Some(&self.layers), - Some(p) => self.node(p)?.children(), - } - } - - /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). - pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { - let id = node.id; - let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); - ch.insert(i, Arc::new(node)); - Ok(id) - } - - /// Remove a node and return it. - pub fn remove(&mut self, id: NodeId) -> Result, DocError> { - let parent = self.parent_of(id); - let path = self.index_path(id).ok_or(DocError::NoNode(id))?; - let idx = *path.last().ok_or(DocError::NoNode(id))?; - let ch = self.children_mut(parent)?; - if idx >= ch.len() { - return Err(DocError::NoNode(id)); - } - Ok(ch.remove(idx)) - } - - /// Move a node to `parent` at `index` (index measured after removal). - pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { - if let Some(p) = parent - && self.ancestry(p).is_some_and(|a| a.contains(&id)) - { - return Err(DocError::Invalid("cannot move an object into itself".into())); - } - let n = self.remove(id)?; - let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); - ch.insert(i, n); - Ok(()) - } - - /// Position of `id` within its parent: (parent, index, sibling count). - pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { - let parent = self.parent_of(id); - let idx = *self.index_path(id)?.last()?; - let n = self.children(parent)?.len(); - Some((parent, idx, n)) - } - - /// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the - /// children of each parent. Each id is listed once and none lies inside another listed node, as - /// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies - /// inside another listed node, it fails with `NoNode` and leaves the document as it was. - pub fn remove_many(&mut self, ids: &[NodeId]) -> Result>, DocError> { - let places = self.places(ids)?; - let removed = self.take(places, ids.len())?; - self.index.clear(); - Ok(removed) - } - - /// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make` - /// builds from them (in the order of `ids`) in the parent of the last of them, at that node's - /// index less the number of listed nodes below it there. Returns the new node's id. - pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec>) -> Node) -> Result { - let places = self.places(ids)?; - let last = ids.len().checked_sub(1); - let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i))); - let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) }; - // The parent and each node it is in move down by the listed nodes before them. - let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index)); - let to: Vec = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect(); - let at = i.saturating_sub(below(&up, i)); - let node = make(self.take(places, ids.len())?); - let id = node.id; - let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?; - let at = at.min(children.len()); - children.insert(at, Arc::new(node)); - self.index.clear(); - Ok(id) - } - - /// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in - /// list order, in one pass over the children of each parent. Each anchor is listed once and none - /// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside - /// another listed anchor, it fails with `NoNode` and leaves the document as it was. - pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec)>, replace: bool) -> Result<(), DocError> { - let anchors: Vec = items.iter().map(|(id, _)| *id).collect(); - let places = self.places(&anchors)?; - let mut lists: Vec> = items.into_iter().map(|(_, nodes)| nodes).collect(); - self.edit_children(places, |k, anchor, children| { - if !replace { - children.push(anchor); - } - let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default(); - children.extend(nodes.into_iter().map(Arc::new)); - })?; - self.index.clear(); - Ok(()) - } - - /// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree, - /// is listed twice or lies inside another listed node. - fn places(&self, ids: &[NodeId]) -> Result { - let paths = self.index_paths(ids.iter().copied()); - let mut places = Places::new(); - for (k, id) in ids.iter().enumerate() { - let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?; - match places.get_mut(up) { - Some(kids) => kids.push((i, k)), - None => { - places.insert(up.to_vec(), vec![(i, k)]); - } - } - } - for kids in places.values_mut() { - kids.sort_unstable(); - } - // An id listed twice has one path, so its parent lists the same child twice. A listed node - // lies inside another when its parent's path, or a part of it from the top, is the path of a - // listed node. - let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1); - let listed = |path: &[usize]| { - path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok())) - }; - let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l]))); - let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k); - if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) { - return Err(DocError::NoNode(*id)); - } - Ok(places) - } - - /// Take the nodes `places` lists out of their parents and return them in the order of the list, - /// which is `len` long. - fn take(&mut self, places: Places, len: usize) -> Result>, DocError> { - let mut taken = vec![None; len]; - self.edit_children(places, |k, node, _| { - if let Some(t) = taken.get_mut(k) { - *t = Some(node); - } - })?; - Ok(taken.into_iter().flatten().collect()) - } - - /// Rebuild the children of each parent in `places` once, the parents later in tree order - /// first. `each` gets each listed child, its place in the list and the parent's new children, - /// onto which it pushes what takes the child's place. - fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc, &mut Vec>)) -> Result<(), DocError> { - for (up, kids) in places.into_iter().rev() { - let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?; - let old = std::mem::take(children); - children.reserve(old.len()); - let mut kids = kids.into_iter().peekable(); - for (i, node) in old.into_iter().enumerate() { - match kids.next_if(|(j, _)| *j == i) { - Some((_, k)) => each(k, node, children), - None => children.push(node), - } - } - } - Ok(()) - } - - /// The children of the node at index path `path`, or the top-level layers for an empty path. - fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec>> { - match path { - [] => Some(&mut self.layers), - _ => self.node_mut_at(path)?.children_mut(), - } - } - - /// Default target layer: the topmost visible, unlocked layer. - pub fn default_layer(&self) -> Option { - self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id) - } - - /// Add a new top-level layer above all others. - pub fn add_layer(&mut self, name: Option<&str>) -> NodeId { - let id = self.alloc_id(); - let n = self.layers.len(); - let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name()); - self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)))); - id - } - /// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the - /// numbering: it starts after the number of layers there are and skips any layer's name. - pub fn next_layer_name(&self) -> String { - let (mut count, mut taken) = (0, std::collections::HashSet::new()); - self.walk(|n| { - if n.is_layer() { - count += 1; - taken.extend(n.name.as_deref()); - } - }); - (count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default() - } - - /// Visit every node depth first in paint order (bottom to top). - pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) { - for l in &self.layers { - l.walk(&mut f); - } - } - pub fn node_count(&self) -> usize { - self.layers.iter().map(|l| l.count()).sum() - } - /// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard - /// ([`Document::on_locked_artboard`])? - pub fn is_editable(&self, id: NodeId) -> bool { - self.is_editable_with(&self.locked_artboards(), id) - } - /// [`Document::is_editable`] with the locked artboards `locks` of this document, measured - /// once for many ids. - pub fn is_editable_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.visible && !n.locked))) && !locks.holds_id(id) - } - /// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once - /// for all of them, a group once for all its members. - pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ { - let locks = self.locked_artboards(); - move |id| self.is_editable_with(&locks, id) - } - /// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]). - pub fn locked_artboards(&self) -> LockedArtboards<'_> { - self.locked_artboards_except(usize::MAX) - } - /// [`Document::locked_artboards`] leaving out artboard `index`. - pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> { - // Art flush with an edge from outside can overlap it by a rounding error, as in - // `art_bounds_on` (#1295). - let rects: Vec = - self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect(); - // The objects of pattern editing mode's layer, a sublayer's objects included. - fn objects(n: &Node, out: &mut std::collections::HashSet) { - for c in n.children().into_iter().flatten() { - if c.is_layer() { - objects(c, out); - } else { - out.insert(c.id); - } - } - } - let mut pattern_art = std::collections::HashSet::new(); - if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) { - objects(layer, &mut pattern_art); - } - LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() } - } - /// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none. - pub fn on_locked_artboard(&self, id: NodeId) -> bool { - self.locked_artboards().holds_id(id) - } - /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else - /// the group or other object around it right inside a layer. None for a layer or sublayer. - pub fn layer_object(&self, id: NodeId) -> Option<&Node> { - self.layer_object_at(&self.index_path(id)?) - } - /// [`Document::layer_object`] for the node at index path `path`. - pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> { - // Down the path to the first node that isn't a layer. - let mut nodes = &self.layers; - for &i in path { - let n = nodes.get(i)?; - if !n.is_layer() { - return Some(n.as_ref()); - } - nodes = n.children()?; - } - None - } - pub fn is_visible(&self, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) - } - /// The innermost layer or sublayer containing `id` (`id` itself when it is a layer). - pub fn layer_containing(&self, id: NodeId) -> Option { - let a = self.ancestry(id)?; - a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer)) - } - /// Every art object the Selection tool can reach (Select All): the visible, unlocked objects - /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first), - /// except those on a locked artboard. - pub fn selectable_art(&self) -> Vec { - let locks = self.locked_artboards(); - self.layers - .iter() - .filter(|l| l.visible && !l.locked && !l.is_template()) - .flat_map(|l| l.layer_art(true)) - .filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n))) - .collect() - } - /// Colour of the innermost layer or sublayer containing `id` (selection highlight colour). - pub fn layer_color(&self, id: NodeId) -> [u8; 3] { - let l = self.layer_containing(id).and_then(|l| self.node(l)); - match l.map(|n| &n.kind) { - Some(NodeKind::Layer { color, .. }) => color.rgb(), - _ => LAYER_COLORS[0].1, - } - } - /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol - /// instances measured by their art ([`Node::geometric_bounds_with`]). - pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { - let symbols = self.symbol_boxes(); - ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| { - vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) }) - }) - } - /// Visual bounds of all art, with symbol instances measured by their art. - pub fn art_bounds(&self) -> Option { - let symbols = self.symbol_boxes(); - self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols))) - } - /// Artboard index containing point `p` (topmost = last), its edges included - /// ([`Document::artboard_holding`]). - pub fn artboard_at(&self, p: Point) -> Option { - self.artboard_holding(p, true) - } - /// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it - /// inside or on its left or top edge, else the first (the last) that holds it by - /// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an - /// edge. On an edge two artboards share, the artboard to the right of it or below it holds the - /// point, except at the end of the edge that lies on that artboard's bottom or right edge, such - /// as the point where both artboards' bottom edges meet; there the first (the last) holds it. - pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option { - let pick = |on: &dyn Fn(Rect) -> bool| { - let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect)); - let mut all = 0..self.artboards.len(); - if topmost { all.rfind(on) } else { all.find(on) } - }; - pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p))) - } - /// The art that moves with an artboard at `rect`: top-level objects (children of layers and - /// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and - /// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display › - /// Move Locked and Hidden Artwork with Artboard). - pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec { - // Art drawn flush with the artboard can end up a rounding error outside it (a background - // 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible. - let rect = rect.inflate(1e-6, 1e-6); - fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec) { - for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { - if c.is_layer() { - collect(c, s, rect, all, out); - } else if let Some(b) = c.geometric_bounds_with(s) - // Inside it, edges included: art flush with the right or bottom edge is on it too - // (`Rect::contains` leaves those out, #890). - && b.x0 >= rect.x0 - && b.y0 >= rect.y0 - && b.x1 <= rect.x1 - && b.y1 <= rect.y1 - { - out.push(c.id); - } - } - } - let symbols = self.symbol_boxes(); - let mut art = vec![]; - for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) { - collect(l, &symbols, rect, locked_and_hidden, &mut art); - } - art - } - /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] - /// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as - /// locked art does. - pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec { - let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] }; - let mut art = self.art_on_artboard(rect, locked_and_hidden); - let locks = self.locked_artboards_except(index); - if !locked_and_hidden && !locks.is_empty() { - art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n))); - } - art - } - /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its - /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose - /// artboard is gone. - pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> { - let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; - Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) - } - /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. - fn guide_layer(&self, g: &Guide) -> Option { - g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) - } - /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is - /// visible? A guide whose layer is gone shows as one on no layer. - pub fn guide_shown(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_visible(l)) - } - /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? - pub fn guide_editable(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_editable(l)) - } - /// Put the ruler guides on no layer this document has (from files saved before guides had - /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. - pub fn rehome_guides(&mut self, layer: NodeId) -> usize { - let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); - let mut n = 0; - for (g, lost) in self.guides.iter_mut().zip(lost) { - if lost { - g.layer = Some(layer); - n += 1; - } - } - n - } - /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that - /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. - pub fn adopt_guides(&mut self) -> usize { - self.default_layer().map_or(0, |home| self.rehome_guides(home)) - } - /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in - /// step. Guides on no layer stay. Returns how many went. - pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { - let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); - self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) - } - /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made - /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies - /// the objects on it. Returns how many. - pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { - let layers = |n: &Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if c.is_layer() { - out.push(c.id); - } - }); - out - }; - let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); - let copies: Vec = self - .guides - .iter() - .filter_map(|g| { - let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; - Some(Guide { layer: Some(to), ..g.clone() }) - }) - .collect(); - let n = copies.len(); - self.guides.extend(copies); - n - } - /// The ruler guides on layer `layer` (not its sublayers'), by index. - pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { - self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) - } - /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? - pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { - let along = if g.vertical { p.y } else { p.x }; - self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) - } - /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their - /// place among the rest (the selected art stays selected). Returns how many went. - pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { - let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); - // Where each kept guide ends up. - let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); - for k in &kept { - to.push(k.then_some(n)); - n += usize::from(*k); - } - sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); - sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); - let before = self.guides.len(); - let mut flags = kept.into_iter(); - self.guides.retain(|_| flags.next().unwrap_or(true)); - before - self.guides.len() - } - /// Move the guides of artboard `id` by `d` (along with their artboard). - pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - *g = g.moved(d); - } - } - /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork - /// with Artboard). - pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; - } - } - /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their - /// artboard). - pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { - let copies: Vec = - self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); - self.guides.extend(copies); - } - /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of - /// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts - /// as given out, so an artboard made after an undo does not take the id of one the undo took - /// away. - pub fn next_artboard_id(&self) -> u32 { - let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()])); - self.retired_artboard_id.raise(id); - id - } - /// Record that the artboard with `id` was deleted: no later artboard takes its id. - pub fn retire_artboard_id(&self, id: u32) { - self.retired_artboard_id.raise(id); - } - pub fn pattern(&self, name: &str) -> Option<&PatternDef> { - self.patterns.iter().find(|p| p.name == name) - } - pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { - self.patterns.iter_mut().find(|p| p.name == name) - } - /// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken - /// in ([`Self::adopt_pixels`]). - pub fn reid(&mut self, node: &Node) -> Node { - let mut n = node.clone(); - n.id = self.alloc_id(); - if let NodeKind::Image(im) = &mut n.kind { - self.adopt_image_pixels(im); - } - // A copied annotation must never bind its source ids to unrelated art in a different - // document. Keep the measured geometry; new measurements can be linked after pasting. - if let Some(e) = n.extras_if_any() - && let Some(dimension) = &mut e.dimension - { - dimension.source = None; - } - // A copy of a threaded frame is in no thread, so its first paragraph starts in it and its - // layout caches are dropped. A caller that keeps the copy as an object fills them again. - if let NodeKind::Text(t) = &mut n.kind - && t.first_para_continues - { - t.first_para_continues = false; - t.cached_bounds = None; - t.cached_baselines.clear(); - } - let mut renamed = std::collections::HashMap::new(); - if let Some(ch) = n.children_mut() { - let old: Vec> = std::mem::take(ch); - *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); - renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); - } - // An Objects on Path group names its path and its objects by id: the copy names the copies. - if let Some(e) = n.extras_if_any() - && let Some(on_path) = &mut e.objects_on_path - { - on_path.rename(&renamed); - } - n - } -} - -impl Document { - /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. - pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> { - im.pixels.as_deref().or_else(|| self.images.get(&im.key)) - } - - /// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art - /// made outside a document) into [`Self::images`]: under the image's key, or a free one when - /// the key shows other pixels. - pub fn adopt_pixels(&mut self, n: &mut Node) { - // Only what holds such pixels is copied out of the art it shares. - fn holds(n: &Node) -> bool { - matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some()) - || n.extras().mask.as_ref().is_some_and(|m| holds(&m.art)) - || n.children().into_iter().flatten().any(|c| holds(c)) - } - if let NodeKind::Image(im) = &mut n.kind { - self.adopt_image_pixels(im); - } - if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) - && holds(&m.art) - { - self.adopt_pixels(Arc::make_mut(&mut m.art)); - } - for c in n.children_mut().into_iter().flatten() { - if holds(c) { - self.adopt_pixels(Arc::make_mut(c)); - } - } - } - - fn adopt_image_pixels(&mut self, im: &mut ImageObject) { - let Some(blob) = im.pixels.take() else { return }; - let mut key = im.key.clone(); - let mut i = 1; - while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) { - i += 1; - key = format!("{}-{i}", im.key); - } - self.images.entry(key.clone()).or_insert_with(|| (*blob).clone()); - im.key = key; - } - - /// Saved selections as a file gives them, made safe to list and recall: at most - /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document - /// has, each once. (An id it doesn't have would select whatever object takes that id later.) - pub fn tidy_saved_selections(&mut self) { - if self.saved_selections.is_empty() { - return; - } - let mut ids = std::collections::HashSet::new(); - self.walk(|n| { - ids.insert(n.id); - }); - let mut kept: Vec = vec![]; - for s in std::mem::take(&mut self.saved_selections) { - if kept.len() >= SavedSelection::MAX { - break; - } - let Some(name) = SavedSelection::clean_name(&s.name) else { continue }; - if kept.iter().any(|k| k.name == name) { - continue; - } - let mut seen = std::collections::HashSet::new(); - let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect(); - kept.push(SavedSelection { name, objects }); - } - self.saved_selections = kept.into(); - } - - /// 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). - pub fn drop_edit_modes(&mut self) { - if let Some(me) = self.mask_edit.take() { - self.layers.retain(|l| l.id != me.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.) - pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> { - if self.mask_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(area) = n.extras_if_any().and_then(|e| e.intertwine.as_deref_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. -impl Document { - /// Index of the graphic style named `name`. - pub fn graphic_style_index(&self, name: &str) -> Option { - self.graphic_styles.iter().position(|g| g.name == name) - } - /// The graphic style named `name`. - pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> { - self.graphic_styles.iter().find(|g| g.name == name) - } - /// The graphic style with id `id` (0 never matches). - pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> { - self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id) - } - /// An id no graphic style has. - pub fn next_graphic_style_id(&self) -> u32 { - free_id(self.graphic_styles.iter().map(|g| g.id)) - } - /// The id of graphic style `index`, assigning one if it has none yet. - pub fn graphic_style_id(&mut self, index: usize) -> u32 { - if self.graphic_styles[index].id == 0 { - self.graphic_styles[index].id = self.next_graphic_style_id(); - } - self.graphic_styles[index].id - } - /// The default name of a new graphic style: the first free "Graphic Style N", N counting on - /// from the number of styles. - pub fn new_graphic_style_name(&self) -> String { - (self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default() - } - /// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name. - pub fn unique_symbol_name(&self, base: &str) -> String { - let taken = |n: &str| self.symbols.iter().any(|s| s.name == n); - if !taken(base) { - return base.to_string(); - } - (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) - } - - /// Symbol Options › Registration for `name` (default: centre). - pub fn symbol_registration(&self, name: &str) -> SymbolRegistration { - self.unknown - .get(SYMBOL_REGISTRATION) - .and_then(|m| m.get(name)) - .and_then(|v| v.as_u64()) - .and_then(SymbolRegistration::from_u64) - .unwrap_or_default() - } - - /// Set or clear (when centre) the registration point stored for `name`. - pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) { - let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({})); - let Some(o) = m.as_object_mut() else { return }; - if reg.is_center() { - o.remove(name); - if o.is_empty() { - self.unknown.remove(SYMBOL_REGISTRATION); - } - } else { - o.insert(name.into(), serde_json::json!(reg.0)); - } - } - - /// Move the stored registration when a symbol is renamed. - pub fn rename_symbol_registration(&mut self, from: &str, to: &str) { - if from == to { - return; - } - let reg = self.symbol_registration(from); - self.set_symbol_registration(from, SymbolRegistration::CENTER); - self.set_symbol_registration(to, reg); - } - - /// Symbol Options › Export Type for `name` (default: Movie Clip). - pub fn symbol_export_type(&self, name: &str) -> SymbolExportType { - self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default() - } - - /// Set or clear (when Movie Clip, the default) the export type stored for `name`. - pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) { - if kind.is_default() { - self.clear_symbol_export_type(name); - return; - } - let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({})); - let Some(o) = m.as_object_mut() else { return }; - o.insert(name.into(), serde_json::json!(kind.id())); - } - - /// Drop any stored export type for `name` (Movie Clip default). - pub fn clear_symbol_export_type(&mut self, name: &str) { - let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return }; - let Some(o) = m.as_object_mut() else { return }; - o.remove(name); - if o.is_empty() { - self.unknown.remove(SYMBOL_EXPORT_TYPE); - } - } - - /// Move the stored export type when a symbol is renamed. - pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) { - if from == to { - return; - } - let kind = self.symbol_export_type(from); - self.clear_symbol_export_type(from); - self.set_symbol_export_type(to, kind); - } - - /// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default). - pub fn symbol_nine_slice(&self, name: &str) -> Option { - self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value) - } - - /// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`. - pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option) { - let Some(g) = guides else { - let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return }; - o.remove(name); - if o.is_empty() { - self.unknown.remove(SYMBOL_NINE_SLICE); - } - return; - }; - let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({})); - if let Some(o) = m.as_object_mut() { - o.insert(name.into(), g.to_value()); - } - } - - /// Move the stored 9-slice guides when a symbol is renamed. - pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) { - if from == to { - return; - } - let g = self.symbol_nine_slice(from); - self.set_symbol_nine_slice(from, None); - self.set_symbol_nine_slice(to, g); - } -} - -fn default_graphic_styles() -> Vec { - use vectorcraft_color::{Color, Paint}; - let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)); - [ - GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()), - GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), - GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)), - GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)), - ] - .into_iter() - .zip(1..) - .map(|(g, id)| GraphicStyle { id, ..g }) - .collect() -} - -/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so -/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). -const MAX_NEXT_ID: u64 = 1 << 62; - -/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there -/// is (a damaged file), the smallest one free. -fn free_id(used: impl Iterator) -> u32 { - let mut used: Vec = used.collect(); - if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) { - return next; - } - used.sort_unstable(); - used.dedup(); - // Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them. - let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count(); - u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1)) -} - -impl Document { - /// Make the next id allocated at least `next`: ids handed out outside the layer tree (such - /// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see. - pub fn reserve_ids(&mut self, next: u64) { - self.next_id = self.next_id.max(next.min(MAX_NEXT_ID)); - } -} - #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_geom::shapes; - - /// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch - /// are binary fractions now, and the minor ticks between them stay binary too; every other - /// unit is still read in tenths. - #[test] - fn an_inch_ruler_steps_in_binary_fractions() { - let step = |u: Unit, zoom: f64| u.ruler_step(zoom); - // Every inch step at or below an inch is 1/2^k, never a tenth or a fifth. - for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] { - let s = step(Unit::Inches, zoom); - if s <= 1.0 { - let k = s.recip(); - assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k"); - assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k"); - } - // A label step always leaves at least 50 px, which is what the ladder is picked for. - assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}"); - } - // The steps the report named: tenths became quarters, eighths and sixteenths. - assert_eq!(step(Unit::Inches, 5.0), 0.25); - assert_eq!(step(Unit::Inches, 10.0), 0.125); - assert_eq!(step(Unit::Inches, 20.0), 0.0625); - // An inch and wider is unchanged. - assert_eq!(step(Unit::Inches, 1.0), 1.0); - // Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in - // further only adds ticks. A label therefore never needs more than the six characters of - // "0.0625", which the left ruler has room for when it spells one out a character per line. - for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] { - assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}"); - } - // The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and - // 128ths the report expects to appear unlabelled when zoomed in close. - assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0); - // The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere. - assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125); - assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5); - assert_eq!(Unit::Points.ruler_minor(100.0), 10.0); - // Other units keep the decimal ladder, so a tenth and a fifth still appear. - assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); - } - - fn doc_with_rects() -> (Document, NodeId, NodeId) { - let mut d = Document::new(612.0, 792.0); - let layer = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); - let b = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap(); - (d, a, b) - } - - #[test] - fn new_document_has_layer_and_artboard() { - let d = Document::new(612.0, 792.0); - assert_eq!(d.layers.len(), 1); - assert_eq!(d.layers[0].display_name(), "Layer 1"); - assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0)); - } - - #[test] - fn art_on_artboard_takes_unlocked_objects_wholly_inside() { - let (mut d, a, b) = doc_with_rects(); - let wide = Rect::new(-1.0, -1.0, 40.0, 15.0); - assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]); - assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside"); - // Flush with the artboard's edges counts as inside: the right and bottom ones too (#890). - for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] { - assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}"); - } - d.node_mut(a).unwrap().locked = true; - assert_eq!(d.art_on_artboard(wide, false), vec![b]); - // Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on. - d.node_mut(b).unwrap().visible = false; - assert!(d.art_on_artboard(wide, false).is_empty()); - assert_eq!(d.art_on_artboard(wide, true), vec![a, b]); - Arc::make_mut(&mut d.layers[0]).locked = true; - assert!(d.art_on_artboard(wide, false).is_empty()); - assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too"); - } - - #[test] - fn art_on_artboard_tolerates_roundoff_at_each_edge() { - let (d, a, _) = doc_with_rects(); - let roundoff = 1e-12; - for board in [ - Rect::new(roundoff, 0.0, 10.0, 10.0), - Rect::new(0.0, roundoff, 10.0, 10.0), - Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0), - Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff), - ] { - assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}"); - } - // A real overhang remains outside. - for board in - [Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)] - { - assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}"); - } - } - - /// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one - /// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with - /// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one. - /// Unlocking the artboard gives the art back. - #[test] - fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { - use crate::hit::{HitOptions, hit_test, marquee, objects_at}; - let (mut d, a, b) = doc_with_rects(); - let layer = d.layers[0].id; - d.artboards.push(Artboard { - id: 2, - name: "Artboard 2".into(), - rect: Rect::new(700.0, 0.0, 900.0, 200.0), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - let rect = |d: &mut Document, r: Rect, appearance: Appearance| { - let id = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap(); - id - }; - let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art()); - // A group with one member on the artboard and one right of it. - let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let members = [(m1, 750.0), (m2, 950.0)] - .map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art()))); - d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap(); - // Unstroked and flush with the artboard's left edge, from outside. - let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default()); - let opt = HitOptions::default(); - let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None)); - let all = Rect::new(-10.0, -10.0, 1000.0, 800.0); - assert_eq!(top(&d, 720.0, 20.0), Some(c)); - d.artboards[1].locked = true; - for id in [c, g, m1, m2] { - assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard"); - } - for id in [a, b, e, layer] { - assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't"); - } - assert_eq!(d.selectable_art(), [a, b, e]); - assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c"); - assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard"); - let isolated = HitOptions { scope: Some(g), ..opt }; - assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock"); - assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty()); - assert_eq!(marquee(&d, all, None, false), [a, b, e]); - assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too"); - // Deselected in one pass, with the key object and the anchors. - let mut sel = Selection::default(); - sel.set([a, c]); - sel.key = Some(c); - sel.anchors.insert(c, [(0, 0)].into()); - sel.deselect_on_locked_artboards(&d); - assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true)); - // A targeted layer stays targeted, with its art off the locked artboard selected. - sel.set_target(&d, layer); - sel.deselect_on_locked_artboards(&d); - assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer))); - // Shift-clicking the layer's target circle removes its art off the locked artboard, and - // a second Shift-click adds it again. - sel.toggle_target(&d, layer); - sel.deselect_on_locked_artboards(&d); - assert_eq!((sel.objects.clone(), sel.target), (vec![], None)); - sel.toggle_target(&d, layer); - sel.deselect_on_locked_artboards(&d); - assert_eq!(sel.objects, [a, b, e]); - // A sublayer with art on both artboards: its art off the locked artboard can still be clicked. - let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); - for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] { - d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())) - .unwrap(); - } - assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None)); - assert!(!d.on_locked_artboard(sub)); - let mut both = Selection::default(); - both.set([s1, s2]); - both.deselect_on_locked_artboards(&d); - assert_eq!(both.objects, [s1]); - // New art on it is editable and can be clicked while it is new art. - d.new_art.set(Arc::new([c].into())); - assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art"); - assert_eq!(top(&d, 720.0, 20.0), Some(c)); - d.new_art.set(Default::default()); - d.artboards[1].locked = false; - assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]); - assert_eq!(top(&d, 955.0, 55.0), Some(g)); - assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]); - } - - #[test] - 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) }); - 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. - assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]); - } - - #[test] - fn artboard_at_counts_the_right_and_bottom_edges() { - // Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it. - let mut d = Document::new(100.0, 100.0); - for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] { - let mut a = d.artboards[0].clone(); - a.rect = rect; - d.artboards.push(a); - } - let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false)); - // Each artboard's own right and bottom edges, edges with roundoff, and points beyond them. - assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge"); - assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge"); - assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner"); - assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge"); - assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge"); - assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge"); - assert_eq!(held(&d, 200.001, 50.0), (None, None)); - assert_eq!(held(&d, 50.0, 200.001), (None, None)); - // On an edge two artboards share, the artboard to the right of it or below it holds the - // point, in either list order, except at the end of the edge that lies on that artboard's - // bottom or right edge. - assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1))); - assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2))); - // The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top - // right corner: the first holds the point, or the last when `topmost`. - assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner"); - d.artboards.swap(0, 1); - assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0))); - // Where two artboards overlap, the topmost holds the point, or the first one when not - // `topmost`. - let mut inner = d.artboards[1].clone(); - inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0); - d.artboards.push(inner); - assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1))); - assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3"); - } - - #[test] - fn find_and_paths() { - let (d, a, b) = doc_with_rects(); - assert_eq!(d.index_path(b), Some(vec![0, 1])); - assert_eq!(d.parent_of(a), Some(d.layers[0].id)); - assert_eq!(d.layer_of(b), Some(d.layers[0].id)); - assert!(d.node(NodeId(999)).is_none()); - assert_eq!(d.node_count(), 3); - } - - #[test] - fn copy_on_write_shares_untouched() { - let (d, a, b) = doc_with_rects(); - let mut e = d.clone(); - e.node_mut(a).unwrap().name = Some("A".into()); - // b's Arc is shared between both versions; a's is not. - let lb = &d.layers[0].children().unwrap()[1]; - let eb = &e.layers[0].children().unwrap()[1]; - assert!(Arc::ptr_eq(lb, eb)); - assert_eq!(d.node(a).unwrap().name, None); - assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A")); - let _ = b; - } - - #[test] - fn move_and_remove() { - let (mut d, a, b) = doc_with_rects(); - let layer = d.layers[0].id; - d.move_node(a, Some(layer), usize::MAX).unwrap(); - assert_eq!(d.index_path(a), Some(vec![0, 1])); - assert_eq!(d.index_path(b), Some(vec![0, 0])); - let r = d.remove(a).unwrap(); - assert_eq!(r.id, a); - assert!(d.node(a).is_none()); - assert!(d.move_node(layer, Some(layer), 0).is_err()); - } - - #[test] - fn layers_and_names() { - let mut d = Document::new(100.0, 100.0); - let l2 = d.add_layer(None); - assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2"); - assert_eq!(d.default_layer(), Some(l2)); - assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1); - } - - /// #1718: a file whose ids or counters are at their type's largest value still hands out ids - /// nothing in it uses (and never 0), instead of overflowing. - #[test] - fn exhausted_ids_in_a_file_dont_run_out() { - let (d, a, b) = doc_with_rects(); - let mut v = serde_json::to_value(&d).unwrap(); - v["next_id"] = serde_json::json!(u64::MAX); - // Object `a` gets the largest id there is. - fn renumber(v: &mut serde_json::Value, from: u64) { - match v { - serde_json::Value::Object(o) => { - if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) { - o.insert("id".into(), serde_json::json!(u64::MAX)); - } - o.values_mut().for_each(|c| renumber(c, from)); - } - serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)), - _ => {} - } - } - renumber(&mut v["layers"], a.0); - let mut d: Document = serde_json::from_value(v).unwrap(); - assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept"); - d.fix_next_id(); - let fresh: Vec = (0..3).map(|_| d.alloc_id()).collect(); - assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}"); - assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]); - // A counter left at the end by other means stays there rather than wrapping to 0. - d.next_id = u64::MAX; - assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX)); - d.reserve_ids(u64::MAX); - // Artboards and graphic styles: one past the largest, else the smallest id free. - assert_eq!(free_id([1, 2, 7].into_iter()), 8); - assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3); - assert_eq!(free_id([u32::MAX].into_iter()), 1); - d.artboards[0].id = u32::MAX; - assert_eq!(d.next_artboard_id(), 1); - } - - #[test] - fn serde_roundtrip() { - let (d, _, _) = doc_with_rects(); - let s = serde_json::to_string(&d).unwrap(); - let back: Document = serde_json::from_str(&s).unwrap(); - assert_eq!(back.node_count(), d.node_count()); - assert_eq!(back.peek_next_id(), d.peek_next_id()); - } - - #[test] - fn reid_gives_fresh_ids() { - let (mut d, a, b) = doc_with_rects(); - let g = d.alloc_id(); - let na = (*d.remove(a).unwrap()).clone(); - let nb = (*d.remove(b).unwrap()).clone(); - let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]); - let copy = d.reid(&group); - assert_ne!(copy.id, g); - let ids: Vec = copy.children().unwrap().iter().map(|c| c.id).collect(); - assert!(!ids.contains(&a) && !ids.contains(&b)); - } - - #[test] - fn setup_round_trips_and_old_files_load() { - let (mut d, _, _) = doc_with_rects(); - // A document without a setup writes no `setup` key (old readers and files stay unchanged). - assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\"")); - d.setup.bleed = [9.0, 9.0, 0.0, 18.0]; - d.setup.grid_size = GridSize::Large; - d.setup.typographers_quotes = false; - d.setup.quotes = setup::language_quotes("German").unwrap(); - d.setup.flattener_preset = Some("High Resolution".into()); - d.setup.background = Background::White; - d.setup.export_text = ExportText::Appearance; - let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); - assert_eq!(back.setup, d.setup); - // Files written before Document Setup existed load with the defaults. - let mut v = serde_json::to_value(&d).unwrap(); - v.as_object_mut().unwrap().remove("setup"); - let old: Document = serde_json::from_value(v).unwrap(); - assert_eq!(old.setup, DocSetup::default()); - } - - #[test] - fn char_position_round_trips() { - let st = CharStyle { - position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), - small_caps: Some(75.0), - ..CharStyle::default() - }; - let v = serde_json::to_value(&st).unwrap(); - assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0})); - assert_eq!(serde_json::from_value::(v).unwrap(), st); - let plain = serde_json::to_value(CharStyle::default()).unwrap(); - assert!(plain.get("position").is_none() && plain.get("small_caps").is_none()); - let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0); - assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); - } - - #[test] - fn units() { - assert_eq!(Unit::Inches.parse("1"), Some(72.0)); - assert_eq!(Unit::Points.parse("1in"), Some(72.0)); - assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0)); - assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); - assert_eq!(Unit::Points.parse("10+5"), Some(15.0)); - assert_eq!(Unit::Points.parse("100/4"), Some(25.0)); - assert_eq!(Unit::Points.parse("-5"), Some(-5.0)); - assert_eq!(Unit::Points.parse("abc"), None); - assert_eq!(Unit::Points.format(12.5), "12.5 pt"); - assert_eq!(Unit::Inches.format(144.0), "2 in"); - } - - #[test] - fn negative_picas_and_points_sign_applies_to_whole_value() { - assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0)); - assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0)); - assert_eq!(Unit::Points.parse("-2p"), Some(-24.0)); - assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); - } - - #[test] - fn pica_form_rejects_exponent_notation() { - for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] { - assert_eq!(Unit::Points.parse(s), None, "{s}"); - } - } -} - -#[cfg(test)] -mod tests_lookups; -#[cfg(test)] -mod tests_slices; -#[cfg(test)] -mod tests_units; +mod tests; diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs new file mode 100644 index 000000000..e030f3d71 --- /dev/null +++ b/crates/doc/src/limits.rs @@ -0,0 +1,20 @@ +use super::*; + +/// Coordinates beyond this many points exceed the supported document range. +pub const MAX_COORD: f64 = 4.0e6; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum DocError { + #[error("no such object {0}")] + NoNode(NodeId), + #[error("object {0} cannot have children")] + NotContainer(NodeId), + #[error("{0}")] + Invalid(String), +} + +pub const FORMAT_VERSION: u32 = 1; + +/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so +/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). +pub(crate) const MAX_NEXT_ID: u64 = 1 << 62; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 28e0a659c..04bad52c6 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -139,7 +139,17 @@ fn proxy_png(bytes: &[u8]) -> Option> { if w.max(h) <= PROXY_SIZE { return None; } - let small = image::load_from_memory(bytes).ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE); + // The proxy decodes within the project's raster cap, counted as RGBA like every other + // decode path (#3597): the header alone decides whether the pixels could ever fit, and the + // decoder gets the same budget instead of image's own default. + if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 { + return None; + } + let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(crate::MAX_RASTER_ALLOC); + reader.limits(limits); + let small = reader.decode().ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE); let mut png = Vec::new(); small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; Some(png) @@ -432,6 +442,44 @@ mod tests { assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable"); } + /// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts. + fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for &b in data { + crc ^= u32::from(b); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xEDB8_8320 & u32::from(crc & 1 != 0).wrapping_neg()); + } + } + !crc + } + + /// A PNG header and nothing else, declaring `w` × `h` 8-bit gray pixels. + fn png_header_declaring(w: u32, h: u32) -> Vec { + let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let mut chunk = Vec::new(); + chunk.extend(b"IHDR"); + chunk.extend(w.to_be_bytes()); + chunk.extend(h.to_be_bytes()); + chunk.extend([8, 0, 0, 0, 0]); + let crc = crc32(&chunk); + out.extend(13u32.to_be_bytes()); + out.extend(&chunk); + out.extend(crc.to_be_bytes()); + out + } + + /// A linked image past the raster cap gets no proxy: the header alone decides (#3597). + #[test] + fn an_image_past_the_raster_cap_gets_no_proxy() { + // 65536 × 65536 gray pixels are 4 GiB as RGBA, past the 2 GiB cap on every target. + let huge = png_header_declaring(1 << 16, 1 << 16); + assert!(proxy_png(&huge).is_none()); + // A header within the cap but with no image data decodes to no preview, as before. + let truncated = png_header_declaring(600, 300); + assert!(proxy_png(&truncated).is_none()); + } + #[test] fn only_blobs_no_embedded_image_shows_are_linked_only() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index f84d0f865..650361374 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -34,299 +34,6 @@ fn fifty() -> f64 { 50.0 } -// ===================================================================================== -// Data -// ===================================================================================== - -/// Blend Options → Spacing. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BlendSpacing { - /// Step count chosen from the colour difference (or the distance when colours match). - #[default] - SmoothColor, - /// Specified number of steps between each pair of key objects (1–1000). - Steps(u32), - /// Specified distance (pt) between the steps. - Distance(f64), -} - -/// Blend Options → Orientation. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BlendOrientation { - #[default] - AlignToPage, - AlignToPath, -} - -/// Distribution of blend steps, as offered by the Blend panel's Type menu. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BlendEaseType { - #[default] - In, - Out, - InOut, - OutIn, -} - -impl BlendEaseType { - pub fn id(self) -> &'static str { - match self { - Self::In => "easeIn", - Self::Out => "easeOut", - Self::InOut => "easeInOut", - Self::OutIn => "easeOutIn", - } - } -} - -/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BlendEase { - #[serde(default)] - pub kind: BlendEaseType, - #[serde(default, deserialize_with = "blend_ramp")] - pub ramp: f64, -} - -impl BlendEase { - pub fn is_linear(&self) -> bool { - self.ramp <= 0.0 - } - - /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. - pub fn is_default(&self) -> bool { - self.kind == BlendEaseType::In && self.ramp == 0.0 - } - - /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help - /// describes the four distributions, not their numeric curves; these curves are original. - pub fn apply(&self, u: f64) -> f64 { - let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; - let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; - let eased = match self.kind { - BlendEaseType::In => u * u, - BlendEaseType::Out => u * (2.0 - u), - BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), - BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), - BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), - }; - u + r * (eased - u) - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BlendSpec { - #[serde(default)] - pub spacing: BlendSpacing, - #[serde(default)] - pub orientation: BlendOrientation, - /// Acceleration of the steps' shapes and positions along the spine between two keys. - #[serde(default, skip_serializing_if = "BlendEase::is_default")] - pub ease: BlendEase, - /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. - #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] - pub color_shift: f64, - /// Replaced spine. `None` = the straight lines between the key objects' centres. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spine: Option, - /// The anchor of the spine's first subpath each key object sits on (a spine edited on the - /// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread - /// evenly along the spine by arc length (Replace Spine). - #[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")] - pub key_anchors: Vec, - /// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks - /// on anchor points); `None`: chosen so closed shapes don't twist. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub starts: Vec>, -} - -impl BlendSpec { - /// The start anchor of key `i` (see [`Self::starts`]). - pub fn start(&self, i: usize) -> Option { - self.starts.get(i).copied().flatten().map(|a| a as usize) - } -} - -/// Blend Options set with no blend selected: what new blends start with. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BlendDefaults { - #[serde(default)] - pub spacing: BlendSpacing, - #[serde(default)] - pub orientation: BlendOrientation, -} - -/// How an envelope distorts its content. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum EnvelopeKind { - /// One of the 15 warp styles (`arc`, `arcLower`, … — see [`WarpStyle::from_id`]); bend and - /// distortions in percent (-100..100). - Warp { - style: String, - #[serde(default)] - bend: f64, - #[serde(default)] - h: f64, - #[serde(default)] - v: f64, - #[serde(default = "yes")] - horizontal: bool, - }, - /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. - Mesh { - rows: u32, - cols: u32, - points: Vec, - /// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`. - /// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed; - /// editing the mesh's points or handles on the canvas fills them in. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - handles: Vec<[Vec2; 4]>, - }, - /// The content's bounding box maps onto the outline of this path. - TopObject { path: PathData }, -} - -/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PreserveShape { - #[default] - ClippingMask, - Transparency, -} - -impl PreserveShape { - pub fn id(self) -> &'static str { - match self { - Self::ClippingMask => "clippingMask", - Self::Transparency => "transparency", - } - } - pub fn parse(s: &str) -> Option { - match s { - "clippingMask" => Some(Self::ClippingMask), - "transparency" => Some(Self::Transparency), - _ => None, - } - } -} - -/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options -/// existed do (their appearance applies after the distortion); new envelopes start from -/// [`EnvelopeOptions::NEW`], as in the reference app. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct EnvelopeOptions { - /// Rasters: Anti-Alias. - pub anti_alias: bool, - /// Rasters: Preserve Shape Using. - pub preserve_shape: PreserveShape, - /// Distort Appearance: strokes and live effects are applied before the distortion, so they - /// bend with the art (off: they apply to the distorted paths). - pub distort_appearance: bool, - /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. - pub distort_linear_gradients: bool, - /// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art. - pub distort_pattern_fills: bool, -} - -impl Default for EnvelopeOptions { - fn default() -> Self { - Self { - anti_alias: true, - preserve_shape: PreserveShape::ClippingMask, - distort_appearance: false, - distort_linear_gradients: false, - distort_pattern_fills: false, - } - } -} - -impl EnvelopeOptions { - /// The options a new envelope gets (the reference app's defaults). - pub const NEW: Self = Self { - anti_alias: true, - preserve_shape: PreserveShape::ClippingMask, - distort_appearance: true, - distort_linear_gradients: false, - distort_pattern_fills: false, - }; - /// Do gradients bend with the art? - pub fn gradients(&self) -> bool { - self.distort_appearance && self.distort_linear_gradients - } - /// Do pattern tiles bend with the art? - pub fn patterns(&self) -> bool { - self.distort_appearance && self.distort_pattern_fills - } -} - -/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. -pub const H_RIGHT: usize = 0; -pub const H_LEFT: usize = 1; -pub const H_DOWN: usize = 2; -pub const H_UP: usize = 3; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct MeshPoint { - pub p: Point, - pub color: Color, - #[serde(default = "one")] - pub opacity: f32, - /// Tangent handles as offsets from `p` (see [`H_RIGHT`] …). - #[serde(default)] - pub handles: [Vec2; 4], -} - -/// A gradient mesh: `(rows+1)×(cols+1)` points, row-major. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GradientMesh { - pub rows: u32, - pub cols: u32, - pub points: Vec, -} - -/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and -/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads -/// meet without a step. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct MeshQuad { - pub pts: [Point; 4], - pub color: Color, - pub opacity: f32, - pub corners: [(Color, f32); 4], -} - -/// Create Gradient Mesh → Appearance. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum MeshAppearance { - #[default] - Flat, - ToCenter, - ToEdge, -} - -impl MeshAppearance { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "flat" => Some(Self::Flat), - "center" | "tocenter" | "to center" => Some(Self::ToCenter), - "edge" | "toedge" | "to edge" => Some(Self::ToEdge), - _ => None, - } - } -} - -/// Default envelope fidelity. -pub fn default_fidelity() -> f64 { - fifty() -} - // ===================================================================================== // Blends (evaluation in `crate::blend`) // ===================================================================================== @@ -336,1637 +43,28 @@ pub use crate::blend::{ steps_knockout_shows, }; -// ===================================================================================== -// Warp maps (shared with vectorcraft-effects' Warp effects) -// ===================================================================================== - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WarpStyle { - Arc, - ArcLower, - ArcUpper, - Arch, - Bulge, - ShellLower, - ShellUpper, - Flag, - Wave, - Fish, - Rise, - Fisheye, - Inflate, - Squeeze, - Twist, -} - -impl WarpStyle { - /// From the id suffix (`arc`, `arcLower`, …). - pub fn from_id(s: &str) -> Option { - use WarpStyle::*; - Some(match s { - "arc" => Arc, - "arcLower" => ArcLower, - "arcUpper" => ArcUpper, - "arch" => Arch, - "bulge" => Bulge, - "shellLower" => ShellLower, - "shellUpper" => ShellUpper, - "flag" => Flag, - "wave" => Wave, - "fish" => Fish, - "rise" => Rise, - "fisheye" => Fisheye, - "inflate" => Inflate, - "squeeze" => Squeeze, - "twist" => Twist, - _ => return None, - }) - } -} - -/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1); -/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real -/// proportions). -pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) { - use WarpStyle::*; - let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom - let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides - let (mut x2, mut y2) = match style { - Arc => { - if b.abs() < 1e-6 { - (x, y) - } else { - // Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in - // the box's real proportions (lengths in half-widths, so y counts `aspect` each): - // the edges are concentric arcs, and one that would pass the centre stops there - // instead of folding over (#1578). - let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; - let sweep = b.abs() * PI; - let r0 = 2.0 / sweep; - let yy = if b > 0.0 { y } else { -y } * k; - let a = x / r0; - let r = (r0 - yy).max(0.0); - let (s, c) = a.sin_cos(); - let (nx, ny) = (r * s, (r0 - r * c) / k); - (nx, if b > 0.0 { ny } else { -ny }) - } - } - ArcLower => (x, y + b * par * t), - ArcUpper => (x, y - b * par * (1.0 - t)), - Arch => (x, y - b * par), - Bulge => (x, y + b * par * y), - ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t), - ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)), - Flag => (x, y + 0.5 * b * (PI * x).sin()), - Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)), - Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())), - Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b), - Fisheye => { - let r2 = x * x + y * y; - let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 }; - (x * s, y * s) - } - Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)), - Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)), - Twist => { - let r = (x * x + y * y).sqrt(); - let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0); - let (s, c) = a.sin_cos(); - (x * c - y * s, x * s + y * c) - } - }; - // Perspective-like distortion: horizontal narrows one side, vertical one end. - if dh != 0.0 { - y2 *= (1.0 + dh * x2 * 0.5).max(0.0); - } - if dv != 0.0 { - x2 *= (1.0 + dv * y2 * 0.5).max(0.0); - } - (x2, y2) -} - -/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. -fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { - let c = sp.segment(i); - if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c } -} - -fn poly_len(c: &CubicBez) -> f64 { - c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) -} - -/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point). -fn cumulative_lengths(pts: &[Point]) -> Vec { - let mut total = 0.0; - let mut cum = Vec::with_capacity(pts.len()); - cum.push(0.0); - for w in pts.windows(2) { - total += w[0].distance(w[1]); - cum.push(total); - } - cum -} - -/// Most pieces [`map_nonlinear`] splits one segment into. -const MAX_PIECES: usize = 64; - -/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`. -pub fn pieces(len: f64, piece: f64, most: usize) -> usize { - ((len / piece).ceil() as usize).clamp(1, most.max(1)) -} - -/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so -/// that the pieces past each segment's first number at most `room`: `piece` when they already do, -/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path, -/// so pieces of the total length over `room` fit; with no room every segment stays one piece. -pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 { - let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add); - if added <= room { - return piece; - } - if room == 0 { - return f64::INFINITY; - } - // A NaN length is always one piece. - piece.max(lens.iter().filter(|len| !len.is_nan()).sum::() / room as f64) -} - -/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces -/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which -/// approximates the image curve to O(h²). Closedness and subpath structure are preserved. -pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData { - map_nonlinear_within(path, max_piece, usize::MAX, f) -} - -/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more: -/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]), -/// a coarser approximation of the image curve. Geometry effects map within their anchor budget. -pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData { - let mut max_piece = max_piece.max(1e-3); - // Each segment adds one anchor per piece past its first. - let room = max.saturating_sub(path.anchor_count()); - let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add); - if segs.saturating_mul(MAX_PIECES - 1) > room { - let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect(); - max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room); - } - let mut subs = Vec::with_capacity(path.subpaths.len()); - for sp in &path.subpaths { - let n = sp.anchors.len(); - if n == 0 { - continue; - } - let segs = sp.segment_count(); - let mut res: Vec = Vec::new(); - let mut pending_in: Option = None; - for i in 0..n { - let a = &sp.anchors[i]; - let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in)); - res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind }); - if i < segs { - let c = seg_cubic(sp, i); - let k = pieces(poly_len(&c), max_piece, MAX_PIECES); - for j in 0..k { - let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64)); - if let Some(last) = res.last_mut() { - last.h_out = f(sub.p1); - } - if j + 1 < k { - res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() }); - } else { - pending_in = Some(f(sub.p2)); - } - } - } - } - if let Some(h) = pending_in - && sp.closed - && let Some(first) = res.first_mut() - { - first.h_in = h; - } - let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect(); - subs.push(SubPath::new(anchors, sp.closed)); - } - PathData::new(subs) -} - -// ===================================================================================== -// Coons patches from outlines -// ===================================================================================== - -fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { - let mut bp = BezPath::new(); - sp.to_bezpath_into(&mut bp); - let mut pts: Vec = Vec::new(); - vectorcraft_geom::kurbo::flatten(bp.iter(), tol, |el| match el { - vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => pts.push(p), - _ => {} - }); - pts -} - -fn resample(poly: &[Point], m: usize) -> Vec { - if poly.is_empty() { - return vec![Point::ZERO; m]; - } - if poly.len() == 1 { - return vec![poly[0]; m]; - } - let cum = cumulative_lengths(poly); - let total = cum.last().copied().unwrap_or(0.0); - let mut out = Vec::with_capacity(m); - let mut i = 0; - for k in 0..m { - let s = if m > 1 { total * k as f64 / (m - 1) as f64 } else { 0.0 }; - while i + 2 < cum.len() && cum[i + 1] < s { - i += 1; - } - let seg = (cum[i + 1] - cum[i]).max(1e-12); - out.push(poly[i].lerp(poly[i + 1], ((s - cum[i]) / seg).clamp(0.0, 1.0))); - } - out -} - -const SIDE: usize = 65; - -/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to -/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding -/// box corners. -#[derive(Clone, Debug)] -pub struct Coons { - top: Vec, - right: Vec, - bottom: Vec, - left: Vec, -} - -impl Coons { - /// A rectangle. - pub fn rect(r: Rect) -> Self { - let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::>(); - let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); - Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) } - } - - pub fn from_path(path: &PathData) -> Option { - // The largest subpath is the outline. - let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; - let r = path.bounds()?; - let tol = (r.width() + r.height()).max(1e-6) / 2000.0; - let mut poly = flatten_subpath(sp, tol); - if poly.len() > 1 - && let (Some(a), Some(b)) = (poly.first(), poly.last()) - && a.distance(*b) < 1e-9 - { - poly.pop(); - } - if poly.len() < 3 { - return Some(Self::rect(r)); - } - // Orient clockwise in y-down (TL → TR → BR → BL). - let area: f64 = (0..poly.len()) - .map(|i| { - let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); - p.x * q.y - q.x * p.y - }) - .sum(); - if area < 0.0 { - poly.reverse(); - } - let nearest = |c: Point| (0..poly.len()).min_by(|i, j| poly[*i].distance_squared(c).total_cmp(&poly[*j].distance_squared(c))).unwrap_or(0); - let tl = nearest(Point::new(r.x0, r.y0)); - let tr = nearest(Point::new(r.x1, r.y0)); - let br = nearest(Point::new(r.x1, r.y1)); - let bl = nearest(Point::new(r.x0, r.y1)); - let n = poly.len(); - let arc = |i: usize, j: usize| -> Vec { - let mut v = vec![poly[i]]; - let mut k = i; - while k != j { - k = (k + 1) % n; - v.push(poly[k]); - } - v - }; - let top = resample(&arc(tl, tr), SIDE); - let right = resample(&arc(tr, br), SIDE); - let mut bottom = resample(&arc(br, bl), SIDE); - bottom.reverse(); - let mut left = resample(&arc(bl, tl), SIDE); - left.reverse(); - Some(Self { top, right, bottom, left }) - } - - /// The point at `u` along a side; past its ends the end segments go on straight (art an - /// envelope bends can reach past the content's box: outlined strokes). - fn sample(side: &[Point], u: f64) -> Point { - let s = u * (side.len() - 1) as f64; - let i = (s.floor().max(0.0) as usize).min(side.len() - 2); - side[i].lerp(side[i + 1], s - i as f64) - } - - /// The surface point at (u, v) ∈ [0,1]². - pub fn eval(&self, u: f64, v: f64) -> Point { - let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v)); - let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]); - let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2() - - ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2()); - c.to_point() - } - - /// The four corners TL, TR, BR, BL. - pub fn corners(&self) -> [Point; 4] { - [self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]] - } -} - -// ===================================================================================== -// Gradient meshes -// ===================================================================================== - -/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. -fn even_steps(n: u32) -> Vec { - let n = n.clamp(1, 200); - (0..=n).map(|i| i as f64 / n as f64).collect() -} - -/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from -/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid. -fn grid_len(rows: u32, cols: u32) -> Option { - (rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?) -} - -impl GradientMesh { - 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()) - } - - /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v - /// order: row 0 is v = 0, columns follow u), each with its handles ([`H_RIGHT`] …). - fn patch(corners: [(Point, [Vec2; 4], Color); 4]) -> Self { - let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles }; - let [a, b, c, d] = corners; - Self { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] } - } - - /// A 1×1 mesh from a Coons patch's twelve boundary points (in the order PDF and PostScript - /// shadings list them: v runs cp0 → cp3 along cp1 and cp2, u runs cp0 → cp9 along cp11 and - /// cp10) and its corner colours (at cp0, cp3, cp6, cp9); `None` if a point isn't finite. - pub fn coons(cp: &[Point; 12], colors: [Color; 4]) -> Option { - if !cp.iter().all(|p| p.is_finite()) { - return None; - } - let h = |a: usize, b: usize| cp[b] - cp[a]; - let handles = |right: Vec2, left: Vec2, down: Vec2, up: Vec2| { - let mut h = [Vec2::ZERO; 4]; - (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up); - h - }; - let [c0, c1, c2, c3] = colors; - Some(Self::patch([ - (cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0), - (cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1), - (cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2), - (cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3), - ])) - } - - /// A 1×1 mesh of a Gouraud-shaded triangle (a patch with two corners together); `None` if a - /// point isn't finite. - pub fn triangle(corners: [(Point, Color); 3]) -> Option { - if !corners.iter().all(|(p, _)| p.is_finite()) { - return None; - } - let [a, b, c] = corners.map(|(p, color)| (p, [Vec2::ZERO; 4], color)); - Some(Self::patch([a, b, c, c])) - } - - /// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives. - pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self { - let (us, vs) = (even_steps(cols), even_steps(rows)); - Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0)) - } - - /// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs` - /// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour - /// change), with handles from its partial derivatives. `color(row, col, point)` gives each - /// point its colour and opacity. - pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self { - let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1)); - let eps = 1e-3; - let mut points = Vec::with_capacity(us.len() * vs.len()); - for (r, &v) in vs.iter().enumerate() { - for (c, &u) in us.iter().enumerate() { - let p = s(u, v); - let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0)); - let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0)); - // A third of the way to the neighbouring line along the tangent; outward handles on - // the border are unused, so they stay at the point. - let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0)); - let handles = [ - h(d_u, u, us.get(c + 1)), - h(d_u, u, c.checked_sub(1).map(|i| &us[i])), - h(d_v, v, vs.get(r + 1)), - h(d_v, v, r.checked_sub(1).map(|i| &vs[i])), - ]; - let (color, opacity) = color(r, c, p); - points.push(MeshPoint { p, color, opacity, handles }); - } - } - Self { rows: rows as u32, cols: cols as u32, points } - } - - /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. - pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { - let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; - m.highlight(appearance, highlight); - Some(m) - } - - /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the - /// 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))) - } - - /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to - /// the edges, by at most `highlight` %. - pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { - let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; - let (rows, cols) = (self.rows as usize, self.cols as usize); - for r in 0..=rows { - for c in 0..=cols { - let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); - let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge - let w = match appearance { - MeshAppearance::Flat => 0.0, - MeshAppearance::ToCenter => (1.0 - d) * h, - MeshAppearance::ToEdge => d * h, - }; - if w > 0.0 { - let i = self.idx(r, c); - self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w); - } - } - } - } - - /// Horizontal edge from (r, c) to (r, c+1). - pub fn edge_u(&self, r: usize, c: usize) -> CubicBez { - let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)]); - CubicBez::new(a.p, a.p + a.handles[H_RIGHT], b.p + b.handles[H_LEFT], b.p) - } - /// Vertical edge from (r, c) to (r+1, c). - pub fn edge_v(&self, r: usize, c: usize) -> CubicBez { - let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r + 1, c)]); - CubicBez::new(a.p, a.p + a.handles[H_DOWN], b.p + b.handles[H_UP], b.p) - } - - /// Coons patch point of patch (r, c) at local (u, v). - pub fn eval(&self, r: usize, c: usize, u: f64, v: f64) -> Point { - let (t, b) = (self.edge_u(r, c).eval(u), self.edge_u(r + 1, c).eval(u)); - let (l, rr) = (self.edge_v(r, c).eval(v), self.edge_v(r, c + 1).eval(v)); - let p00 = self.points[self.idx(r, c)].p.to_vec2(); - let p10 = self.points[self.idx(r, c + 1)].p.to_vec2(); - let p01 = self.points[self.idx(r + 1, c)].p.to_vec2(); - let p11 = self.points[self.idx(r + 1, c + 1)].p.to_vec2(); - ((1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * rr.to_vec2() - - ((1.0 - u) * (1.0 - v) * p00 + u * (1.0 - v) * p10 + (1.0 - u) * v * p01 + u * v * p11)) - .to_point() - } - - /// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all - /// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out - /// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB. - pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) { - let q = - [&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]]; - let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v]; - let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum(); - let cmyk: Option> = - q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect(); - if let Some(inks) = cmyk { - let mut mix = [0.0f32; 4]; - for (ink, w) in inks.iter().zip(w) { - for (m, x) in mix.iter_mut().zip(ink) { - *m += x * w as f32; - } - } - let [c, m, y, k] = mix; - return (Color::Cmyk { c, m, y, k }, op); - } - let grey: Option> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect(); - if let Some(ks) = grey { - return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op); - } - let mut rgb = [0.0f32; 3]; - for (p, w) in q.iter().zip(w) { - for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) { - *ch += x * w as f32; - } - } - (Color::rgb(rgb[0], rgb[1], rgb[2]), op) - } - - /// Tessellate into `n`×`n` quads per patch, 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 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 { - for c in 0..self.cols as usize { - for j in 0..=n { - for i in 0..=n { - let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); - grid[j * (n + 1) + i] = self.eval(r, c, u, v); - colors[j * (n + 1) + i] = self.color_at(r, c, u, v); - } - } - for j in 0..n { - for i in 0..n { - let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); - let g = |a: usize, b: usize| grid[b * (n + 1) + a]; - let k = |a: usize, b: usize| colors[b * (n + 1) + a]; - out.push(MeshQuad { - pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], - color, - opacity, - corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)], - }); - } - } - } - } - out - } - - /// The mesh's outer boundary as a closed path. - pub fn outline(&self) -> PathData { - if !self.is_valid() { - return PathData::default(); - } - let (rows, cols) = (self.rows as usize, self.cols as usize); - let mut segs: Vec = vec![]; - for c in 0..cols { - segs.push(self.edge_u(0, c)); - } - for r in 0..rows { - segs.push(self.edge_v(r, cols)); - } - for c in (0..cols).rev() { - let e = self.edge_u(rows, c); - segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); - } - for r in (0..rows).rev() { - let e = self.edge_v(r, 0); - segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); - } - let mut anchors: Vec = Vec::with_capacity(segs.len()); - for (i, s) in segs.iter().enumerate() { - let prev = &segs[(i + segs.len() - 1) % segs.len()]; - anchors.push(Anchor::with_handles(s.p0, prev.p2, s.p1)); - } - PathData::single(SubPath::new(anchors, true)) - } - - /// Mesh lines (all row and column edges) as open paths, for outline view and editing overlays. - pub fn lines(&self) -> PathData { - if !self.is_valid() { - return PathData::default(); - } - let (rows, cols) = (self.rows as usize, self.cols as usize); - let mut subs = vec![]; - let cub = |e: CubicBez| SubPath::new(vec![Anchor::with_handles(e.p0, e.p0, e.p1), Anchor::with_handles(e.p3, e.p2, e.p3)], false); - for r in 0..=rows { - for c in 0..cols { - subs.push(cub(self.edge_u(r, c))); - } - } - for c in 0..=cols { - for r in 0..rows { - subs.push(cub(self.edge_v(r, c))); - } - } - PathData::new(subs) - } - - /// Control bounds (points and handles). - pub fn bounds(&self) -> Option { - let mut it = self.points.iter().flat_map(|p| std::iter::once(p.p).chain(p.handles.iter().map(move |h| p.p + *h))); - let first = it.next()?; - Some(it.fold(Rect::from_points(first, first), |r, q| r.union_pt(q))) - } - - pub fn transform(&mut self, a: Affine) { - let lin = Affine::new({ - let c = a.as_coeffs(); - [c[0], c[1], c[2], c[3], 0.0, 0.0] - }); - for p in &mut self.points { - p.p = a * p.p; - for h in &mut p.handles { - *h = (lin * h.to_point()).to_vec2(); - } - } - } - - /// Map points (and handle ends) through a non-linear function. - pub fn map(&mut self, f: &dyn Fn(Point) -> Point) { - for p in &mut self.points { - let q = f(p.p); - for h in &mut p.handles { - *h = f(p.p + *h) - q; - } - p.p = q; - } - } - - /// 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)> { - if !self.is_valid() { - return None; - } - let bp = self.outline().to_bezpath(); - if bp.winding(p) == 0 { - return None; - } - const N: usize = 16; - let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); - for r in 0..self.rows as usize { - for c in 0..self.cols as usize { - for j in 0..=N { - for i in 0..=N { - let (u, v) = (i as f64 / N as f64, j as f64 / N as f64); - let d = self.eval(r, c, u, v).distance_squared(p); - if d < best.0 { - best = (d, r, c, u, v); - } - } - } - } - } - // Refine with a few Newton-free local searches. - let (_, r, c, mut u, mut v) = best; - let mut step = 0.5 / N as f64; - for _ in 0..12 { - let mut improved = false; - for (du, dv) in [(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] { - let (nu, nv) = ((u + du).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)); - if self.eval(r, c, nu, nv).distance_squared(p) < self.eval(r, c, u, v).distance_squared(p) { - u = nu; - v = nv; - improved = true; - } - } - if !improved { - step /= 2.0; - } - } - Some((r, c, u, v)) - } - - /// Index of the mesh point within `tol` of `p`. - pub fn point_near(&self, p: Point, tol: f64) -> Option { - self.points - .iter() - .enumerate() - .filter(|(_, q)| q.p.distance(p) <= tol) - .min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p))) - .map(|(i, _)| i) - } - - fn surface_du(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { - let e = 1e-3; - let (a, b) = ((u - e).max(0.0), (u + e).min(1.0)); - (self.eval(r, c, b, v) - self.eval(r, c, a, v)) / (b - a) - } - fn surface_dv(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { - let e = 1e-3; - let (a, b) = ((v - e).max(0.0), (v + e).min(1.0)); - (self.eval(r, c, u, b) - self.eval(r, c, u, a)) / (b - a) - } - - /// Insert a mesh row through patch row `r` at local `v`. - pub fn insert_row(&mut self, r: usize, v: f64) { - let cols = self.cols as usize; - let v = v.clamp(0.01, 0.99); - let mut row = Vec::with_capacity(cols + 1); - // Split the vertical edges. - let mut ups = vec![]; - for c in 0..=cols { - let e = self.edge_v(r, c); - let (l, rr) = (e.subsegment(0.0..v), e.subsegment(v..1.0)); - ups.push((l.p1 - l.p0, rr.p2 - rr.p3)); - let pc = c.min(cols - 1); - let u = if c == cols { 1.0 } else { 0.0 }; - let (color, opacity) = self.color_at(r, pc, u, v); - // Horizontal handles: isocurve derivatives on either side. - let right = if c < cols { self.surface_du(r, c, 0.0, v) / (3.0) } else { Vec2::ZERO }; - let left = if c > 0 { -self.surface_du(r, c - 1, 1.0, v) / 3.0 } else { Vec2::ZERO }; - row.push(MeshPoint { p: l.p3, color, opacity, handles: [right, left, rr.p1 - rr.p0, l.p2 - l.p3] }); - } - for (c, (down, up)) in ups.into_iter().enumerate() { - let i0 = self.idx(r, c); - let i1 = self.idx(r + 1, c); - self.points[i0].handles[H_DOWN] = down; - self.points[i1].handles[H_UP] = up; - } - let at = self.idx(r + 1, 0); - self.points.splice(at..at, row); - self.rows += 1; - } - - /// Insert a mesh column through patch column `c` at local `u`. - pub fn insert_col(&mut self, c: usize, u: f64) { - let rows = self.rows as usize; - let u = u.clamp(0.01, 0.99); - let mut col = Vec::with_capacity(rows + 1); - for r in 0..=rows { - let e = self.edge_u(r, c); - let (l, rr) = (e.subsegment(0.0..u), e.subsegment(u..1.0)); - let pr = r.min(rows - 1); - let v = if r == rows { 1.0 } else { 0.0 }; - let (color, opacity) = self.color_at(pr, c, u, v); - let down = if r < rows { self.surface_dv(r, c, u, 0.0) / 3.0 } else { Vec2::ZERO }; - let up = if r > 0 { -self.surface_dv(r - 1, c, u, 1.0) / 3.0 } else { Vec2::ZERO }; - col.push((MeshPoint { p: l.p3, color, opacity, handles: [rr.p1 - rr.p0, l.p2 - l.p3, down, up] }, l.p1 - l.p0, rr.p2 - rr.p3)); - } - let cols = self.cols as usize; - let mut pts = Vec::with_capacity((rows + 1) * (cols + 2)); - for (r, (mp, right, left)) in col.into_iter().enumerate() { - for cc in 0..=cols { - let mut q = self.points[r * (cols + 1) + cc].clone(); - if cc == c { - q.handles[H_RIGHT] = right; - } - if cc == c + 1 { - q.handles[H_LEFT] = left; - } - pts.push(q); - if cc == c { - pts.push(mp.clone()); - } - } - } - self.points = pts; - 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)?; - let mut rr = r; - let mut cc = c; - let row_new = v > 0.02 && v < 0.98; - let col_new = u > 0.02 && u < 0.98; - if row_new { - self.insert_row(r, v); - rr = r + 1; - } else if v >= 0.98 { - rr = r + 1; - } - if col_new { - self.insert_col(c, u); - cc = c + 1; - } else if u >= 0.98 { - cc = c + 1; - } - Some(self.idx(rr, cc)) - } - - /// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only). - pub fn remove_point_lines(&mut self, index: usize) -> bool { - // A mesh from a file may not have the points its rows and columns need: it is indexed below. - if !self.is_valid() || index >= self.points.len() { - return false; - } - let w = self.cols as usize + 1; - let (r, c) = (index / w, index % w); - let mut changed = false; - if r > 0 && r < self.rows as usize { - // Merge the edges above and below: keep the outer handles. - for cc in 0..w { - 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; - } - self.points.drain(r * w..(r + 1) * w); - self.rows -= 1; - changed = true; - } - let w = self.cols as usize + 1; - if c > 0 && c < self.cols as usize { - 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 mut k = 0; - self.points.retain(|_| { - let keep = k % w != c; - k += 1; - keep - }); - self.cols -= 1; - changed = true; - } - changed - } -} - -// ===================================================================================== -// Envelopes -// ===================================================================================== - -/// Union of the geometric bounds of `nodes`. -pub fn nodes_bounds(nodes: &[Arc]) -> Option { - nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())) -} - -/// A `(rows+1)×(cols+1)` grid over `r` (row-major). -pub fn grid_points(r: Rect, rows: u32, cols: u32) -> Vec { - let mut v = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); - for i in 0..=rows { - for j in 0..=cols { - v.push(Point::new(r.x0 + r.width() * j as f64 / cols.max(1) as f64, r.y0 + r.height() * i as f64 / rows.max(1) as f64)); - } - } - v -} - -fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 { - let t2 = t * t; - let t3 = t2 * t; - 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3) -} - -/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear -/// extrapolation at the borders so an undistorted grid is the identity, also past the square). -fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { - let at = |r: i64, c: i64| -> Vec2 { - let rr = r.clamp(0, rows as i64); - let cc = c.clamp(0, cols as i64); - let base = pts[rr as usize * (cols + 1) + cc as usize].to_vec2(); - // Linear extrapolation outside the grid. - let er = r - rr; - let ec = c - cc; - let mut p = base; - if er != 0 && rows >= 1 { - let inner = (rr - er.signum()).clamp(0, rows as i64); - p += (base - pts[inner as usize * (cols + 1) + cc as usize].to_vec2()) * er.abs() as f64; - } - if ec != 0 && cols >= 1 { - let inner = (cc - ec.signum()).clamp(0, cols as i64); - p += (base - pts[rr as usize * (cols + 1) + inner as usize].to_vec2()) * ec.abs() as f64; - } - p - }; - // Past the grid's edges the outer patches go on (art can reach past the content's box). - let (su, sv) = (u * cols as f64, v * rows as f64); - let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1)); - let (fu, fv) = (su - ci as f64, sv - ri as f64); - let mut rowsv = [Vec2::ZERO; 4]; - for (k, rv) in rowsv.iter_mut().enumerate() { - let r = ri - 1 + k as i64; - *rv = catmull(at(r, ci - 1), at(r, ci), at(r, ci + 1), at(r, ci + 2), fu); - } - catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point() -} - -/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in -/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's -/// axes; a mesh's points and a top object's path are in the document already. -fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { - let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); - match kind { - EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { - let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); - let b = bend.clamp(-100.0, 100.0) / 100.0; - let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); - let c = src.center(); - let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); - let horizontal = *horizontal; - Box::new(move |u: f64, v: f64| { - let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0); - let (x2, y2) = if horizontal { - warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y) - } else { - let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x); - (b2, a) - }; - frame * Point::new(c.x + x2 * hw, c.y + y2 * hh) - }) - } - EnvelopeKind::Mesh { rows, cols, points, handles } => { - if !handles.is_empty() - && let Some(m) = envelope_grid(*rows, *cols, points, handles) - { - return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v)); - } - if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) { - return Box::new(flat); - } - let (rows, cols) = (*rows as usize, *cols as usize); - Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v)) - } - EnvelopeKind::TopObject { path } => match Coons::from_path(path) { - Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)), - None => Box::new(flat), - }, - } -} - -/// The envelope's point map for content whose bounding box is `src` (page axes). -pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { - let s = surface_of(kind, src, Affine::IDENTITY); - let (w, h) = (src.width().max(1e-9), src.height().max(1e-9)); - Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h)) -} - -/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third -/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves -/// an envelope mesh without handles of its own runs along between its points. -pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> { - let (rows, cols) = (rows as usize, cols as usize); - let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default(); - let tangent = |prev: Option, p: Point, next: Option| match (prev, next) { - (Some(a), Some(b)) => (b - a) / 2.0, - (None, Some(b)) => b - p, - (Some(a), None) => p - a, - (None, None) => Vec2::ZERO, - }; - let mut out = Vec::with_capacity(points.len()); - for r in 0..=rows { - for c in 0..=cols { - let p = at(r, c); - let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0; - let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0; - // Handles pointing out of the grid are unused: they stay at the point. - let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO }; - let mut h = [Vec2::ZERO; 4]; - (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv)); - out.push(h); - } - } - out -} - -/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles -/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the -/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid. -pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option { - if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) { - return None; - } - let smooth; - let handles = if handles.len() == points.len() { - handles - } else { - smooth = smooth_handles(rows, cols, points); - &smooth - }; - let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect(); - Some(GradientMesh { rows, cols, points }) -} - -/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges -/// the outer patches go on). -fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point { - let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64); - let (su, sv) = (u * cols as f64, v * rows as f64); - let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1)); - m.eval(r as usize, c as usize, su - c as f64, sv - r as f64) -} - -/// The envelope `id` is, or the innermost one it sits in. -pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { - doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. }))) -} - -/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid, -/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to -/// edit (its points and handles). -pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { - let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; - match &**kind { - EnvelopeKind::Mesh { rows, cols, points, handles } => { - let grid = envelope_grid(*rows, *cols, points, handles)?; - Some((grid.lines(), Some(grid))) - } - EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), - EnvelopeKind::TopObject { path } => Some((path.clone(), None)), - } -} - -/// The frame an envelope keeps after transform `m` (its frame so far composed with the -/// transform): identity when its axes stay square to the page, unflipped, since evaluating on -/// the page axes gives the same result then. -pub fn envelope_frame(m: Affine) -> Affine { - let [a, b, c, d, _, _] = m.as_coeffs(); - if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m } -} - -/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there, -/// onto the unit square and from there onto the envelope's surface ([`surface_of`]). -pub struct EnvelopeMap<'a> { - src: Rect, - frame: Affine, - to_frame: Affine, - surface: Box Point + 'a>, -} - -impl<'a> EnvelopeMap<'a> { - /// The map of an envelope of `kind` around `content` with `frame`. `None` when the content - /// has no bounds. - pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine) -> Option { - let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; - let to_frame = frame.inverse(); - let src = if frame == Affine::IDENTITY { - nodes_bounds(content)? - } else { - content.iter().try_fold(None, |acc, c| { - let mut m = (**c).clone(); - m.transform(to_frame, false); - Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) - })?? - }; - Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) - } - - /// The map of envelope node `n` (`None` for other nodes). - pub fn of(n: &'a Node) -> Option { - match &n.kind { - NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), - _ => None, - } - } - - /// The content's bounds in the envelope's frame. - pub fn src(&self) -> Rect { - self.src - } - - /// The surface point at (u, v) ∈ [0,1]². - pub fn at(&self, u: f64, v: f64) -> Point { - (self.surface)(u, v) - } - - /// Where the envelope puts document point `p`. - pub fn map(&self, p: Point) -> Point { - let q = self.to_frame * p; - let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9)); - self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h) - } - - /// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major). - pub fn grid(&self, rows: u32, cols: u32) -> Vec { - grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect() - } - - /// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose - /// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts - /// from it. - pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh { - GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color) - } - - /// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly). - fn bounds(&self) -> Option { - const N: usize = 16; - let mut r: Option = None; - for j in 0..=N { - for i in 0..=N { - let q = self.at(i as f64 / N as f64, j as f64 / N as f64); - r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); - } - } - r - } -} - -/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. -pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; - -/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over -/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch. -pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option>; - -/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them). -#[derive(Clone, Copy, Default)] -pub struct Hooks<'a> { - /// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]). - pub outline: Outliner<'a>, - /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` - /// when it has none to bake). - pub appearance: Outliner<'a>, - /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). - pub pattern: Option>, - /// The document whose symbols' art counts toward the instance budget of a Repeat of their - /// instances ([`crate::RepeatSpec::expand`]). - pub symbols: Option<&'a crate::Document>, -} - -impl<'a> From> for Hooks<'a> { - fn from(outline: Outliner<'a>) -> Self { - Self { outline, ..Default::default() } - } -} - -/// The affine map that best matches `f` near `p` (finite differences with step `eps`). -pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine { - let o = f(p); - let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps); - let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps); - let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]); - Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) -} - -/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before -/// the rest is left as it is: a file can make a symbol contain itself. -const MAX_HOOK_DEPTH: u32 = 16; - -/// How an envelope pushes art through its point map. -#[derive(Clone, Copy)] -struct Warper<'a> { - f: &'a dyn Fn(Point) -> Point, - /// Longest piece a segment is cut into before mapping. - piece: f64, - /// Finite-difference step for the local affine maps gradients follow. - eps: f64, - /// Rows and columns of the pieces an image is cut into. - cells: usize, - hooks: Hooks<'a>, - options: EnvelopeOptions, - /// How many hooks the art being mapped came through. - depth: u32, -} - -impl Warper<'_> { - /// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]). - fn deeper(&self) -> Option { - (self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self }) - } -} - -/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency, -/// opacity mask and effects. -fn carry_look(from: &Node, mut to: Node) -> Node { - to.name = from.name.clone(); - to.opacity = from.opacity; - to.blend = from.blend; - to.isolate = from.isolate; - to.knockout = from.knockout; - to.knockout_shape = from.knockout_shape; - to.extras_mut().mask = from.extras().mask.clone(); - to.appearance.effects = from.appearance.effects.clone(); - to -} - -fn map_node(n: &Node, w: &Warper) -> Node { - let deeper = w.deeper(); - // Distort Appearance: strokes and geometry effects bend with the art. - if w.options.distort_appearance - && let Some(d) = &deeper - && let Some(o) = w.hooks.appearance.and_then(|h| h(n)) - { - return map_node(&o, d); - } - // Distort Pattern Fills: the tiles bend with the art (not the patterns inside them). - if w.options.patterns() - && let Some(d) = &deeper - && let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p)) - { - let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d }; - return map_node(&o, &inner); - } - let mut out = n.clone(); - // Distort Linear Gradients: the gradient vectors follow the warp where they sit. - if w.options.gradients() - && let Some(b) = n.geometric_bounds() - { - out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); - } - if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { - m.art = Arc::new(map_node(&m.art, w)); - } - match &mut out.kind { - NodeKind::Path { path, live, .. } => { - *path = map_nonlinear(path, w.piece, w.f); - *live = None; - } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - for c in children.iter_mut() { - *c = Arc::new(map_node(c, w)); - } - } - NodeKind::Mesh(m) => m.map(w.f), - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { - if let Some(d) = &deeper { - return map_node(&expanded_group_hooks(n, w.hooks), d); - } - } - NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { - if let Some(d) = &deeper - && let Some(o) = w.hooks.outline.and_then(|h| h(n)) - { - return map_node(&carry_look(n, o), d); - } - } - NodeKind::Image(im) => { - let im = im.clone(); - return warp_image(&out, &im, w); - } - // Its art is plain, so it warps at this depth. - NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), - } - out -} - -/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate). -fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { - let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]); - let bs = basis(s); - if bs.determinant().abs() < 1e-12 { - return None; - } - let a = basis(d) * bs.inverse(); - a.is_finite().then_some(a) -} - -/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into -/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its -/// edges (at most half a point) so neighbours overlap and no seams show. -fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { - let k = w.cells.max(1); - // Two triangles a cell, three objects each; an image an inner envelope already cut into - // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image - // is left out. - if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { - let mut g = n.clone(); - g.kind = NodeKind::Group { children: vec![], clip: false }; - return g; - } - let (pw, ph) = (im.width as f64, im.height as f64); - let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); - let mut pieces = Vec::with_capacity(2 * k * k); - for j in 0..k { - for i in 0..k { - let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; - let d = s.map(|p| (w.f)(im.xf * p)); - for t in [[0, 1, 2], [0, 2, 3]] { - let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); - let Some(xf) = affine_between(st, dt) else { continue }; - let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0); - let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5); - let tri: Vec = dt - .iter() - .map(|p| { - let v = *p - c; - let len = v.hypot(); - if len > 1e-12 { *p + v * (grow / len) } else { *p } - }) - .collect(); - let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); - if let NodeKind::Path { clipping, .. } = &mut clip.kind { - *clipping = true; - } - let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); - pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); - } - } - } - let mut g = n.clone(); - g.kind = NodeKind::Group { children: pieces, clip: false }; - g -} - -/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the -/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its -/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None` -/// without a pattern fill whose definition `pattern` finds. -fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { - let (path, rule, _) = crate::blend::node_path(n)?; - let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. })); - if !n.appearance.items.iter().any(pattern_fill) { - return None; - } - let bounds = path.bounds()?; - let def = |i: &AppearanceItem| match i { - AppearanceItem::Fill(f) if f.visible => match &f.paint { - Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)), - _ => None, - }, - _ => None, - }; - let plain = |items: Vec| { - let mut p = Node { - appearance: Appearance { items, ..Default::default() }.into(), - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Default::default(), - knockout_shape: false, - name: None, - ..n.clone() - }; - p.extras_mut().mask = None; - Arc::new(p) - }; - let mut pieces = vec![]; - let mut rest = vec![]; - for i in &n.appearance.items { - let Some((tiles, opacity, blend)) = def(i) else { - rest.push(i.clone()); - continue; - }; - if !rest.is_empty() { - pieces.push(plain(std::mem::take(&mut rest))); - } - let mut clip = Node::path(n.id, path.clone(), Appearance::default()); - if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind { - (*clipping, *r) = (true, rule); - } - let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: true }); - g.opacity = opacity; - g.blend = blend; - pieces.push(Arc::new(g)); - } - if !rest.is_empty() { - pieces.push(plain(rest)); - } - Some(Node { - kind: NodeKind::Group { children: pieces, clip: false }, - appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(), - ..n.clone() - }) -} - -/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes -/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node). -pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { - expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into()) -} - -/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and -/// into how many pieces images are), the options what bends with the art. -fn expand_envelope(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, options: EnvelopeOptions, frame: Affine, hooks: Hooks) -> Vec { - let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() }; - let src = map.src(); - let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 }; - let f = |p: Point| map.map(p); - let w = Warper { - f: &f, - piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0), - eps: (src.width().max(src.height()) * 0.05).max(0.5), - cells: (4.0 + fid * 12.0).round() as usize, - hooks, - options, - depth: 0, - }; - content.iter().map(|c| map_node(c, &w)).collect() -} - -/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled). -pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine) -> Option { - match kind { - EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(), - EnvelopeKind::Mesh { points, .. } => { - let first = *points.first()?; - Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) - } - EnvelopeKind::TopObject { path } => path.bounds(), - EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(), - } -} - -// ===================================================================================== -// Expansion -// ===================================================================================== - -/// Is this one of the live kinds? -pub fn is_live(n: &Node) -> bool { - matches!( - n.kind, - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } - ) -} - -/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. -pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { - m.quads(n) - .into_iter() - .map(|q| { - let mut ap = Appearance::default(); - let mut fl = FillLayer::new(Paint::solid(q.color)); - fl.opacity = q.opacity; - ap.items.push(AppearanceItem::Fill(fl)); - Node::path(NodeId(0), PathData::single(SubPath::polyline(&q.pts, true)), ap) - }) - .collect() -} - -/// One level of evaluation of a live object (non-live nodes return themselves). Blend steps and -/// envelope results may still contain live objects (e.g. a blend of two meshes). -pub fn expand_live(n: &Node) -> Vec { - expand_live_with(n, None) -} - -pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { - expand_live_hooks(n, outline.into()) -} - -/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]). -pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { - let _generating = Generating::enter(); - match &n.kind { - NodeKind::Blend { children, spec } => blend_expand(children, spec), - NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), - NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), - NodeKind::Repeat(r) => r.expand(hooks.symbols), - NodeKind::PlacedDocument(p) => p.art(n), - NodeKind::Intertwine { children } => crate::intertwine::expand(children), - _ => vec![n.clone()], - } -} - -/// A live object evaluated into a plain group (keeping the object's id, name and transparency). -pub fn expanded_group(n: &Node, outline: Outliner) -> Node { - expanded_group_hooks(n, outline.into()) -} - -/// [`expanded_group`] with [`Hooks`]. -pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { - let _generating = Generating::enter(); - let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); - g.name = n.name.clone(); - g.visible = n.visible; - g.locked = n.locked; - g.opacity = n.opacity; - g.blend = n.blend; - g.isolate = n.isolate; - g.knockout = n.knockout; - // Knocking out opaque blend steps changes nothing: don't make the outputs work for it. - if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) { - g.knockout = crate::Knockout::Off; - } - g.knockout_shape = n.knockout_shape; - g.extras_mut().mask = n.extras().mask.clone(); - g -} - -/// Recursively replace every live object in `n` by plain geometry (for exporters). -pub fn expand_deep(n: &Node, outline: Outliner) -> Node { - expand_deep_hooks(n, outline.into()) -} - -/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one -/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each -/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art -/// expands to nothing. -pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { - let _generating = Generating::enter(); - let mut out = if is_live(n) { - let g = expanded_group_hooks(n, hooks); - if !Generating::charge(g.count()) { - return Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); - } - g - } else { - n.clone() - }; - if let Some(ch) = out.children_mut() { - for c in ch.iter_mut() { - if subtree_has_live(c) { - *c = Arc::new(expand_deep_hooks(c, hooks)); - } - } - } - out -} - -/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything -/// inside them) may make. -pub const MAX_GENERATED: usize = 4_000_000; - -std::thread_local! { - /// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside - /// one. - static GENERATED_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; -} - -/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it. -struct Generating(bool); - -impl Generating { - fn enter() -> Self { - let outermost = GENERATED_LEFT.get().is_none(); - if outermost { - GENERATED_LEFT.set(Some(MAX_GENERATED)); - } - Self(outermost) - } - - /// Spend `n` objects; `false` (and nothing left) when there aren't that many. - fn charge(n: usize) -> bool { - match GENERATED_LEFT.get() { - None => true, - Some(left) => { - GENERATED_LEFT.set(Some(left.saturating_sub(n))); - n <= left - } - } - } -} - -impl Drop for Generating { - fn drop(&mut self) { - if self.0 { - GENERATED_LEFT.set(None); - } - } -} - -/// Does `n` or any descendant need live evaluation? -pub fn subtree_has_live(n: &Node) -> bool { - let mut any = false; - n.walk(&mut |c| any |= is_live(c)); - any -} - -/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the -/// outsets in the art of the symbols its instances show count too -/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made -/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad -/// does. -pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxes>) -> f64 { - let mut o = 0.0f64; - for n in nodes { - n.walk(&mut |c| { - o = o.max(c.appearance.outset()); - if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind) - && let Some(art) = s.art(symbol) - { - let reach = if s.art_strokes() { art.reach } else { 0.0 }; - o = o.max(reach + s.brush_pad(symbol)); - } - }); - } - o -} - -/// Reading a native file never fails over one bad blend number: out-of-range values clamp, -/// and anything that is not a finite number (NaN, null, text) becomes the default. The -/// commands still refuse such input (`ease_param`). -fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { - let v = serde_json::Value::deserialize(d)?; - Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) -} - -fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - blend_number(d, 0.0, 1.0) -} - -fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - blend_number(d, -100.0, 100.0) -} - -/// Whether an optional stored color shift has its unchanged default. -fn is_zero(v: &f64) -> bool { - *v == 0.0 -} - -impl BlendSpec { - /// Shift the color transition independently of shape placement. The exponential curve is - /// original; the documented behavior specifies the direction, not numeric sample values. - pub fn color_position(&self, u: f64) -> f64 { - let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; - let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; - if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } - } -} +mod blend_spec; +mod envelope; +mod expand; +mod mesh; +mod warp; + +pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; +#[cfg(test)] +use blend_spec::{blend_color_shift, blend_ramp}; +pub use envelope::{ + EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame, + envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles, +}; +pub(crate) use expand::max_outset; +pub use expand::{ + Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, + expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, +}; +#[cfg(test)] +use mesh::grid_eval; +pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; +pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; #[cfg(test)] mod tests { diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs new file mode 100644 index 000000000..79c129030 --- /dev/null +++ b/crates/doc/src/live/blend_spec.rs @@ -0,0 +1,157 @@ +use super::*; + +// ===================================================================================== +// Data +// ===================================================================================== + +/// Blend Options → Spacing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpacing { + /// Step count chosen from the colour difference (or the distance when colours match). + #[default] + SmoothColor, + /// Specified number of steps between each pair of key objects (1–1000). + Steps(u32), + /// Specified distance (pt) between the steps. + Distance(f64), +} + +/// Blend Options → Orientation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendOrientation { + #[default] + AlignToPage, + AlignToPath, +} + +/// Distribution of blend steps, as offered by the Blend panel's Type menu. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendEaseType { + #[default] + In, + Out, + InOut, + OutIn, +} + +impl BlendEaseType { + pub fn id(self) -> &'static str { + match self { + Self::In => "easeIn", + Self::Out => "easeOut", + Self::InOut => "easeInOut", + Self::OutIn => "easeOutIn", + } + } +} + +/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendEase { + #[serde(default)] + pub kind: BlendEaseType, + #[serde(default, deserialize_with = "blend_ramp")] + pub ramp: f64, +} + +impl BlendEase { + pub fn is_linear(&self) -> bool { + self.ramp <= 0.0 + } + + /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. + pub fn is_default(&self) -> bool { + self.kind == BlendEaseType::In && self.ramp == 0.0 + } + + /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help + /// describes the four distributions, not their numeric curves; these curves are original. + pub fn apply(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; + let eased = match self.kind { + BlendEaseType::In => u * u, + BlendEaseType::Out => u * (2.0 - u), + BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), + BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), + BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), + }; + u + r * (eased - u) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendSpec { + #[serde(default)] + pub spacing: BlendSpacing, + #[serde(default)] + pub orientation: BlendOrientation, + /// Acceleration of the steps' shapes and positions along the spine between two keys. + #[serde(default, skip_serializing_if = "BlendEase::is_default")] + pub ease: BlendEase, + /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. + #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] + pub color_shift: f64, + /// Replaced spine. `None` = the straight lines between the key objects' centres. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spine: Option, + /// The anchor of the spine's first subpath each key object sits on (a spine edited on the + /// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread + /// evenly along the spine by arc length (Replace Spine). + #[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")] + pub key_anchors: Vec, + /// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks + /// on anchor points); `None`: chosen so closed shapes don't twist. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub starts: Vec>, +} + +impl BlendSpec { + /// The start anchor of key `i` (see [`Self::starts`]). + pub fn start(&self, i: usize) -> Option { + self.starts.get(i).copied().flatten().map(|a| a as usize) + } +} + +/// Blend Options set with no blend selected: what new blends start with. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendDefaults { + #[serde(default)] + pub spacing: BlendSpacing, + #[serde(default)] + pub orientation: BlendOrientation, +} + +/// Reading a native file never fails over one bad blend number: out-of-range values clamp, +/// and anything that is not a finite number (NaN, null, text) becomes the default. The +/// commands still refuse such input (`ease_param`). +fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) +} + +pub(super) fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, 0.0, 1.0) +} + +pub(super) fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, -100.0, 100.0) +} + +/// Whether an optional stored color shift has its unchanged default. +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl BlendSpec { + /// Shift the color transition independently of shape placement. The exponential curve is + /// original; the documented behavior specifies the direction, not numeric sample values. + pub fn color_position(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; + if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } + } +} diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs new file mode 100644 index 000000000..13d30cef0 --- /dev/null +++ b/crates/doc/src/live/envelope.rs @@ -0,0 +1,332 @@ +use super::expand::{Hooks, Outliner, Warper, map_node}; +use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of}; +use super::*; + +/// How an envelope distorts its content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum EnvelopeKind { + /// One of the 15 warp styles (`arc`, `arcLower`, … — see [`WarpStyle::from_id`]); bend and + /// distortions in percent (-100..100). + Warp { + style: String, + #[serde(default)] + bend: f64, + #[serde(default)] + h: f64, + #[serde(default)] + v: f64, + #[serde(default = "yes")] + horizontal: bool, + }, + /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. + Mesh { + rows: u32, + cols: u32, + points: Vec, + /// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`. + /// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed; + /// editing the mesh's points or handles on the canvas fills them in. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + handles: Vec<[Vec2; 4]>, + }, + /// The content's bounding box maps onto the outline of this path. + TopObject { path: PathData }, +} + +/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreserveShape { + #[default] + ClippingMask, + Transparency, +} + +impl PreserveShape { + pub fn id(self) -> &'static str { + match self { + Self::ClippingMask => "clippingMask", + Self::Transparency => "transparency", + } + } + pub fn parse(s: &str) -> Option { + match s { + "clippingMask" => Some(Self::ClippingMask), + "transparency" => Some(Self::Transparency), + _ => None, + } + } +} + +/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options +/// existed do (their appearance applies after the distortion); new envelopes start from +/// [`EnvelopeOptions::NEW`], as in the reference app. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct EnvelopeOptions { + /// Rasters: Anti-Alias. + pub anti_alias: bool, + /// Rasters: Preserve Shape Using. + pub preserve_shape: PreserveShape, + /// Distort Appearance: strokes and live effects are applied before the distortion, so they + /// bend with the art (off: they apply to the distorted paths). + pub distort_appearance: bool, + /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. + pub distort_linear_gradients: bool, + /// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art. + pub distort_pattern_fills: bool, +} + +impl Default for EnvelopeOptions { + fn default() -> Self { + Self { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: false, + } + } +} + +impl EnvelopeOptions { + /// The options a new envelope gets (the reference app's defaults). + pub const NEW: Self = Self { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: true, + distort_linear_gradients: false, + distort_pattern_fills: false, + }; + /// Do gradients bend with the art? + pub fn gradients(&self) -> bool { + self.distort_appearance && self.distort_linear_gradients + } + /// Do pattern tiles bend with the art? + pub fn patterns(&self) -> bool { + self.distort_appearance && self.distort_pattern_fills + } +} + +/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. +pub const H_RIGHT: usize = 0; + +pub const H_LEFT: usize = 1; + +pub const H_DOWN: usize = 2; + +pub const H_UP: usize = 3; + +/// The envelope's point map for content whose bounding box is `src` (page axes). +pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { + let s = surface_of(kind, src, Affine::IDENTITY); + let (w, h) = (src.width().max(1e-9), src.height().max(1e-9)); + Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h)) +} + +/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third +/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves +/// an envelope mesh without handles of its own runs along between its points. +pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> { + let (rows, cols) = (rows as usize, cols as usize); + let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default(); + let tangent = |prev: Option, p: Point, next: Option| match (prev, next) { + (Some(a), Some(b)) => (b - a) / 2.0, + (None, Some(b)) => b - p, + (Some(a), None) => p - a, + (None, None) => Vec2::ZERO, + }; + let mut out = Vec::with_capacity(points.len()); + for r in 0..=rows { + for c in 0..=cols { + let p = at(r, c); + let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0; + let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0; + // Handles pointing out of the grid are unused: they stay at the point. + let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO }; + let mut h = [Vec2::ZERO; 4]; + (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv)); + out.push(h); + } + } + out +} + +/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles +/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the +/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid. +pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option { + if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) { + return None; + } + let smooth; + let handles = if handles.len() == points.len() { + handles + } else { + smooth = smooth_handles(rows, cols, points); + &smooth + }; + let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect(); + Some(GradientMesh { rows, cols, points }) +} + +/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges +/// the outer patches go on). +pub(super) fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point { + let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64); + let (su, sv) = (u * cols as f64, v * rows as f64); + let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1)); + m.eval(r as usize, c as usize, su - c as f64, sv - r as f64) +} + +/// The envelope `id` is, or the innermost one it sits in. +pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { + doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. }))) +} + +/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid, +/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to +/// edit (its points and handles). +pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { + let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; + match &**kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let grid = envelope_grid(*rows, *cols, points, handles)?; + Some((grid.lines(), Some(grid))) + } + EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), + EnvelopeKind::TopObject { path } => Some((path.clone(), None)), + } +} + +/// The frame an envelope keeps after transform `m` (its frame so far composed with the +/// transform): identity when its axes stay square to the page, unflipped, since evaluating on +/// the page axes gives the same result then. +pub fn envelope_frame(m: Affine) -> Affine { + let [a, b, c, d, _, _] = m.as_coeffs(); + if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m } +} + +/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there, +/// onto the unit square and from there onto the envelope's surface ([`surface_of`]). +pub struct EnvelopeMap<'a> { + src: Rect, + frame: Affine, + to_frame: Affine, + surface: Box Point + 'a>, +} + +impl<'a> EnvelopeMap<'a> { + /// The map of an envelope of `kind` around `content` with `frame`. `None` when the content + /// has no bounds. + pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine) -> Option { + let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; + let to_frame = frame.inverse(); + let src = if frame == Affine::IDENTITY { + nodes_bounds(content)? + } else { + content.iter().try_fold(None, |acc, c| { + let mut m = (**c).clone(); + m.transform(to_frame, false); + Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) + })?? + }; + Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) + } + + /// The map of envelope node `n` (`None` for other nodes). + pub fn of(n: &'a Node) -> Option { + match &n.kind { + NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), + _ => None, + } + } + + /// The content's bounds in the envelope's frame. + pub fn src(&self) -> Rect { + self.src + } + + /// The surface point at (u, v) ∈ [0,1]². + pub fn at(&self, u: f64, v: f64) -> Point { + (self.surface)(u, v) + } + + /// Where the envelope puts document point `p`. + pub fn map(&self, p: Point) -> Point { + let q = self.to_frame * p; + let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9)); + self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h) + } + + /// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major). + pub fn grid(&self, rows: u32, cols: u32) -> Vec { + grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect() + } + + /// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose + /// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts + /// from it. + pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh { + GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color) + } + + /// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly). + fn bounds(&self) -> Option { + const N: usize = 16; + let mut r: Option = None; + for j in 0..=N { + for i in 0..=N { + let q = self.at(i as f64 / N as f64, j as f64 / N as f64); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r + } +} + +/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes +/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node). +pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { + expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into()) +} + +/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and +/// into how many pieces images are), the options what bends with the art. +pub(super) fn expand_envelope( + content: &[Arc], + kind: &EnvelopeKind, + fidelity: f64, + options: EnvelopeOptions, + frame: Affine, + hooks: Hooks, +) -> Vec { + let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() }; + let src = map.src(); + let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 }; + let f = |p: Point| map.map(p); + let w = Warper { + f: &f, + piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0), + eps: (src.width().max(src.height()) * 0.05).max(0.5), + cells: (4.0 + fid * 12.0).round() as usize, + hooks, + options, + depth: 0, + }; + content.iter().map(|c| map_node(c, &w)).collect() +} + +/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled). +pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine) -> Option { + match kind { + EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(), + EnvelopeKind::Mesh { points, .. } => { + let first = *points.first()?; + Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) + } + EnvelopeKind::TopObject { path } => path.bounds(), + EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(), + } +} diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs new file mode 100644 index 000000000..09eada113 --- /dev/null +++ b/crates/doc/src/live/expand.rs @@ -0,0 +1,444 @@ +use super::envelope::{EnvelopeOptions, expand_envelope}; +use super::mesh::GradientMesh; +use super::warp::map_nonlinear; +use super::*; + +/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. +pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; + +/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over +/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch. +pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option>; + +/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them). +#[derive(Clone, Copy, Default)] +pub struct Hooks<'a> { + /// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]). + pub outline: Outliner<'a>, + /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` + /// when it has none to bake). + pub appearance: Outliner<'a>, + /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). + pub pattern: Option>, + /// The document whose symbols' art counts toward the instance budget of a Repeat of their + /// instances ([`crate::RepeatSpec::expand`]). + pub symbols: Option<&'a crate::Document>, +} + +impl<'a> From> for Hooks<'a> { + fn from(outline: Outliner<'a>) -> Self { + Self { outline, ..Default::default() } + } +} + +/// The affine map that best matches `f` near `p` (finite differences with step `eps`). +pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine { + let o = f(p); + let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps); + let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps); + let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]); + Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) +} + +/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before +/// the rest is left as it is: a file can make a symbol contain itself. +const MAX_HOOK_DEPTH: u32 = 16; + +/// How an envelope pushes art through its point map. +#[derive(Clone, Copy)] +pub(super) struct Warper<'a> { + pub(super) f: &'a dyn Fn(Point) -> Point, + /// Longest piece a segment is cut into before mapping. + pub(super) piece: f64, + /// Finite-difference step for the local affine maps gradients follow. + pub(super) eps: f64, + /// Rows and columns of the pieces an image is cut into. + pub(super) cells: usize, + pub(super) hooks: Hooks<'a>, + pub(super) options: EnvelopeOptions, + /// How many hooks the art being mapped came through. + pub(super) depth: u32, +} + +impl Warper<'_> { + /// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]). + fn deeper(&self) -> Option { + (self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self }) + } +} + +/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency, +/// opacity mask and effects. +fn carry_look(from: &Node, mut to: Node) -> Node { + to.name = from.name.clone(); + to.opacity = from.opacity; + to.blend = from.blend; + to.isolate = from.isolate; + to.knockout = from.knockout; + to.knockout_shape = from.knockout_shape; + to.extras_mut().mask = from.extras().mask.clone(); + to.appearance.effects = from.appearance.effects.clone(); + to +} + +pub(super) fn map_node(n: &Node, w: &Warper) -> Node { + let deeper = w.deeper(); + // Distort Appearance: strokes and geometry effects bend with the art. + if w.options.distort_appearance + && let Some(d) = &deeper + && let Some(o) = w.hooks.appearance.and_then(|h| h(n)) + { + return map_node(&o, d); + } + // Distort Pattern Fills: the tiles bend with the art (not the patterns inside them). + if w.options.patterns() + && let Some(d) = &deeper + && let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p)) + { + let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d }; + return map_node(&o, &inner); + } + let mut out = n.clone(); + // Distort Linear Gradients: the gradient vectors follow the warp where they sit. + if w.options.gradients() + && let Some(b) = n.geometric_bounds() + { + out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); + } + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = Arc::new(map_node(&m.art, w)); + } + match &mut out.kind { + NodeKind::Path { path, live, .. } => { + *path = map_nonlinear(path, w.piece, w.f); + *live = None; + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + *c = Arc::new(map_node(c, w)); + } + } + NodeKind::Mesh(m) => m.map(w.f), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + if let Some(d) = &deeper { + return map_node(&expanded_group_hooks(n, w.hooks), d); + } + } + NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { + if let Some(d) = &deeper + && let Some(o) = w.hooks.outline.and_then(|h| h(n)) + { + return map_node(&carry_look(n, o), d); + } + } + NodeKind::Image(im) => { + let im = im.clone(); + return warp_image(&out, &im, w); + } + // Its art is plain, so it warps at this depth. + NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), + } + out +} + +/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate). +fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { + let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]); + let bs = basis(s); + if bs.determinant().abs() < 1e-12 { + return None; + } + let a = basis(d) * bs.inverse(); + a.is_finite().then_some(a) +} + +/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into +/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its +/// edges (at most half a point) so neighbours overlap and no seams show. +fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { + let k = w.cells.max(1); + // Two triangles a cell, three objects each; an image an inner envelope already cut into + // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image + // is left out. + if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![], clip: false }; + return g; + } + let (pw, ph) = (im.width as f64, im.height as f64); + let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); + let mut pieces = Vec::with_capacity(2 * k * k); + for j in 0..k { + for i in 0..k { + let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; + let d = s.map(|p| (w.f)(im.xf * p)); + for t in [[0, 1, 2], [0, 2, 3]] { + let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); + let Some(xf) = affine_between(st, dt) else { continue }; + let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0); + let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5); + let tri: Vec = dt + .iter() + .map(|p| { + let v = *p - c; + let len = v.hypot(); + if len > 1e-12 { *p + v * (grow / len) } else { *p } + }) + .collect(); + let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); + pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); + } + } + } + let mut g = n.clone(); + g.kind = NodeKind::Group { children: pieces, clip: false }; + g +} + +/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the +/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its +/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None` +/// without a pattern fill whose definition `pattern` finds. +fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { + let (path, rule, _) = crate::blend::node_path(n)?; + let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. })); + if !n.appearance.items.iter().any(pattern_fill) { + return None; + } + let bounds = path.bounds()?; + let def = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) if f.visible => match &f.paint { + Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)), + _ => None, + }, + _ => None, + }; + let plain = |items: Vec| { + let mut p = Node { + appearance: Appearance { items, ..Default::default() }.into(), + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + knockout_shape: false, + name: None, + ..n.clone() + }; + p.extras_mut().mask = None; + Arc::new(p) + }; + let mut pieces = vec![]; + let mut rest = vec![]; + for i in &n.appearance.items { + let Some((tiles, opacity, blend)) = def(i) else { + rest.push(i.clone()); + continue; + }; + if !rest.is_empty() { + pieces.push(plain(std::mem::take(&mut rest))); + } + let mut clip = Node::path(n.id, path.clone(), Appearance::default()); + if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind { + (*clipping, *r) = (true, rule); + } + let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: true }); + g.opacity = opacity; + g.blend = blend; + pieces.push(Arc::new(g)); + } + if !rest.is_empty() { + pieces.push(plain(rest)); + } + Some(Node { + kind: NodeKind::Group { children: pieces, clip: false }, + appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(), + ..n.clone() + }) +} + +// ===================================================================================== +// Expansion +// ===================================================================================== + +/// Is this one of the live kinds? +pub fn is_live(n: &Node) -> bool { + matches!( + n.kind, + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } + ) +} + +/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. +pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { + m.quads(n) + .into_iter() + .map(|q| { + let mut ap = Appearance::default(); + let mut fl = FillLayer::new(Paint::solid(q.color)); + fl.opacity = q.opacity; + ap.items.push(AppearanceItem::Fill(fl)); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&q.pts, true)), ap) + }) + .collect() +} + +/// One level of evaluation of a live object (non-live nodes return themselves). Blend steps and +/// envelope results may still contain live objects (e.g. a blend of two meshes). +pub fn expand_live(n: &Node) -> Vec { + expand_live_with(n, None) +} + +pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { + expand_live_hooks(n, outline.into()) +} + +/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]). +pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { + let _generating = Generating::enter(); + match &n.kind { + NodeKind::Blend { children, spec } => blend_expand(children, spec), + NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), + NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), + NodeKind::Repeat(r) => r.expand(hooks.symbols), + NodeKind::PlacedDocument(p) => p.art(n), + NodeKind::Intertwine { children } => crate::intertwine::expand(children), + _ => vec![n.clone()], + } +} + +/// A live object evaluated into a plain group (keeping the object's id, name and transparency). +pub fn expanded_group(n: &Node, outline: Outliner) -> Node { + expanded_group_hooks(n, outline.into()) +} + +/// [`expanded_group`] with [`Hooks`]. +pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { + let _generating = Generating::enter(); + let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); + g.name = n.name.clone(); + g.visible = n.visible; + g.locked = n.locked; + g.opacity = n.opacity; + g.blend = n.blend; + g.isolate = n.isolate; + g.knockout = n.knockout; + // Knocking out opaque blend steps changes nothing: don't make the outputs work for it. + if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) { + g.knockout = crate::Knockout::Off; + } + g.knockout_shape = n.knockout_shape; + g.extras_mut().mask = n.extras().mask.clone(); + g +} + +/// Recursively replace every live object in `n` by plain geometry (for exporters). +pub fn expand_deep(n: &Node, outline: Outliner) -> Node { + expand_deep_hooks(n, outline.into()) +} + +/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one +/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each +/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art +/// expands to nothing. +pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { + let _generating = Generating::enter(); + let mut out = if is_live(n) { + let g = expanded_group_hooks(n, hooks); + if !Generating::charge(g.count()) { + return Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); + } + g + } else { + n.clone() + }; + if let Some(ch) = out.children_mut() { + for c in ch.iter_mut() { + if subtree_has_live(c) { + *c = Arc::new(expand_deep_hooks(c, hooks)); + } + } + } + out +} + +/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything +/// inside them) may make. +pub const MAX_GENERATED: usize = 4_000_000; + +std::thread_local! { + /// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside + /// one. + static GENERATED_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it. +struct Generating(bool); + +impl Generating { + fn enter() -> Self { + let outermost = GENERATED_LEFT.get().is_none(); + if outermost { + GENERATED_LEFT.set(Some(MAX_GENERATED)); + } + Self(outermost) + } + + /// Spend `n` objects; `false` (and nothing left) when there aren't that many. + fn charge(n: usize) -> bool { + match GENERATED_LEFT.get() { + None => true, + Some(left) => { + GENERATED_LEFT.set(Some(left.saturating_sub(n))); + n <= left + } + } + } +} + +impl Drop for Generating { + fn drop(&mut self) { + if self.0 { + GENERATED_LEFT.set(None); + } + } +} + +/// Does `n` or any descendant need live evaluation? +pub fn subtree_has_live(n: &Node) -> bool { + let mut any = false; + n.walk(&mut |c| any |= is_live(c)); + any +} + +/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the +/// outsets in the art of the symbols its instances show count too +/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made +/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad +/// does. +pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxes>) -> f64 { + let mut o = 0.0f64; + for n in nodes { + n.walk(&mut |c| { + o = o.max(c.appearance.outset()); + if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind) + && let Some(art) = s.art(symbol) + { + let reach = if s.art_strokes() { art.reach } else { 0.0 }; + o = o.max(reach + s.brush_pad(symbol)); + } + }); + } + o +} diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs new file mode 100644 index 000000000..2cbe7602c --- /dev/null +++ b/crates/doc/src/live/mesh.rs @@ -0,0 +1,761 @@ +use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; +use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MeshPoint { + pub p: Point, + pub color: Color, + #[serde(default = "one")] + pub opacity: f32, + /// Tangent handles as offsets from `p` (see [`H_RIGHT`] …). + #[serde(default)] + pub handles: [Vec2; 4], +} + +/// A gradient mesh: `(rows+1)×(cols+1)` points, row-major. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientMesh { + pub rows: u32, + pub cols: u32, + pub points: Vec, +} + +/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and +/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads +/// meet without a step. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshQuad { + pub pts: [Point; 4], + pub color: Color, + pub opacity: f32, + pub corners: [(Color, f32); 4], +} + +/// Create Gradient Mesh → Appearance. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MeshAppearance { + #[default] + Flat, + ToCenter, + ToEdge, +} + +impl MeshAppearance { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "flat" => Some(Self::Flat), + "center" | "tocenter" | "to center" => Some(Self::ToCenter), + "edge" | "toedge" | "to edge" => Some(Self::ToEdge), + _ => None, + } + } +} + +/// Default envelope fidelity. +pub fn default_fidelity() -> f64 { + fifty() +} + +const SIDE: usize = 65; + +/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to +/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding +/// box corners. +#[derive(Clone, Debug)] +pub struct Coons { + top: Vec, + right: Vec, + bottom: Vec, + left: Vec, +} + +impl Coons { + /// A rectangle. + pub fn rect(r: Rect) -> Self { + let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::>(); + let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) } + } + + pub fn from_path(path: &PathData) -> Option { + // The largest subpath is the outline. + let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; + let r = path.bounds()?; + let tol = (r.width() + r.height()).max(1e-6) / 2000.0; + let mut poly = flatten_subpath(sp, tol); + if poly.len() > 1 + && let (Some(a), Some(b)) = (poly.first(), poly.last()) + && a.distance(*b) < 1e-9 + { + poly.pop(); + } + if poly.len() < 3 { + return Some(Self::rect(r)); + } + // Orient clockwise in y-down (TL → TR → BR → BL). + let area: f64 = (0..poly.len()) + .map(|i| { + let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); + p.x * q.y - q.x * p.y + }) + .sum(); + if area < 0.0 { + poly.reverse(); + } + let nearest = |c: Point| (0..poly.len()).min_by(|i, j| poly[*i].distance_squared(c).total_cmp(&poly[*j].distance_squared(c))).unwrap_or(0); + let tl = nearest(Point::new(r.x0, r.y0)); + let tr = nearest(Point::new(r.x1, r.y0)); + let br = nearest(Point::new(r.x1, r.y1)); + let bl = nearest(Point::new(r.x0, r.y1)); + let n = poly.len(); + let arc = |i: usize, j: usize| -> Vec { + let mut v = vec![poly[i]]; + let mut k = i; + while k != j { + k = (k + 1) % n; + v.push(poly[k]); + } + v + }; + let top = resample(&arc(tl, tr), SIDE); + let right = resample(&arc(tr, br), SIDE); + let mut bottom = resample(&arc(br, bl), SIDE); + bottom.reverse(); + let mut left = resample(&arc(bl, tl), SIDE); + left.reverse(); + Some(Self { top, right, bottom, left }) + } + + /// The point at `u` along a side; past its ends the end segments go on straight (art an + /// envelope bends can reach past the content's box: outlined strokes). + fn sample(side: &[Point], u: f64) -> Point { + let s = u * (side.len() - 1) as f64; + let i = (s.floor().max(0.0) as usize).min(side.len() - 2); + side[i].lerp(side[i + 1], s - i as f64) + } + + /// The surface point at (u, v) ∈ [0,1]². + pub fn eval(&self, u: f64, v: f64) -> Point { + let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v)); + let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]); + let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2() + - ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2()); + c.to_point() + } + + /// The four corners TL, TR, BR, BL. + pub fn corners(&self) -> [Point; 4] { + [self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]] + } +} + +// ===================================================================================== +// Gradient meshes +// ===================================================================================== + +/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. +fn even_steps(n: u32) -> Vec { + let n = n.clamp(1, 200); + (0..=n).map(|i| i as f64 / n as f64).collect() +} + +/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from +/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid. +pub(super) fn grid_len(rows: u32, cols: u32) -> Option { + (rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?) +} + +impl GradientMesh { + 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()) + } + + /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v + /// order: row 0 is v = 0, columns follow u), each with its handles ([`H_RIGHT`] …). + fn patch(corners: [(Point, [Vec2; 4], Color); 4]) -> Self { + let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles }; + let [a, b, c, d] = corners; + Self { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] } + } + + /// A 1×1 mesh from a Coons patch's twelve boundary points (in the order PDF and PostScript + /// shadings list them: v runs cp0 → cp3 along cp1 and cp2, u runs cp0 → cp9 along cp11 and + /// cp10) and its corner colours (at cp0, cp3, cp6, cp9); `None` if a point isn't finite. + pub fn coons(cp: &[Point; 12], colors: [Color; 4]) -> Option { + if !cp.iter().all(|p| p.is_finite()) { + return None; + } + let h = |a: usize, b: usize| cp[b] - cp[a]; + let handles = |right: Vec2, left: Vec2, down: Vec2, up: Vec2| { + let mut h = [Vec2::ZERO; 4]; + (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up); + h + }; + let [c0, c1, c2, c3] = colors; + Some(Self::patch([ + (cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0), + (cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1), + (cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2), + (cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3), + ])) + } + + /// A 1×1 mesh of a Gouraud-shaded triangle (a patch with two corners together); `None` if a + /// point isn't finite. + pub fn triangle(corners: [(Point, Color); 3]) -> Option { + if !corners.iter().all(|(p, _)| p.is_finite()) { + return None; + } + let [a, b, c] = corners.map(|(p, color)| (p, [Vec2::ZERO; 4], color)); + Some(Self::patch([a, b, c, c])) + } + + /// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives. + pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self { + let (us, vs) = (even_steps(cols), even_steps(rows)); + Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0)) + } + + /// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs` + /// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour + /// change), with handles from its partial derivatives. `color(row, col, point)` gives each + /// point its colour and opacity. + pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self { + let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1)); + let eps = 1e-3; + let mut points = Vec::with_capacity(us.len() * vs.len()); + for (r, &v) in vs.iter().enumerate() { + for (c, &u) in us.iter().enumerate() { + let p = s(u, v); + let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0)); + let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0)); + // A third of the way to the neighbouring line along the tangent; outward handles on + // the border are unused, so they stay at the point. + let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0)); + let handles = [ + h(d_u, u, us.get(c + 1)), + h(d_u, u, c.checked_sub(1).map(|i| &us[i])), + h(d_v, v, vs.get(r + 1)), + h(d_v, v, r.checked_sub(1).map(|i| &vs[i])), + ]; + let (color, opacity) = color(r, c, p); + points.push(MeshPoint { p, color, opacity, handles }); + } + } + Self { rows: rows as u32, cols: cols as u32, points } + } + + /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. + pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { + let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; + m.highlight(appearance, highlight); + Some(m) + } + + /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the + /// 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))) + } + + /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to + /// the edges, by at most `highlight` %. + pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { + let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; + let (rows, cols) = (self.rows as usize, self.cols as usize); + for r in 0..=rows { + for c in 0..=cols { + let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge + let w = match appearance { + MeshAppearance::Flat => 0.0, + MeshAppearance::ToCenter => (1.0 - d) * h, + MeshAppearance::ToEdge => d * h, + }; + if w > 0.0 { + let i = self.idx(r, c); + self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w); + } + } + } + } + + /// Horizontal edge from (r, c) to (r, c+1). + pub fn edge_u(&self, r: usize, c: usize) -> CubicBez { + let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)]); + CubicBez::new(a.p, a.p + a.handles[H_RIGHT], b.p + b.handles[H_LEFT], b.p) + } + /// Vertical edge from (r, c) to (r+1, c). + pub fn edge_v(&self, r: usize, c: usize) -> CubicBez { + let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r + 1, c)]); + CubicBez::new(a.p, a.p + a.handles[H_DOWN], b.p + b.handles[H_UP], b.p) + } + + /// Coons patch point of patch (r, c) at local (u, v). + pub fn eval(&self, r: usize, c: usize, u: f64, v: f64) -> Point { + let (t, b) = (self.edge_u(r, c).eval(u), self.edge_u(r + 1, c).eval(u)); + let (l, rr) = (self.edge_v(r, c).eval(v), self.edge_v(r, c + 1).eval(v)); + let p00 = self.points[self.idx(r, c)].p.to_vec2(); + let p10 = self.points[self.idx(r, c + 1)].p.to_vec2(); + let p01 = self.points[self.idx(r + 1, c)].p.to_vec2(); + let p11 = self.points[self.idx(r + 1, c + 1)].p.to_vec2(); + ((1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * rr.to_vec2() + - ((1.0 - u) * (1.0 - v) * p00 + u * (1.0 - v) * p10 + (1.0 - u) * v * p01 + u * v * p11)) + .to_point() + } + + /// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all + /// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out + /// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB. + pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) { + let q = + [&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]]; + let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v]; + let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum(); + let cmyk: Option> = + q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect(); + if let Some(inks) = cmyk { + let mut mix = [0.0f32; 4]; + for (ink, w) in inks.iter().zip(w) { + for (m, x) in mix.iter_mut().zip(ink) { + *m += x * w as f32; + } + } + let [c, m, y, k] = mix; + return (Color::Cmyk { c, m, y, k }, op); + } + let grey: Option> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect(); + if let Some(ks) = grey { + return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op); + } + let mut rgb = [0.0f32; 3]; + for (p, w) in q.iter().zip(w) { + for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) { + *ch += x * w as f32; + } + } + (Color::rgb(rgb[0], rgb[1], rgb[2]), op) + } + + /// Tessellate into `n`×`n` quads per patch, 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 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 { + for c in 0..self.cols as usize { + for j in 0..=n { + for i in 0..=n { + let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); + grid[j * (n + 1) + i] = self.eval(r, c, u, v); + colors[j * (n + 1) + i] = self.color_at(r, c, u, v); + } + } + for j in 0..n { + for i in 0..n { + let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); + let g = |a: usize, b: usize| grid[b * (n + 1) + a]; + let k = |a: usize, b: usize| colors[b * (n + 1) + a]; + out.push(MeshQuad { + pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], + color, + opacity, + corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)], + }); + } + } + } + } + out + } + + /// The mesh's outer boundary as a closed path. + pub fn outline(&self) -> PathData { + if !self.is_valid() { + return PathData::default(); + } + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut segs: Vec = vec![]; + for c in 0..cols { + segs.push(self.edge_u(0, c)); + } + for r in 0..rows { + segs.push(self.edge_v(r, cols)); + } + for c in (0..cols).rev() { + let e = self.edge_u(rows, c); + segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); + } + for r in (0..rows).rev() { + let e = self.edge_v(r, 0); + segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); + } + let mut anchors: Vec = Vec::with_capacity(segs.len()); + for (i, s) in segs.iter().enumerate() { + let prev = &segs[(i + segs.len() - 1) % segs.len()]; + anchors.push(Anchor::with_handles(s.p0, prev.p2, s.p1)); + } + PathData::single(SubPath::new(anchors, true)) + } + + /// Mesh lines (all row and column edges) as open paths, for outline view and editing overlays. + pub fn lines(&self) -> PathData { + if !self.is_valid() { + return PathData::default(); + } + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut subs = vec![]; + let cub = |e: CubicBez| SubPath::new(vec![Anchor::with_handles(e.p0, e.p0, e.p1), Anchor::with_handles(e.p3, e.p2, e.p3)], false); + for r in 0..=rows { + for c in 0..cols { + subs.push(cub(self.edge_u(r, c))); + } + } + for c in 0..=cols { + for r in 0..rows { + subs.push(cub(self.edge_v(r, c))); + } + } + PathData::new(subs) + } + + /// Control bounds (points and handles). + pub fn bounds(&self) -> Option { + let mut it = self.points.iter().flat_map(|p| std::iter::once(p.p).chain(p.handles.iter().map(move |h| p.p + *h))); + let first = it.next()?; + Some(it.fold(Rect::from_points(first, first), |r, q| r.union_pt(q))) + } + + pub fn transform(&mut self, a: Affine) { + let lin = Affine::new({ + let c = a.as_coeffs(); + [c[0], c[1], c[2], c[3], 0.0, 0.0] + }); + for p in &mut self.points { + p.p = a * p.p; + for h in &mut p.handles { + *h = (lin * h.to_point()).to_vec2(); + } + } + } + + /// Map points (and handle ends) through a non-linear function. + pub fn map(&mut self, f: &dyn Fn(Point) -> Point) { + for p in &mut self.points { + let q = f(p.p); + for h in &mut p.handles { + *h = f(p.p + *h) - q; + } + p.p = q; + } + } + + /// 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)> { + if !self.is_valid() { + return None; + } + let bp = self.outline().to_bezpath(); + if bp.winding(p) == 0 { + return None; + } + const N: usize = 16; + let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); + for r in 0..self.rows as usize { + for c in 0..self.cols as usize { + for j in 0..=N { + for i in 0..=N { + let (u, v) = (i as f64 / N as f64, j as f64 / N as f64); + let d = self.eval(r, c, u, v).distance_squared(p); + if d < best.0 { + best = (d, r, c, u, v); + } + } + } + } + } + // Refine with a few Newton-free local searches. + let (_, r, c, mut u, mut v) = best; + let mut step = 0.5 / N as f64; + for _ in 0..12 { + let mut improved = false; + for (du, dv) in [(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] { + let (nu, nv) = ((u + du).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)); + if self.eval(r, c, nu, nv).distance_squared(p) < self.eval(r, c, u, v).distance_squared(p) { + u = nu; + v = nv; + improved = true; + } + } + if !improved { + step /= 2.0; + } + } + Some((r, c, u, v)) + } + + /// Index of the mesh point within `tol` of `p`. + pub fn point_near(&self, p: Point, tol: f64) -> Option { + self.points + .iter() + .enumerate() + .filter(|(_, q)| q.p.distance(p) <= tol) + .min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p))) + .map(|(i, _)| i) + } + + fn surface_du(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { + let e = 1e-3; + let (a, b) = ((u - e).max(0.0), (u + e).min(1.0)); + (self.eval(r, c, b, v) - self.eval(r, c, a, v)) / (b - a) + } + fn surface_dv(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { + let e = 1e-3; + let (a, b) = ((v - e).max(0.0), (v + e).min(1.0)); + (self.eval(r, c, u, b) - self.eval(r, c, u, a)) / (b - a) + } + + /// Insert a mesh row through patch row `r` at local `v`. + pub fn insert_row(&mut self, r: usize, v: f64) { + let cols = self.cols as usize; + let v = v.clamp(0.01, 0.99); + let mut row = Vec::with_capacity(cols + 1); + // Split the vertical edges. + let mut ups = vec![]; + for c in 0..=cols { + let e = self.edge_v(r, c); + let (l, rr) = (e.subsegment(0.0..v), e.subsegment(v..1.0)); + ups.push((l.p1 - l.p0, rr.p2 - rr.p3)); + let pc = c.min(cols - 1); + let u = if c == cols { 1.0 } else { 0.0 }; + let (color, opacity) = self.color_at(r, pc, u, v); + // Horizontal handles: isocurve derivatives on either side. + let right = if c < cols { self.surface_du(r, c, 0.0, v) / (3.0) } else { Vec2::ZERO }; + let left = if c > 0 { -self.surface_du(r, c - 1, 1.0, v) / 3.0 } else { Vec2::ZERO }; + row.push(MeshPoint { p: l.p3, color, opacity, handles: [right, left, rr.p1 - rr.p0, l.p2 - l.p3] }); + } + for (c, (down, up)) in ups.into_iter().enumerate() { + let i0 = self.idx(r, c); + let i1 = self.idx(r + 1, c); + self.points[i0].handles[H_DOWN] = down; + self.points[i1].handles[H_UP] = up; + } + let at = self.idx(r + 1, 0); + self.points.splice(at..at, row); + self.rows += 1; + } + + /// Insert a mesh column through patch column `c` at local `u`. + pub fn insert_col(&mut self, c: usize, u: f64) { + let rows = self.rows as usize; + let u = u.clamp(0.01, 0.99); + let mut col = Vec::with_capacity(rows + 1); + for r in 0..=rows { + let e = self.edge_u(r, c); + let (l, rr) = (e.subsegment(0.0..u), e.subsegment(u..1.0)); + let pr = r.min(rows - 1); + let v = if r == rows { 1.0 } else { 0.0 }; + let (color, opacity) = self.color_at(pr, c, u, v); + let down = if r < rows { self.surface_dv(r, c, u, 0.0) / 3.0 } else { Vec2::ZERO }; + let up = if r > 0 { -self.surface_dv(r - 1, c, u, 1.0) / 3.0 } else { Vec2::ZERO }; + col.push((MeshPoint { p: l.p3, color, opacity, handles: [rr.p1 - rr.p0, l.p2 - l.p3, down, up] }, l.p1 - l.p0, rr.p2 - rr.p3)); + } + let cols = self.cols as usize; + let mut pts = Vec::with_capacity((rows + 1) * (cols + 2)); + for (r, (mp, right, left)) in col.into_iter().enumerate() { + for cc in 0..=cols { + let mut q = self.points[r * (cols + 1) + cc].clone(); + if cc == c { + q.handles[H_RIGHT] = right; + } + if cc == c + 1 { + q.handles[H_LEFT] = left; + } + pts.push(q); + if cc == c { + pts.push(mp.clone()); + } + } + } + self.points = pts; + 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)?; + let mut rr = r; + let mut cc = c; + let row_new = v > 0.02 && v < 0.98; + let col_new = u > 0.02 && u < 0.98; + if row_new { + self.insert_row(r, v); + rr = r + 1; + } else if v >= 0.98 { + rr = r + 1; + } + if col_new { + self.insert_col(c, u); + cc = c + 1; + } else if u >= 0.98 { + cc = c + 1; + } + Some(self.idx(rr, cc)) + } + + /// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only). + pub fn remove_point_lines(&mut self, index: usize) -> bool { + // A mesh from a file may not have the points its rows and columns need: it is indexed below. + if !self.is_valid() || index >= self.points.len() { + return false; + } + let w = self.cols as usize + 1; + let (r, c) = (index / w, index % w); + let mut changed = false; + if r > 0 && r < self.rows as usize { + // Merge the edges above and below: keep the outer handles. + for cc in 0..w { + 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; + } + self.points.drain(r * w..(r + 1) * w); + self.rows -= 1; + changed = true; + } + let w = self.cols as usize + 1; + if c > 0 && c < self.cols as usize { + 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 mut k = 0; + self.points.retain(|_| { + let keep = k % w != c; + k += 1; + keep + }); + self.cols -= 1; + changed = true; + } + changed + } +} + +// ===================================================================================== +// Envelopes +// ===================================================================================== + +/// Union of the geometric bounds of `nodes`. +pub fn nodes_bounds(nodes: &[Arc]) -> Option { + nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())) +} + +/// A `(rows+1)×(cols+1)` grid over `r` (row-major). +pub fn grid_points(r: Rect, rows: u32, cols: u32) -> Vec { + let mut v = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); + for i in 0..=rows { + for j in 0..=cols { + v.push(Point::new(r.x0 + r.width() * j as f64 / cols.max(1) as f64, r.y0 + r.height() * i as f64 / rows.max(1) as f64)); + } + } + v +} + +fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 { + let t2 = t * t; + let t3 = t2 * t; + 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3) +} + +/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear +/// extrapolation at the borders so an undistorted grid is the identity, also past the square). +pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { + let at = |r: i64, c: i64| -> Vec2 { + let rr = r.clamp(0, rows as i64); + let cc = c.clamp(0, cols as i64); + let base = pts[rr as usize * (cols + 1) + cc as usize].to_vec2(); + // Linear extrapolation outside the grid. + let er = r - rr; + let ec = c - cc; + let mut p = base; + if er != 0 && rows >= 1 { + let inner = (rr - er.signum()).clamp(0, rows as i64); + p += (base - pts[inner as usize * (cols + 1) + cc as usize].to_vec2()) * er.abs() as f64; + } + if ec != 0 && cols >= 1 { + let inner = (cc - ec.signum()).clamp(0, cols as i64); + p += (base - pts[rr as usize * (cols + 1) + inner as usize].to_vec2()) * ec.abs() as f64; + } + p + }; + // Past the grid's edges the outer patches go on (art can reach past the content's box). + let (su, sv) = (u * cols as f64, v * rows as f64); + let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1)); + let (fu, fv) = (su - ci as f64, sv - ri as f64); + let mut rowsv = [Vec2::ZERO; 4]; + for (k, rv) in rowsv.iter_mut().enumerate() { + let r = ri - 1 + k as i64; + *rv = catmull(at(r, ci - 1), at(r, ci), at(r, ci + 1), at(r, ci + 2), fu); + } + catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point() +} + +/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in +/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's +/// axes; a mesh's points and a top object's path are in the document already. +pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { + let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); + match kind { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); + let b = bend.clamp(-100.0, 100.0) / 100.0; + let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + let c = src.center(); + let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); + let horizontal = *horizontal; + Box::new(move |u: f64, v: f64| { + let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0); + let (x2, y2) = if horizontal { + warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y) + } else { + let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x); + (b2, a) + }; + frame * Point::new(c.x + x2 * hw, c.y + y2 * hh) + }) + } + EnvelopeKind::Mesh { rows, cols, points, handles } => { + if !handles.is_empty() + && let Some(m) = envelope_grid(*rows, *cols, points, handles) + { + return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v)); + } + if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) { + return Box::new(flat); + } + let (rows, cols) = (*rows as usize, *cols as usize); + Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v)) + } + EnvelopeKind::TopObject { path } => match Coons::from_path(path) { + Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)), + None => Box::new(flat), + }, + } +} diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs new file mode 100644 index 000000000..cacf26ec5 --- /dev/null +++ b/crates/doc/src/live/warp.rs @@ -0,0 +1,253 @@ +use super::*; + +// ===================================================================================== +// Warp maps (shared with vectorcraft-effects' Warp effects) +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WarpStyle { + Arc, + ArcLower, + ArcUpper, + Arch, + Bulge, + ShellLower, + ShellUpper, + Flag, + Wave, + Fish, + Rise, + Fisheye, + Inflate, + Squeeze, + Twist, +} + +impl WarpStyle { + /// From the id suffix (`arc`, `arcLower`, …). + pub fn from_id(s: &str) -> Option { + use WarpStyle::*; + Some(match s { + "arc" => Arc, + "arcLower" => ArcLower, + "arcUpper" => ArcUpper, + "arch" => Arch, + "bulge" => Bulge, + "shellLower" => ShellLower, + "shellUpper" => ShellUpper, + "flag" => Flag, + "wave" => Wave, + "fish" => Fish, + "rise" => Rise, + "fisheye" => Fisheye, + "inflate" => Inflate, + "squeeze" => Squeeze, + "twist" => Twist, + _ => return None, + }) + } +} + +/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1); +/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real +/// proportions). +pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) { + use WarpStyle::*; + let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom + let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides + let (mut x2, mut y2) = match style { + Arc => { + if b.abs() < 1e-6 { + (x, y) + } else { + // Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in + // the box's real proportions (lengths in half-widths, so y counts `aspect` each): + // the edges are concentric arcs, and one that would pass the centre stops there + // instead of folding over (#1578). + let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let sweep = b.abs() * PI; + let r0 = 2.0 / sweep; + let yy = if b > 0.0 { y } else { -y } * k; + let a = x / r0; + let r = (r0 - yy).max(0.0); + let (s, c) = a.sin_cos(); + let (nx, ny) = (r * s, (r0 - r * c) / k); + (nx, if b > 0.0 { ny } else { -ny }) + } + } + ArcLower => (x, y + b * par * t), + ArcUpper => (x, y - b * par * (1.0 - t)), + Arch => (x, y - b * par), + Bulge => (x, y + b * par * y), + ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t), + ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)), + Flag => (x, y + 0.5 * b * (PI * x).sin()), + Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)), + Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())), + Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b), + Fisheye => { + let r2 = x * x + y * y; + let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 }; + (x * s, y * s) + } + Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)), + Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)), + Twist => { + let r = (x * x + y * y).sqrt(); + let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0); + let (s, c) = a.sin_cos(); + (x * c - y * s, x * s + y * c) + } + }; + // Perspective-like distortion: horizontal narrows one side, vertical one end. + if dh != 0.0 { + y2 *= (1.0 + dh * x2 * 0.5).max(0.0); + } + if dv != 0.0 { + x2 *= (1.0 + dv * y2 * 0.5).max(0.0); + } + (x2, y2) +} + +/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. +fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { + let c = sp.segment(i); + if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c } +} + +fn poly_len(c: &CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point). +fn cumulative_lengths(pts: &[Point]) -> Vec { + let mut total = 0.0; + let mut cum = Vec::with_capacity(pts.len()); + cum.push(0.0); + for w in pts.windows(2) { + total += w[0].distance(w[1]); + cum.push(total); + } + cum +} + +/// Most pieces [`map_nonlinear`] splits one segment into. +const MAX_PIECES: usize = 64; + +/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`. +pub fn pieces(len: f64, piece: f64, most: usize) -> usize { + ((len / piece).ceil() as usize).clamp(1, most.max(1)) +} + +/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so +/// that the pieces past each segment's first number at most `room`: `piece` when they already do, +/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path, +/// so pieces of the total length over `room` fit; with no room every segment stays one piece. +pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 { + let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add); + if added <= room { + return piece; + } + if room == 0 { + return f64::INFINITY; + } + // A NaN length is always one piece. + piece.max(lens.iter().filter(|len| !len.is_nan()).sum::() / room as f64) +} + +/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces +/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which +/// approximates the image curve to O(h²). Closedness and subpath structure are preserved. +pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData { + map_nonlinear_within(path, max_piece, usize::MAX, f) +} + +/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more: +/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]), +/// a coarser approximation of the image curve. Geometry effects map within their anchor budget. +pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData { + let mut max_piece = max_piece.max(1e-3); + // Each segment adds one anchor per piece past its first. + let room = max.saturating_sub(path.anchor_count()); + let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add); + if segs.saturating_mul(MAX_PIECES - 1) > room { + let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect(); + max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room); + } + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + let segs = sp.segment_count(); + let mut res: Vec = Vec::new(); + let mut pending_in: Option = None; + for i in 0..n { + let a = &sp.anchors[i]; + let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in)); + res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind }); + if i < segs { + let c = seg_cubic(sp, i); + let k = pieces(poly_len(&c), max_piece, MAX_PIECES); + for j in 0..k { + let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64)); + if let Some(last) = res.last_mut() { + last.h_out = f(sub.p1); + } + if j + 1 < k { + res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() }); + } else { + pending_in = Some(f(sub.p2)); + } + } + } + } + if let Some(h) = pending_in + && sp.closed + && let Some(first) = res.first_mut() + { + first.h_in = h; + } + let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect(); + subs.push(SubPath::new(anchors, sp.closed)); + } + PathData::new(subs) +} + +// ===================================================================================== +// Coons patches from outlines +// ===================================================================================== + +pub(super) fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let mut pts: Vec = Vec::new(); + vectorcraft_geom::kurbo::flatten(bp.iter(), tol, |el| match el { + vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => pts.push(p), + _ => {} + }); + pts +} + +pub(super) fn resample(poly: &[Point], m: usize) -> Vec { + if poly.is_empty() { + return vec![Point::ZERO; m]; + } + if poly.len() == 1 { + return vec![poly[0]; m]; + } + let cum = cumulative_lengths(poly); + let total = cum.last().copied().unwrap_or(0.0); + let mut out = Vec::with_capacity(m); + let mut i = 0; + for k in 0..m { + let s = if m > 1 { total * k as f64 / (m - 1) as f64 } else { 0.0 }; + while i + 2 < cum.len() && cum[i + 1] < s { + i += 1; + } + let seg = (cum[i + 1] - cum[i]).max(1e-12); + out.push(poly[i].lerp(poly[i + 1], ((s - cum[i]) / seg).clamp(0.0, 1.0))); + } + out +} diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9eebb57f2..2be25e0b1 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -14,530 +14,24 @@ use crate::pattern::RepeatSpec; use crate::symbols::{SYMBOL_SQUARE, SymbolBoxes}; use crate::text::TextObject; -/// Stable per-document object id. Never reused. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct NodeId(pub u64); +mod attributes; +mod clip; +mod extras; +mod ids; +mod kind; +mod live_shape; +mod opacity; +mod ops; +mod wire; -impl std::fmt::Display for NodeId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "#{}", self.0) - } -} - -/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum LayerColor { - Preset(u8), - Custom([u8; 3]), -} - -/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue. -pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [ - ("Light Blue", [0x4f, 0x80, 0xff]), - ("Red", [0xff, 0x4f, 0x4f]), - ("Green", [0x4f, 0xff, 0x4f]), - ("Blue", [0x4f, 0x4f, 0xff]), - ("Yellow", [0xff, 0xff, 0x4f]), - ("Magenta", [0xff, 0x4f, 0xff]), - ("Cyan", [0x4f, 0xff, 0xff]), - ("Gray", [0x80, 0x80, 0x80]), - ("Black", [0x00, 0x00, 0x00]), - ("Orange", [0xff, 0x66, 0x00]), - ("Dark Green", [0x00, 0x80, 0x00]), - ("Teal", [0x00, 0x80, 0x80]), - ("Tan", [0xcc, 0x99, 0x66]), - ("Brown", [0x99, 0x33, 0x00]), - ("Violet", [0x99, 0x33, 0xff]), - ("Gold", [0xff, 0x99, 0x00]), - ("Dark Blue", [0x00, 0x00, 0x80]), - ("Pink", [0xff, 0x99, 0xcc]), - ("Lavender", [0x99, 0x99, 0xff]), - ("Brick Red", [0x99, 0x00, 0x00]), - ("Olive Green", [0x66, 0x66, 0x00]), - ("Peach", [0xff, 0x99, 0x99]), - ("Burgundy", [0x99, 0x00, 0x33]), - ("Grass Green", [0x99, 0xcc, 0x00]), - ("Ochre", [0x99, 0x66, 0x00]), - ("Purple", [0x66, 0x00, 0x99]), - ("Light Gray", [0xbb, 0xbb, 0xbb]), -]; - -impl LayerColor { - pub fn rgb(self) -> [u8; 3] { - match self { - LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1, - LayerColor::Custom(c) => c, - } - } -} - -/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these -/// while the shape stays live; editing anchors directly converts it to a plain path. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "shape", rename_all = "lowercase")] -pub enum LiveShape { - Rectangle { - /// Untransformed width/height. Transforms fold their scale into these (see - /// [`LiveShape::transform`]), so they are document lengths unless `xf` shears. - w: f64, - h: f64, - /// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as - /// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale. - radii: [f64; 4], - /// Corner kinds, in the same order (Live Corners' corner types). - #[serde(default, skip_serializing_if = "all_round")] - kinds: [CornerKind; 4], - /// When true, `radii` are fractions in 0..1 of each corner's largest possible radius. - #[serde(default, skip_serializing_if = "is_false")] - radii_relative: bool, - /// Maps the untransformed shape (origin at its top-left) into the document. A rotation - /// or reflection plus a move, unless a shear (or a file saved before transforms folded - /// their scale into `w`/`h`) put more in it. - xf: Affine, - }, - Ellipse { - w: f64, - h: f64, - /// Pie start/end angles in degrees (0/360 = full ellipse). - pie: (f64, f64), - xf: Affine, - }, - Polygon { - radius: f64, - /// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read. - #[serde(deserialize_with = "de_sides")] - sides: u32, - xf: Affine, - /// Corner radii in document units, by vertex from the first one clockwise (none: sharp). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - radii: Vec, - /// Corner kinds, in the same order (none: round). - #[serde(default, skip_serializing_if = "all_round")] - kinds: Vec, - #[serde(default, skip_serializing_if = "is_false")] - radii_relative: bool, - }, - Line { - a: Point, - b: Point, - }, - /// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut - /// outline so the corners stay editable. Without a cut corner it is a plain path again. - Path { - /// The path with its corners uncut, in the document. - base: PathData, - /// Corner radii in document units, by anchor index of `base` (counting every subpath's - /// anchors in order; missing: sharp). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - radii: Vec, - /// Corner kinds, in the same order (missing: round). - #[serde(default, skip_serializing_if = "all_round")] - kinds: Vec, - #[serde(default, skip_serializing_if = "is_false")] - radii_relative: bool, - }, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -impl LiveShape { - pub fn radii_relative(&self) -> bool { - match self { - LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => { - *radii_relative - } - LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false, - } - } - pub fn label(&self) -> &'static str { - match self { - LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", - LiveShape::Rectangle { .. } => "Rectangle", - LiveShape::Ellipse { .. } => "Ellipse", - LiveShape::Polygon { .. } => "Polygon", - LiveShape::Line { .. } => "Line", - LiveShape::Path { .. } => "Path", - } - } - /// Regenerate the path. - pub fn to_path(&self) -> PathData { - match self { - LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => { - let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); - let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative); - let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); - shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf) - } - LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), - LiveShape::Polygon { radii, kinds, radii_relative, .. } => { - let outline = self.polygon_outline(); - let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative); - cut_corners(&outline, &radii, kinds).0 - } - LiveShape::Line { a, b } => shapes::line(*a, *b), - LiveShape::Path { base, radii, kinds, radii_relative } => { - let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative); - cut_corners(base, &radii, kinds).0 - } - } - } - /// A polygon's outline with its corners uncut, in the document (empty for other shapes). - pub fn polygon_outline(&self) -> PathData { - match self { - LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf), - _ => PathData::default(), - } - } - /// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else - /// the corners past the old last one are sharp. - pub fn set_sides(&mut self, n: u64) { - let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return }; - *sides = n.clamp(3, 1000) as u32; - fn spread(v: &mut Vec, n: usize) { - match v.first().copied() { - Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n], - Some(_) => v.resize(n, T::default()), - None => {} - } - } - spread(radii, *sides as usize); - spread(kinds, *sides as usize); - } - /// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of - /// `xf` (exact while its sides are equal). None for other shapes. - pub fn polygon_radius(&self) -> Option { - let LiveShape::Polygon { radius, xf, .. } = self else { return None }; - Some(radius * xf.determinant().abs().sqrt()) - } - /// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by - /// from straight up (the Rotate field's sense). None for other shapes. - pub fn polygon_angle(&self) -> Option { - let LiveShape::Polygon { .. } = self else { return None }; - // No −0° or 360° from rounding: a polygon drawn upright reads 0°. - let a = (-self.angle_deg()).rem_euclid(360.0); - Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 }) - } - /// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True - /// for other shapes. - pub fn polygon_sides_equal(&self) -> bool { - let LiveShape::Polygon { xf, .. } = self else { return true }; - let [a, b, c, d, _, _] = xf.as_coeffs(); - let (sx, sy) = (a.hypot(b), c.hypot(d)); - (sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy - } - /// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]). - pub fn set_polygon_radius(&mut self, r: f64) { - let LiveShape::Polygon { radius, xf, .. } = self else { return }; - let k = xf.determinant().abs().sqrt(); - if k > 1e-12 && r.is_finite() { - *radius = r.max(0.0) / k; - } - } - /// The document radius that makes a polygon's sides `side` long (with its sides equal). - pub fn polygon_radius_for_side(&self, side: f64) -> Option { - let LiveShape::Polygon { sides, .. } = self else { return None }; - Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin())) - } - /// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]). - pub fn set_polygon_angle(&mut self, angle: f64) { - let Some(now) = self.polygon_angle() else { return }; - if let LiveShape::Polygon { xf, .. } = self - && angle.is_finite() - { - let centre = *xf * Point::ZERO; - *xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf; - } - } - /// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from - /// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`]. - pub fn make_sides_equal(&mut self) { - let Some(r) = self.polygon_radius() else { return }; - let LiveShape::Polygon { radius, xf, .. } = self else { return }; - let [a, b, _, _, e, f] = xf.as_coeffs(); - let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY }; - *xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip; - *radius = r; - } - /// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so - /// the caller must regenerate the path with [`LiveShape::to_path`]. - /// - /// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own - /// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps - /// only the rotation, reflection and position. The radii scale by the mean scale (the square - /// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off. - /// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a - /// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a - /// path's corner radii are document lengths: they scale by the mean scale and the corners are - /// cut again, circular whatever `a` does. - pub fn transform(&mut self, a: Affine) -> bool { - match self { - LiveShape::Rectangle { xf, .. } => { - *xf = a * *xf; - !moves_only(a) && self.fold_scale() - } - LiveShape::Ellipse { xf, .. } => { - *xf = a * *xf; - false - } - LiveShape::Polygon { xf, radii, radii_relative, .. } => { - *xf = a * *xf; - scale_radii(radii, a, *radii_relative) - } - LiveShape::Path { base, radii, radii_relative, .. } => { - base.transform(a); - scale_radii(radii, a, *radii_relative) - } - LiveShape::Line { a: p, b } => { - *p = a * *p; - *b = a * *b; - false - } - } - } - /// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so - /// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the - /// document. False (and nothing changes) when there is no scale to move, when `xf` shears or - /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an - /// uneven scale in `xf`: their corners are elliptical until this runs. - pub fn fold_scale(&mut self) -> bool { - let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false }; - let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); - let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); - let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; - let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite(); - let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12; - if !orthogonal || !finite || unscaled { - return false; - } - let k = (sx * sy).sqrt(); - if !*radii_relative { - radii.iter_mut().for_each(|r| *r *= k); - } - *w *= sx; - *h *= sy; - *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); - true - } - /// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document - /// units (what Live Corners show and edit). - pub fn folded(&self) -> Self { - let mut s = self.clone(); - s.fold_scale(); - s - } - /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners - /// keep their size (Scale Corners off). False when nothing changed. - pub fn keep_corners(&mut self, k: f64) -> bool { - let relative = self.radii_relative(); - let radii: &mut [f64] = match self { - LiveShape::Rectangle { radii, .. } => radii, - LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii, - LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false, - }; - if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { - return false; - } - radii.iter_mut().for_each(|r| *r /= k); - true - } - /// Rotation angle of the live shape in degrees, clockwise on the page (y points down). - pub fn angle_deg(&self) -> f64 { - match self { - LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => { - let c = xf.as_coeffs(); - c[1].atan2(c[0]).to_degrees() - } - LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(), - LiveShape::Path { .. } => 0.0, - } - } -} - -fn all_round(kinds: &[CornerKind]) -> bool { - kinds.iter().all(|k| *k == CornerKind::Round) -} - -/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its -/// corner lists hold one entry per side, so a huge count would allocate gigabytes. -fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - u32::deserialize(d).map(|n| n.clamp(3, 1000)) -} - -/// Whether `a` only moves (no rotation, scale, shear or reflection). -fn moves_only(a: Affine) -> bool { - let [a0, a1, a2, a3, _, _] = a.as_coeffs(); - (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12 -} - -/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True -/// when the path must be cut again: some corner is cut and `a` does more than move it (an -/// uneven scale would otherwise make the cuts elliptical). -fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool { - if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) { - return false; - } - let k = a.determinant().abs().sqrt(); - radii.iter_mut().for_each(|r| *r *= k); - true -} - -/// What a transform scales besides geometry, by its mean scale (the square root of its -/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke -/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects -/// and Scale Corners. -#[derive(Clone, Copy, Debug, Default)] -pub struct Scaling { - /// Stroke weights, dash lengths and dash offsets scale. - pub strokes: bool, - /// With `strokes`: scales an effect's distance parameters (the effects catalogue's). - pub effects: Option, - /// Type keeps its character strokes' weight (they otherwise scale with the type). - pub keep_type_strokes: bool, - /// Live corner radii keep their size. - pub keep_corners: bool, - /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform - /// Pattern Tiles is its default): their tiles transform too, instead of staying put. - pub patterns: bool, -} - -impl Scaling { - /// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by. - pub fn factor(a: Affine) -> Option { - let k = a.determinant().abs().sqrt(); - ((k - 1.0).abs() > 1e-9).then_some(k) - } -} - -impl From for Scaling { - fn from(strokes: bool) -> Self { - Self { strokes, ..Self::default() } - } -} - -/// Embedded or linked raster image. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ImageObject { - /// Key into [`crate::Document::images`]. - pub key: String, - pub width: u32, - pub height: u32, - /// Maps pixel space (0..w, 0..h) into the document. - pub xf: Affine, - /// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved - /// before links had details hold just the path. - #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")] - pub link: Option, - /// Links panel → Placement Options: how a file read again takes this image's place. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub placement: crate::links::PlacementOptions, - /// The pixels of an image in art made outside a document (type's outlines, a glyph's art), - /// which has no [`crate::Document::images`] to keep them in. A document takes them in - /// ([`crate::Document::adopt_pixels`]): no image in one holds its own. - #[serde(skip)] - pub pixels: Option>, -} - -/// Written with its `type` first, then its fields (an internally tagged enum); read by -/// [`kind_de`], which takes the fields straight from the file when `type` comes first. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum NodeKind { - /// A top-level layer or sublayer. - Layer { - color: LayerColor, - #[serde(default)] - template: bool, - #[serde(default = "yes")] - printable: bool, - children: Vec>, - /// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - clip: bool, - /// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen - /// (Ctrl-click its eye in the Layers panel). - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] - preview: bool, - /// Layer Options → Dim Images to: on screen, images on the layer show faded to this - /// percentage (0–100); `None`: not dimmed. - #[serde(default, skip_serializing_if = "Option::is_none")] - dim_images: Option, - }, - /// A group. With `clip`, the first (bottom-most) child is the clipping path. - Group { - children: Vec>, - #[serde(default)] - clip: bool, - }, - Path { - path: PathData, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - rule: FillRule, - /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. - #[serde(default, skip_serializing_if = "Option::is_none")] - live: Option>, - /// This path acts as a clipping path inside a clip group. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - clipping: bool, - /// Guide (non-printing) path. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - guide: bool, - }, - /// Compound path: children are paths painted as one with the compound's appearance. - Compound { - children: Vec>, - #[serde(default)] - rule: FillRule, - }, - Text(Box), - /// Boxed, as the other large kinds: a node is as large as its largest kind. - Image(Box), - SymbolInstance { - symbol: String, - xf: Affine, - }, - /// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate - /// steps are evaluated on demand (`live::blend_expand`). - Blend { - children: Vec>, - #[serde(default)] - spec: Box, - }, - /// Live envelope distortion of `content`. - Envelope { - content: Vec>, - kind: Box, - /// Envelope Options → Fidelity (0–100). - #[serde(default = "crate::live::default_fidelity")] - fidelity: f64, - /// Edit Contents mode (the content, not the envelope, is edited). - #[serde(default)] - editing: bool, - /// Envelope Options besides Fidelity. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - options: crate::live::EnvelopeOptions, - /// The envelope's own axes (→ the document): the transforms it took since it was made that - /// the page axes can't stand for (rotation, shear, reflection). The content maps from its - /// bounds in this frame, and a warp bends along it. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - frame: Box, - }, - /// Gradient mesh. - Mesh(GradientMesh), - /// Live Repeat (radial / grid / mirror) of source art. - Repeat(RepeatSpec), - /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` - /// file, linked and locked (see [`crate::placed_document`]). - PlacedDocument(Box), - /// Object › Intertwine: the objects keep their own stacking order, except where one of them - /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see - /// [`crate::intertwine`]). - Intertwine { - children: Vec>, - }, -} +pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; +pub use clip::{ClipPaint, Uniter}; +pub use extras::{Extras, ExtrasMut, NodeExtras}; +pub use ids::{LAYER_COLORS, LayerColor, NodeId}; +pub use kind::{ImageObject, NodeKind}; +pub use live_shape::{LiveShape, Scaling}; +pub use opacity::{Knockout, OpacityMask, whiten}; +use wire::NodeWire; fn yes() -> bool { true @@ -578,2029 +72,5 @@ pub struct Node { pub extras: Extras, } -/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without -/// them stay small ([`Node::extras`]). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct NodeExtras { - /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. - pub mask: Option>, - /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows - /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). - pub trace: Option>, - /// Object → Text Wrap: area type below this object (in the same layer) flows around it. - pub wrap: Option, - /// Graph object: the group's children are generated from this spec (Object → Graph). - pub graph: Option>, - /// Index of the graph series this generated group belongs to. Axes, the legend and other - /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. - pub series_index: Option, - /// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale - /// the placed gradients in it with the outlines. - pub type_outlines: bool, - /// Editable Shaper composition; the original art is retained in its source child. - pub shaper: Option>, - /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps - /// that style's look: editing its appearance or transparency breaks the link. - pub graphic_style: Option, - /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). - pub attrs: Option>, - /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with - /// these options. - pub slice: Option>, - /// Object › Perspective: the perspective grid plane the object is attached to. - pub perspective: Option>, - /// Dimension object: the live measurement it draws; its ordinary group children hold its - /// exportable appearance (Object › Dimensions, the Dimension tool). - pub dimension: Option>, - /// 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>, - /// 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 - /// [`crate::objects_on_path`]). - pub objects_on_path: Option>, -} - -/// No extras: what [`Node::extras`] gives for a node without any. -static NO_EXTRAS: NodeExtras = NodeExtras { - mask: None, - trace: None, - wrap: None, - graph: None, - series_index: None, - type_outlines: false, - shaper: None, - graphic_style: None, - attrs: None, - slice: None, - perspective: None, - dimension: None, - intertwine: None, - live_paint_gaps: None, - objects_on_path: None, -}; - -/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal -/// none. -#[derive(Clone, Debug, Default)] -pub struct Extras(Option>); - -impl Extras { - fn get(&self) -> &NodeExtras { - self.0.as_deref().unwrap_or(&NO_EXTRAS) - } -} - -impl PartialEq for Extras { - fn eq(&self, other: &Self) -> bool { - match (&self.0, &other.0) { - (Some(a), Some(b)) => a == b, - _ => self.get() == other.get(), - } - } -} - -impl Node { - /// The rarely set parts of this node (all unset when it has none). - #[inline] - pub fn extras(&self) -> &NodeExtras { - self.extras.get() - } - /// The rarely set parts of this node, to change: when the change is done (the guard dropped), - /// extras that were set are allocated and extras left all unset are let go. Setting one to - /// `None` on a node without extras allocates nothing. - #[inline] - pub fn extras_mut(&mut self) -> ExtrasMut<'_> { - ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } - } - /// The rarely set parts of this node, if it has any: to change what is there in place. - #[inline] - pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { - self.extras.0.as_deref_mut() - } - /// The live shape of a path object, if it is one. - pub fn live_shape(&self) -> Option<&LiveShape> { - match &self.kind { - NodeKind::Path { live, .. } => live.as_deref(), - _ => None, - } - } - /// The live shape of a path object, if it is one, to change. - pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { - match &mut self.kind { - NodeKind::Path { live, .. } => live.as_deref_mut(), - _ => None, - } - } - /// This node with opacity mask `mask` (or none). - pub fn with_mask(mut self, mask: Option>) -> Self { - self.extras_mut().mask = mask; - self - } - /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). - pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { - self.extras.0.as_deref() - } -} - -/// [`Node::extras_mut`]: the node's extras while they change. -pub struct ExtrasMut<'a> { - slot: &'a mut Option>, - /// Stands for the extras of a node without any, boxed into it when dropped if one was set. - local: NodeExtras, -} - -impl std::ops::Deref for ExtrasMut<'_> { - type Target = NodeExtras; - fn deref(&self) -> &NodeExtras { - self.slot.as_deref().unwrap_or(&self.local) - } -} - -impl std::ops::DerefMut for ExtrasMut<'_> { - fn deref_mut(&mut self) -> &mut NodeExtras { - match self.slot.as_deref_mut() { - Some(e) => e, - None => &mut self.local, - } - } -} - -impl Drop for ExtrasMut<'_> { - fn drop(&mut self) { - match self.slot.as_deref() { - Some(e) if *e == NO_EXTRAS => *self.slot = None, - Some(_) => {} - None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), - None => {} - } - } -} - -/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from -/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. -impl Serialize for Node { - fn serialize(&self, s: S) -> Result { - use crate::skip::{is_default, is_one, is_true}; - use serde::ser::SerializeStruct; - let e = self.extras(); - // As the derive did: the fields written, then each field written or skipped in order. - let skips = [ - false, - self.name.is_none(), - is_true(&self.visible), - is_default(&self.locked), - is_one(&self.opacity), - is_default(&self.blend), - is_default(&self.isolate), - is_default(&self.knockout), - is_default(&self.knockout_shape), - is_default(&self.appearance), - e.mask.is_none(), - e.trace.is_none(), - e.wrap.is_none(), - e.graph.is_none(), - e.series_index.is_none(), - is_default(&e.type_outlines), - e.shaper.is_none(), - false, - e.graphic_style.is_none(), - e.attrs.is_none(), - e.slice.is_none(), - is_default(&self.bbox_angle), - e.perspective.is_none(), - e.dimension.is_none(), - e.intertwine.is_none(), - e.live_paint_gaps.is_none(), - e.objects_on_path.is_none(), - ]; - let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; - let mut skip = skips.into_iter(); - macro_rules! field { - ($name:literal, $v:expr) => { - if skip.next().unwrap_or(false) { - st.skip_field($name)?; - } else { - st.serialize_field($name, $v)?; - } - }; - } - field!("id", &self.id); - field!("name", &self.name); - field!("visible", &self.visible); - field!("locked", &self.locked); - field!("opacity", &self.opacity); - field!("blend", &self.blend); - field!("isolate", &self.isolate); - field!("knockout", &self.knockout); - field!("knockout_shape", &self.knockout_shape); - field!("appearance", &self.appearance); - field!("mask", &e.mask); - field!("trace", &e.trace); - field!("wrap", &e.wrap); - field!("graph", &e.graph); - field!("series_index", &e.series_index); - field!("type_outlines", &e.type_outlines); - field!("shaper", &e.shaper); - field!("kind", &self.kind); - field!("graphic_style", &e.graphic_style); - field!("attrs", &e.attrs); - field!("slice", &e.slice); - field!("bbox_angle", &self.bbox_angle); - field!("perspective", &e.perspective); - field!("dimension", &e.dimension); - field!("intertwine", &e.intertwine); - field!("live_paint_gaps", &e.live_paint_gaps); - field!("objects_on_path", &e.objects_on_path); - st.end() - } -} - -/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node -/// (and its extras, allocated only when one is set) without a copy of what they own. -#[derive(Deserialize)] -#[serde(rename = "Node")] -struct NodeWire { - id: NodeId, - #[serde(default)] - name: Option, - #[serde(default = "yes")] - visible: bool, - #[serde(default)] - locked: bool, - #[serde(default = "one")] - opacity: f32, - #[serde(default)] - blend: BlendMode, - #[serde(default)] - isolate: bool, - #[serde(default)] - knockout: Knockout, - #[serde(default)] - knockout_shape: bool, - #[serde(default)] - appearance: crate::Shared, - #[serde(default)] - mask: Option>, - #[serde(default)] - trace: Option>, - #[serde(default)] - wrap: Option, - #[serde(default)] - graph: Option>, - #[serde(default)] - series_index: Option, - #[serde(default)] - type_outlines: bool, - #[serde(default)] - shaper: Option>, - kind: NodeKind, - #[serde(default)] - graphic_style: Option, - #[serde(default)] - attrs: Option>, - #[serde(default)] - slice: Option>, - #[serde(default)] - bbox_angle: f64, - #[serde(default)] - perspective: Option>, - #[serde(default)] - dimension: Option>, - #[serde(default)] - intertwine: Option>, - #[serde(default)] - live_paint_gaps: Option>, - #[serde(default)] - objects_on_path: Option>, -} - -impl From for Node { - fn from(w: NodeWire) -> Self { - let extras = NodeExtras { - mask: w.mask, - trace: w.trace, - wrap: w.wrap, - graph: w.graph, - series_index: w.series_index, - type_outlines: w.type_outlines, - shaper: w.shaper, - graphic_style: w.graphic_style, - attrs: w.attrs, - slice: w.slice, - perspective: w.perspective, - dimension: w.dimension, - intertwine: w.intertwine, - live_paint_gaps: w.live_paint_gaps, - objects_on_path: w.objects_on_path, - }; - Node { - id: w.id, - name: w.name, - visible: w.visible, - locked: w.locked, - opacity: w.opacity, - blend: w.blend, - isolate: w.isolate, - knockout: w.knockout, - knockout_shape: w.knockout_shape, - appearance: w.appearance, - kind: w.kind, - bbox_angle: w.bbox_angle, - extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), - } - } -} - -/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct OpacityMask { - /// Mask art in document coordinates. - pub art: Arc, - /// Clip: outside the mask art is hidden. Off: outside the mask art stays visible. - #[serde(default = "yes")] - pub clip: bool, - /// Invert the mask's luminance. - #[serde(default)] - pub invert: bool, - /// Disabled masks are kept but not applied. - #[serde(default)] - pub disabled: bool, - /// Linked masks move with the object. - #[serde(default = "yes")] - pub linked: bool, -} - -impl OpacityMask { - pub fn new(art: Node, clip: bool) -> Self { - Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true } - } -} - -/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity -/// mask's, whose luminance over the black outside the art is then the art's alpha. -pub fn whiten(n: &mut Node) { - let white = |p: &mut Paint| match p { - Paint::Solid { color, swatch, tint } => { - (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); - } - Paint::Gradient(g) => { - for s in &mut g.gradient.stops { - (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); - } - } - Paint::None | Paint::Pattern { .. } => {} - }; - for item in &mut n.appearance.items { - white(item.paint_mut()); - } - match &mut n.kind { - NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), - NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { - white(&mut r.style.fill); - white(&mut r.style.stroke); - }), - _ => {} - } - if let Some(ch) = n.children_mut() { - for c in ch { - whiten(Arc::make_mut(c)); - } - } -} - -impl Node { - pub fn new(id: NodeId, kind: NodeKind) -> Self { - Self { - id, - name: None, - visible: true, - locked: false, - opacity: 1.0, - blend: BlendMode::Normal, - isolate: false, - knockout: Knockout::Neutral, - knockout_shape: false, - appearance: Appearance::default().into(), - kind, - bbox_angle: 0.0, - extras: Extras::default(), - } - } - pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { - let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }); - n.appearance = appearance.into(); - n - } - pub fn group(id: NodeId, children: Vec>) -> Self { - Self::new(id, NodeKind::Group { children, clip: false }) - } - pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { - let mut n = Self::new( - id, - NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None }, - ); - n.name = Some(name.to_string()); - n - } - pub fn children(&self) -> Option<&Vec>> { - match &self.kind { - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } - | NodeKind::Repeat(RepeatSpec { source: children, .. }) - | NodeKind::Intertwine { children } => Some(children), - _ => None, - } - } - pub fn children_mut(&mut self) -> Option<&mut Vec>> { - match &mut self.kind { - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } - | NodeKind::Repeat(RepeatSpec { source: children, .. }) - | NodeKind::Intertwine { children } => Some(children), - _ => None, - } - } - /// Does this object or layer move with its artboard? Locked and hidden ones only with - /// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with - /// Artboard). - pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool { - locked_and_hidden || (!self.locked && self.visible) - } - pub fn is_layer(&self) -> bool { - matches!(self.kind, NodeKind::Layer { .. }) - } - /// A template layer (Layer Options → Template). - pub fn is_template(&self) -> bool { - matches!(self.kind, NodeKind::Layer { template: true, .. }) - } - /// The art objects of this container: its children, looking through sublayers, which are not - /// objects (bottom first). With `editable`, only the visible, unlocked objects in visible, - /// unlocked, non-template sublayers. - pub fn layer_art(&self, editable: bool) -> Vec { - fn visit(n: &Node, editable: bool, out: &mut Vec) { - for c in n.children().into_iter().flatten() { - if editable && (!c.visible || c.locked || c.is_template()) { - continue; - } - if c.is_layer() { - visit(c, editable, out); - } else { - out.push(c.id); - } - } - } - let mut out = vec![]; - visit(self, editable, &mut out); - out - } - pub fn is_container(&self) -> bool { - self.children().is_some() - } - /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel - /// fills an object's target circle otherwise). - pub fn has_default_transparency(&self) -> bool { - self.opacity >= 1.0 - && self.blend == BlendMode::Normal - && !self.isolate - && self.knockout == Knockout::Neutral - && !self.knockout_shape - && self.extras().mask.is_none() - } - /// Kind name as the Layers panel / Properties panel shows it. - pub fn kind_label(&self) -> &'static str { - match &self.kind { - NodeKind::Layer { .. } => "Layer", - NodeKind::Group { clip: true, .. } => "Clip Group", - NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path", - NodeKind::Group { .. } => "Group", - NodeKind::Path { live: Some(l), .. } => l.label(), - NodeKind::Path { guide: true, .. } => "Guide", - NodeKind::Path { .. } => "Path", - NodeKind::Compound { .. } => "Compound Path", - NodeKind::Text(_) => "Type", - NodeKind::Image(_) => "Image", - NodeKind::SymbolInstance { .. } => "Symbol", - NodeKind::Blend { .. } => "Blend", - NodeKind::Envelope { .. } => "Envelope", - NodeKind::Mesh(_) => "Mesh", - NodeKind::Repeat(r) => r.kind.label(), - NodeKind::PlacedDocument(_) => "Placed Document", - NodeKind::Intertwine { .. } => "Intertwine", - } - } - /// Name shown in the Layers panel: explicit name or ``. - pub fn display_name(&self) -> String { - if let Some(n) = &self.name { - return n.clone(); - } - match &self.kind { - NodeKind::Text(t) => { - let s: String = t.plain_text().chars().take(32).collect(); - if s.is_empty() { "".into() } else { s } - } - _ => format!("<{}>", self.kind_label()), - } - } - - /// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID. - pub fn identify_label(&self, xml_id: bool) -> String { - let name = self.display_name(); - if xml_id { crate::xml_id::from_object_name(&name) } else { name } - } - /// Path data for path nodes. - pub fn path_data(&self) -> Option<&PathData> { - match &self.kind { - NodeKind::Path { path, .. } => Some(path), - _ => None, - } - } - pub fn path_data_mut(&mut self) -> Option<&mut PathData> { - match &mut self.kind { - NodeKind::Path { path, .. } => Some(path), - _ => None, - } - } - /// The path a path's or compound path's own strokes follow (a compound's members' together). - pub fn stroke_path(&self) -> Option { - 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())); - Some(bp) - } - _ => None, - } - } - /// A quick box round everything the object can paint, for culling clicks: paths and compound - /// paths grow their geometric bounds by the farthest their strokes can reach from any path - /// ([`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`]. - pub fn reach_bounds(&self) -> Option { - self.reach(None) - } - /// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from - /// `symbols`. An instance that only moves its art is measured as the art's visual box moved, - /// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`]) - /// grown by the farthest a stroke in the art can reach. - pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { - symbols.quickly(|| self.reach(Some(symbols))) - } - /// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given. - pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option { - match &self.kind { - NodeKind::Path { .. } | NodeKind::Compound { .. } => { - let o = self.appearance.outset(); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - // The members' reach, grown by the container's own strokes. - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - let o = self.appearance.outset(); - children - .iter() - .skip(usize::from(self.extras().shaper.is_some())) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))) - .map(|b| b.inflate(o, o)) - } - NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))), - // The frame and each shape the image's own fills and strokes paint, a stroke's grown by - // the farthest it can reach from its shape. - NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| { - 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))) - }), - _ => self.visual(symbols), - } - } - /// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A - /// symbol instance is measured as its transform applied to the 20 × 20 square around the origin - /// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures - /// its art). - pub fn geometric_bounds(&self) -> Option { - self.geometric(None) - } - /// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to - /// its symbol's art box from `symbols`. An instance is measured as the square when its symbol - /// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and - /// when symbols nest 32 deep in its art. - pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option { - self.geometric(Some(symbols)) - } - /// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a - /// hidden bitmap for re-tracing/release; only source-image views measure that bitmap. - pub fn editing_children(&self) -> &[Arc] { - let children = self.children().map(Vec::as_slice).unwrap_or_default(); - let trace_source = self.extras().trace.is_some() - && !self.trace_view().shows_source() - && children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_))); - let skip = usize::from(self.extras().shaper.is_some() || trace_source); - children.get(skip..).unwrap_or_default() - } - - /// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols` - /// is given. - pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option { - let union = |nodes: &[Arc]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))); - match &self.kind { - NodeKind::Path { path, .. } => path.bounds(), - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - children.first().and_then(|c| c.geometric(symbols)) - } - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self - .editing_children() - .iter() - .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))), - NodeKind::Text(t) => self.projected(t.bounds()), - NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), - NodeKind::SymbolInstance { symbol, xf } => { - let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric); - self.projected(Some(xf.transform_rect_bbox(art))) - } - NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())), - NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), - NodeKind::Mesh(m) => m.bounds(), - // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the - // copies out by the source's bounds measured without the document. - NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())), - NodeKind::PlacedDocument(p) => Some(p.bounds()), - // The members drawn over the others are drawn where they are. - NodeKind::Intertwine { children } => union(children), - } - } - /// Visual bounds: what the object paints, its strokes included (paths and compound paths - /// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is - /// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`], - /// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art). - pub fn visual_bounds(&self) -> Option { - self.visual(None) - } - /// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from - /// `symbols`, the strokes in it included. An instance that only moves its art is measured as - /// the art's visual box moved. Any other instance is measured as its art transformed by it with - /// the strokes at their own weight, as the renderer draws it, and never smaller than its - /// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as - /// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of - /// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In - /// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's - /// geometric box placed by its transform, grown by its own strokes. - pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option { - self.visual(Some(symbols)) - } - /// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is - /// given. - pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option { - match &self.kind { - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - // A clipping path that paints (see `clip_paint`) strokes over the clip edge. - let clip = children.first()?; - if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { - clip.visual(symbols) - } else { - clip.geometric(symbols) - } - } - NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), - NodeKind::Layer { .. } | NodeKind::Group { .. } => { - // The container's own strokes paint around its members. - let o = self.appearance.outset(); - self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) - } - NodeKind::Blend { children, spec } => { - let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); - let o = crate::live::max_outset(children, symbols); - vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o))) - } - NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => { - let o = crate::live::max_outset(content, symbols); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - NodeKind::Path { .. } | NodeKind::Compound { .. } => { - let b = self.geometric(symbols)?; - Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default())) - } - NodeKind::SymbolInstance { symbol, xf } => { - let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) { - Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric), - Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), - Some((s, art)) => { - let placed = xf.transform_rect_bbox(art.geometric); - match s.placed_visual(symbol, *xf) { - Some(v) => v.union(placed), - None => placed.inflate(art.reach, art.reach), - } - } - None => xf.transform_rect_bbox(SYMBOL_SQUARE), - }; - let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol)); - self.projected(Some(b)).map(|b| b.inflate(o, o)) - } - NodeKind::Text(t) => { - // The characters' strokes paint around the glyphs. - let o = self.appearance.outset().max(t.char_stroke_outset()); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - // An image is measured by its frame, without its own fills and strokes. - NodeKind::Image(_) => self.geometric(symbols), - _ => { - let o = self.appearance.outset(); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - } - } - /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. - /// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights - /// only, see [`Scaling`]). - pub fn transform(&mut self, a: Affine, scaling: impl Into) { - self.transform_scaled(a, &scaling.into()); - } - fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { - // A dimension's measurement moves with it. - if let Some(e) = self.extras_if_any() - && let Some(dimension) = &mut e.dimension - { - 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); - } - if !self.is_layer() { - self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); - } - // Type and symbols in perspective keep looking the same, moved by `a`. - self.transform_projection(a); - // Refitting an unplaced gradient only reproduces moves and uniform scales. - if !keeps_gradient_fit(a) { - self.pin_gradients(); - } - let k = Scaling::factor(a); - if let Some(k) = k - && sc.strokes - { - self.appearance.scale_strokes(k); - if let Some(f) = sc.effects { - self.appearance.scale_effects(k, f); - } - } - // Only through `DerefMut` when there is something to change: the appearance is shared - // copy-on-write (`Shared`), and a move must not copy what it leaves as it is. - if self.appearance.gradients().next().is_some() { - self.appearance.transform_gradients(a); - } - if sc.patterns { - crate::pattern::transform_pattern_paints(self, a); - } - match &mut self.kind { - NodeKind::Path { path, live, .. } => { - path.transform(a); - if let Some(l) = live { - // A rectangle scaled unevenly keeps circular corners: its path is regenerated. - let reshaped = l.transform(a); - let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k)); - if reshaped || kept { - *path = l.to_path(); - } - } - } - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Intertwine { children } => { - for c in children.iter_mut() { - Arc::make_mut(c).transform_scaled(a, sc); - } - } - NodeKind::Text(t) => { - t.transform(a); - // Character strokes scale with the type: undo that when strokes keep their weight. - if sc.keep_type_strokes - && let Some(k) = k.filter(|k| *k > 1e-12) - { - t.scale_char_strokes(1.0 / k); - } - } - NodeKind::Image(im) => im.xf = a * im.xf, - NodeKind::PlacedDocument(p) => p.xf = a * p.xf, - NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, - NodeKind::Blend { children, spec } => { - for c in children.iter_mut() { - Arc::make_mut(c).transform_scaled(a, sc); - } - if let Some(s) = &mut spec.spine { - s.transform(a); - } - } - NodeKind::Envelope { content, kind, frame, .. } => { - for c in content.iter_mut() { - Arc::make_mut(c).transform_scaled(a, sc); - } - **frame = crate::live::envelope_frame(a * **frame); - match &mut **kind { - EnvelopeKind::Mesh { points, handles, .. } => { - for p in points.iter_mut() { - *p = a * *p; - } - // Handles are offsets: they take the linear part. - let [m0, m1, m2, m3, _, _] = a.as_coeffs(); - let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]); - for h in handles.iter_mut().flatten() { - *h = (lin * h.to_point()).to_vec2(); - } - } - EnvelopeKind::TopObject { path } => path.transform(a), - EnvelopeKind::Warp { .. } => {} - } - } - NodeKind::Mesh(m) => m.transform(a), - NodeKind::Repeat(r) => r.transform(a, *sc), - } - 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); - } - } - /// Visit this node and all descendants depth first (paint order). - pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { - f(self); - if let Some(ch) = self.children() { - for c in ch { - c.walk(f); - } - } - } - /// Count of nodes in this subtree. - pub fn count(&self) -> usize { - let mut n = 0; - self.walk(&mut |_| n += 1); - n - } - /// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying - /// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks. - pub fn count_with_masks(&self) -> usize { - let mut n = 0; - self.walk(&mut |c| { - n += 1; - if let Some(m) = &c.extras().mask { - n += m.art.count_with_masks(); - } - }); - n - } - /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform - /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. - pub fn pin_gradients(&mut self) { - if self.appearance.has_unplaced_gradient() - && let Some(b) = self.geometric_bounds() - { - self.appearance.pin_gradients(b); - } - } - /// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and - /// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active - /// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise - /// type objects paint their runs (the first run speaks for all) in text space, fitted to the - /// layout bounds; everything else paints its fill or stroke in the document, fitted to the - /// geometric bounds. Strokes fit the box grown by half their weight. - pub fn proxy_paint(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { - let item = self.appearance.item_of_kind(item, !stroke); - if item.is_none() - && let NodeKind::Text(t) = &self.kind - { - let st = &t.runs.first()?.style; - let b = t.local_bounds(); - return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); - } - // An object without bounds (an empty path) still shows its paints, fitted to an empty box. - let b = self.geometric_bounds().unwrap_or(Rect::ZERO); - if stroke { - self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) - } else { - self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b)) - } - } - /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates - /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. - pub fn proxy_gradient(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { - let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; - let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; - let mut geom = g.resolve(b); - if to_doc != Affine::IDENTITY { - geom.transform(to_doc, g.gradient.kind); - } - Some((g, geom)) - } - /// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates - /// (the automatic ones while unplaced), and the length its spreads are fractions of there - /// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits. - pub fn proxy_freeform(&self, stroke: bool, item: Option) -> Option<(vectorcraft_color::Freeform, f64)> { - let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; - let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; - if g.gradient.kind != vectorcraft_color::GradientKind::Freeform { - return None; - } - let mut f = g.freeform_on(b).into_owned(); - let mut scale = vectorcraft_color::freeform::spread_scale(b); - if to_doc != Affine::IDENTITY { - f.transform(to_doc); - scale *= to_doc.determinant().abs().sqrt(); - } - Some((f, scale)) - } -} - -/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans -/// live above this crate (`vectorcraft-pathops`), so callers supply it. -pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath; - -impl Node { - /// The filled regions this object clips to as the clipping path of a clip group, in document - /// space, each with its fill rule: a path, a compound path (with its holes), an image's frame, - /// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible - /// members of a group (their union) and the clipping path of a nested clip group. Live objects - /// are evaluated first. Guides and symbol instances add nothing. - pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> { - let mut out = vec![]; - self.push_clip_shapes(text, &mut out); - out - } - - fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { - match &self.kind { - NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} - NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), - NodeKind::Compound { children, rule } => { - let mut bp = BezPath::new(); - for p in children.iter().filter_map(|c| c.path_data()) { - bp.extend(p.to_bezpath()); - } - out.push((bp, *rule)); - } - NodeKind::Group { children, clip: true } => { - if let Some(c) = children.first() { - c.push_clip_shapes(text, out); - } - } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { - c.push_clip_shapes(text, out); - } - } - NodeKind::Text(_) => { - if let Some(o) = text.and_then(|f| f(self)) { - o.push_clip_shapes(None, out); - } - } - NodeKind::Image(im) => { - let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - out.push((frame.to_bezpath(), FillRule::NonZero)); - } - NodeKind::PlacedDocument(p) => { - let frame = shapes::rectangle(p.natural()).transformed(p.xf); - out.push((frame.to_bezpath(), FillRule::NonZero)); - } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { - crate::live::expand_deep(self, text).push_clip_shapes(text, out); - } - } - } - - /// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared - /// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its - /// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to - /// clip by: the clipped art is then hidden. - pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> { - let mut shapes = self.clip_shapes(text); - match shapes.len() { - 0 => None, - 1 => shapes.pop(), - _ => Some((unite(&shapes), FillRule::NonZero)), - } - } - - /// What this object paints as the clipping path of a clip group, shared by the renderer and - /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted - /// over it (unclipped). Each part is this object keeping only those appearance items (type: - /// also only its characters' fills or strokes); `None` where it paints nothing, as right after - /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. - pub fn clip_paint(&self) -> ClipPaint { - use crate::appearance::AppearanceItem; - let runs = match &self.kind { - NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, - NodeKind::Text(t) => Some(&t.runs), - _ => return ClipPaint::default(), - }; - let paints = |fill: bool| { - let item = |i: &AppearanceItem| match i { - AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, - }; - let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; - self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) - }; - let part = |fill: bool| { - paints(fill).then(|| { - let mut n = self.clone(); - for i in (0..n.appearance.items.len()).rev() { - if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill { - n.appearance.remove_item(i); - } - } - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - *(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None; - } - } - n - }) - }; - ClipPaint { fill: part(true), stroke: part(false) } - } -} - -/// What a clipping path paints ([`Node::clip_paint`]). -#[derive(Clone, Debug, Default)] -pub struct ClipPaint { - /// The clipping path with only its fills: painted behind the clipped art (inside the clip). - pub fill: Option, - /// The clipping path with only its strokes: painted over the clipped art, not clipped. - pub stroke: Option, -} - -/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a -/// knockout group each child composites against the group's backdrop, so it hides the children -/// below it instead of showing them through its transparency. Neutral passes the enclosing -/// group's setting through to the children. Files from before the three states stored a bool: -/// `true` loads as On, `false` as Neutral. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum Knockout { - #[default] - Neutral, - Off, - On, -} - -impl Knockout { - pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On]; - - /// Name in files and command parameters. - pub fn label(self) -> &'static str { - match self { - Knockout::Neutral => "neutral", - Knockout::Off => "off", - Knockout::On => "on", - } - } - /// A name ([`Self::label`], any case) or a bool as older files and commands wrote it. - pub fn from_value(v: &serde_json::Value) -> Option { - match v { - serde_json::Value::Bool(b) => Some(Self::from(*b)), - serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())), - _ => None, - } - } - /// The state a click on the checkbox moves to: on → neutral → off → on. - pub fn cycle(self) -> Self { - match self { - Knockout::On => Knockout::Neutral, - Knockout::Neutral => Knockout::Off, - Knockout::Off => Knockout::On, - } - } - /// Whether children of a group with this state knock each other out, inside a group (or page) - /// where that is `enclosing`. - pub fn resolve(self, enclosing: bool) -> bool { - match self { - Knockout::Neutral => enclosing, - Knockout::Off => false, - Knockout::On => true, - } - } -} - -impl From for Knockout { - fn from(b: bool) -> Self { - if b { Knockout::On } else { Knockout::Neutral } - } -} - -impl Serialize for Knockout { - fn serialize(&self, s: S) -> Result { - s.serialize_str(self.label()) - } -} - -impl<'de> Deserialize<'de> for Knockout { - fn deserialize>(d: D) -> Result { - let v = serde_json::Value::deserialize(d)?; - Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}"))) - } -} - -impl Node { - /// Whether the children of this group or layer knock each other out, inside a group (or page) - /// where that is `enclosing`. - pub fn knocks_out(&self, enclosing: bool) -> bool { - matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing) - } - - /// A plain group or layer that is neutral to knockout and has no transparency or appearance - /// of its own: inside a knockout group its children composite as the group's own children. - pub fn passes_knockout_through(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. }) - && self.has_default_transparency() - && self.appearance.items.is_empty() - && self.appearance.effects.is_empty() - } - - /// The visible elements of a knockout group made of `children` (bottom first): neutral plain - /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. - pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { - fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { - for c in children.iter().filter(|c| c.visible) { - match c.children() { - Some(ch) if c.passes_knockout_through() => push(ch, out), - _ => out.push(c), - } - } - } - let mut out = Vec::with_capacity(children.len()); - push(children, &mut out); - out - } - - /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. - pub fn clips(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) - } - - /// Make this group or layer clip its children by its first child, or stop it. - pub fn set_clips(&mut self, on: bool) { - if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind { - *clip = on; - } - } - - /// Whether blending inside this object reaches the art below it unless the object isolates - /// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke), - /// directly or through children that don't isolate their own blending. - pub fn blends_through(&self) -> bool { - self.blends_through_with(&mut |c| c.blends_through()) - } - - /// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache). - pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc) -> bool) -> bool { - match self.children() { - Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner), - _ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal), - } - } - - /// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child). - pub fn children_blend(children: &[Arc], inner: &mut dyn FnMut(&Arc) -> bool) -> bool { - children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c)))) - } -} - -impl Node { - /// The Appearance panel's row for what this object's own fills and strokes paint around: - /// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters" - /// for type; `None` for objects whose fills and strokes paint their own geometry. - pub fn contents_label(&self) -> Option<&'static str> { - match self.kind { - NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"), - NodeKind::Text(_) => Some("Characters"), - _ => None, - } - } -} - -/// Window → Attributes: an object's settings that don't change how it prints. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ObjectAttributes { - /// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub show_center: Option, - /// Image Map: the clickable area the URL covers in web output. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub image_map: ImageMap, - /// URL the object links to (SVG ``; empty: none). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub url: String, - /// The note shown in the Attributes panel. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub note: String, - /// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is - /// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub data: Vec<(String, String)>, - /// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order - /// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as - /// the element's event attributes; nothing in VectorCraft runs them. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub events: Vec<(String, String)>, - /// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG - /// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives - /// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's, - /// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them - /// with it. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub foreign: Vec, -} - -/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter= -/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name: -/// "pen", value: "2"}`. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ForeignAttribute { - /// The namespace's URI, which names it. - pub ns: String, - /// The prefix the file gave the namespace, which export reuses where it can. - pub prefix: String, - /// The attribute's local name. - pub name: String, - /// The attribute's value, as the file had it. - pub value: String, -} - -/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event -/// attributes that run it. -pub const SVG_EVENTS: [&str; 19] = [ - "onfocusin", - "onfocusout", - "onactivate", - "onmousedown", - "onmouseup", - "onclick", - "onmouseover", - "onmousemove", - "onmouseout", - "onkeydown", - "onkeypress", - "onkeyup", - "onload", - "onerror", - "onabort", - "onunload", - "onzoom", - "onresize", - "onscroll", -]; - -/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the -/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another. -pub const MAX_SVG_SCRIPTS: usize = 64; - -/// The Attributes panel's Image Map shapes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ImageMap { - #[default] - None, - Rectangle, - Polygon, -} - -impl ImageMap { - pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon]; - - pub fn label(self) -> &'static str { - match self { - ImageMap::None => "None", - ImageMap::Rectangle => "Rectangle", - ImageMap::Polygon => "Polygon", - } - } - - /// Parse a label or serialized name, ignoring case. - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) - } -} - -impl Node { - /// The URL this object links to, if any. - pub fn url(&self) -> Option<&str> { - self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) - } - - /// Whether the canvas shows this object's centre point when it is selected: as set in the - /// Attributes panel, else [`Self::shows_center_by_default`]. - pub fn shows_center(&self) -> bool { - self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) - } - - /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. - pub fn shows_center_by_default(&self) -> bool { - matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) - } - - /// Change this object's attributes with `f`; all-default attributes are dropped. - pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { - let mut a = self.extras_mut().attrs.take().unwrap_or_default(); - let r = f(&mut a); - self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); - r - } -} - -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. - pub fn shows_transparency(&self) -> 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); - self.visible - && (self.opacity < 1.0 - || self.blend != BlendMode::Normal - || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) - || 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()))) - } -} - -/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? -fn keeps_gradient_fit(a: Affine) -> bool { - let [m0, m1, m2, m3, _, _] = a.as_coeffs(); - let eps = 1e-12 * m0.abs().max(1.0); - m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps -} - -impl Node { - /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true - /// everywhere for objects without them, such as type, whose boxes stand in). - pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> { - let shapes = self.clip_shapes(None); - move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) - } -} - -/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every -/// object inside a group, again at each level of nesting) before reading it, since `type` may come -/// anywhere among its fields. Files have it first (it is written first): then the kind is read -/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the -/// fields before `type` are buffered, and only those. -mod kind_de { - use std::fmt; - use std::sync::Arc; - - use serde::de::value::{MapAccessDeserializer, StringDeserializer}; - use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor}; - use serde::{Deserialize, forward_to_deserialize_any}; - use vectorcraft_geom::{Affine, FillRule, PathData}; - - use super::{LayerColor, LiveShape, Node, NodeKind, yes}; - use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; - use crate::pattern::RepeatSpec; - use crate::text::TextObject; - - /// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes - /// note (the compiler checks that every field is here), read externally tagged: the kind from - /// `type`, its fields from the rest of the object ([`Tagged`]). - #[derive(Deserialize)] - #[serde(remote = "NodeKind", rename_all = "lowercase")] - enum KindDef { - Layer { - color: LayerColor, - #[serde(default)] - template: bool, - #[serde(default = "yes")] - printable: bool, - children: Vec>, - #[serde(default)] - clip: bool, - #[serde(default = "yes")] - preview: bool, - #[serde(default)] - dim_images: Option, - }, - Group { - children: Vec>, - #[serde(default)] - clip: bool, - }, - Path { - path: PathData, - #[serde(default)] - rule: FillRule, - #[serde(default)] - live: Option>, - #[serde(default)] - clipping: bool, - #[serde(default)] - guide: bool, - }, - Compound { - children: Vec>, - #[serde(default)] - rule: FillRule, - }, - Text(Box), - Image(Box), - SymbolInstance { - symbol: String, - xf: Affine, - }, - Blend { - children: Vec>, - #[serde(default)] - spec: Box, - }, - Envelope { - content: Vec>, - kind: Box, - #[serde(default = "crate::live::default_fidelity")] - fidelity: f64, - #[serde(default)] - editing: bool, - #[serde(default)] - options: crate::live::EnvelopeOptions, - #[serde(default)] - frame: Box, - }, - Mesh(GradientMesh), - Repeat(RepeatSpec), - PlacedDocument(Box), - Intertwine { - children: Vec>, - }, - } - - impl<'de> Deserialize<'de> for NodeKind { - fn deserialize>(d: D) -> Result { - d.deserialize_map(KindVisitor) - } - } - - struct KindVisitor; - - impl<'de> Visitor<'de> for KindVisitor { - type Value = NodeKind; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an object kind (a map with `type`)") - } - - fn visit_map>(self, mut map: A) -> Result { - let mut before = vec![]; - while let Some(key) = map.next_key::()? { - if key == "type" { - let tag: String = map.next_value()?; - return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); - } - before.push((key, map.next_value::()?)); - } - Err(de::Error::missing_field("type")) - } - } - - /// The fields read before `type` (buffered), then the rest of the object. - struct Buffered { - before: std::vec::IntoIter<(String, serde_json::Value)>, - value: Option, - map: A, - } - - impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered { - type Error = A::Error; - - fn next_key_seed>(&mut self, seed: K) -> Result, A::Error> { - match self.before.next() { - Some((key, value)) => { - self.value = Some(value); - seed.deserialize(StringDeserializer::::new(key)).map(Some) - } - None => self.map.next_key_seed(seed), - } - } - - fn next_value_seed>(&mut self, seed: V) -> Result { - match self.value.take() { - Some(value) => seed.deserialize(value).map_err(de::Error::custom), - None => self.map.next_value_seed(seed), - } - } - } - - /// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`. - struct Tagged { - tag: String, - map: A, - } - - impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged { - type Error = A::Error; - - fn deserialize_any>(self, visitor: V) -> Result { - visitor.visit_enum(self) - } - - forward_to_deserialize_any! { - bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf - option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum - identifier ignored_any - } - } - - impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged { - type Error = A::Error; - type Variant = Fields; - - fn variant_seed>(self, seed: V) -> Result<(V::Value, Fields), A::Error> { - let kind = seed.deserialize(self.tag.into_deserializer())?; - Ok((kind, Fields(self.map))) - } - } - - /// A kind's fields: the rest of the object. - struct Fields(A); - - impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields { - type Error = A::Error; - - fn unit_variant(self) -> Result<(), A::Error> { - Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields")) - } - - fn newtype_variant_seed>(self, seed: T) -> Result { - seed.deserialize(MapAccessDeserializer::new(self.0)) - } - - fn tuple_variant>(self, _: usize, _: V) -> Result { - Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields")) - } - - fn struct_variant>(self, _: &'static [&'static str], visitor: V) -> Result { - visitor.visit_map(self.0) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - /// A node writes its rarely set parts among the others, in the file's field order, and reads - /// them back into its extras; a node without any keeps none. - #[test] - fn extras_are_written_flat_in_order() { - let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); - n.name = Some("n".into()); - n.knockout_shape = true; - n.bbox_angle = 30.0; - { - let mut e = n.extras_mut(); - e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); - e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); - e.series_index = Some(2); - e.type_outlines = true; - e.graphic_style = Some(3); - e.attrs = Some(Box::default()); - e.slice = Some(Box::default()); - e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); - e.live_paint_gaps = Some(Box::default()); - } - /// The top-level keys of a JSON object, in the order written. - struct Keys(Vec); - impl<'de> Deserialize<'de> for Keys { - fn deserialize>(d: D) -> Result { - struct V; - impl<'de> serde::de::Visitor<'de> for V { - type Value = Keys; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("an object") - } - fn visit_map>(self, mut a: A) -> Result { - let mut out = vec![]; - while let Some(k) = a.next_key::()? { - a.next_value::()?; - out.push(k); - } - Ok(Keys(out)) - } - } - d.deserialize_map(V) - } - } - let text = serde_json::to_string(&n).unwrap(); - let keys = serde_json::from_str::(&text).unwrap().0; - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - let order = [ - "id", - "name", - "knockout_shape", - "appearance", - "mask", - "trace", - "series_index", - "type_outlines", - "kind", - "graphic_style", - "attrs", - "slice", - "bbox_angle", - "perspective", - "live_paint_gaps", - ]; - assert_eq!(keys, order); - assert_eq!(serde_json::from_value::(v).unwrap(), n); - let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); - let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); - assert!(back.extras_box().is_none()); - // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. - n.extras_mut().mask = None; - assert!(n.extras_box().is_some()); - *n.extras_mut() = NodeExtras::default(); - assert!(n.extras_box().is_none()); - n.extras_mut().mask = None; - assert!(n.extras_box().is_none()); - assert_eq!(n.extras(), &NodeExtras::default()); - } - - #[test] - fn rect_live_shape_roundtrip() { - let l = LiveShape::Rectangle { - w: 10.0, - h: 20.0, - radii: [0.0; 4], - kinds: Default::default(), - radii_relative: false, - xf: Affine::translate((5.0, 5.0)), - }; - assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); - assert_eq!(l.label(), "Rectangle"); - } - - #[test] - fn corner_kinds_save_only_when_some_corner_isnt_round() { - let mut l = LiveShape::Rectangle { - w: 100.0, - h: 50.0, - radii: [0.0, 10.0, 0.0, 0.0], - kinds: Default::default(), - radii_relative: false, - xf: Affine::IDENTITY, - }; - // Older files read as round. - let json = serde_json::to_value(&l).unwrap(); - assert!(json.get("kinds").is_none(), "{json}"); - assert_eq!(serde_json::from_value::(json).unwrap(), l); - if let LiveShape::Rectangle { kinds, .. } = &mut l { - kinds[1] = CornerKind::Chamfer; - } - let json = serde_json::to_value(&l).unwrap(); - assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); - assert_eq!(serde_json::from_value::(json).unwrap(), l); - // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. - let p: LiveShape = - serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); - assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); - let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); - let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; - let json = serde_json::to_value(&path).unwrap(); - assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); - assert_eq!(serde_json::from_value::(json).unwrap(), path); - assert_eq!(path.label(), "Path"); - } - - /// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one - /// with four billion made its outline and corner lists allocate tens of gigabytes. - #[test] - fn a_polygon_from_a_file_has_3_to_1000_sides() { - let read = |sides: u32| { - let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]}); - serde_json::from_value::(json).unwrap() - }; - let sides_of = |l: &LiveShape| match l { - LiveShape::Polygon { sides, .. } => *sides, - _ => panic!("not a polygon: {l:?}"), - }; - let huge = read(4_000_000_000); - assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]); - assert_eq!(sides_of(&huge), 1000); - assert_eq!(huge.polygon_outline().anchor_count(), 1000); - } - - #[test] - fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() { - let close = |a: f64, b: f64| (a - b).abs() < 1e-9; - // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. - let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); - let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; - assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); - p.set_polygon_angle(45.0); - assert!(close(p.polygon_angle().unwrap(), 45.0)); - let centre = p.to_path().bounds().unwrap().center(); - assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}"); - p.set_polygon_radius(30.0); - assert!(close(p.polygon_radius().unwrap(), 30.0)); - // A hexagon's side is as long as its radius. - assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0)); - // Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius. - p.transform(Affine::scale_non_uniform(2.0, 1.0)); - assert!(!p.polygon_sides_equal()); - let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap()); - p.make_sides_equal(); - assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a)); - let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") }; - assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0)); - // Other shapes have none of it. - let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; - assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None)); - } - - /// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. - fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; - let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); - if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(Box::new(live)); - } - n - } - - fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { - let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; - let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; - (*w, *h, *radii, *xf) - } - - fn near(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-9 - } - - fn near_xf(a: Affine, b: Affine) -> bool { - a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) - } - - /// Size and radius of a live rectangle, within rounding. - fn assert_live(n: &Node, w: f64, h: f64, r: f64) { - let (lw, lh, radii, _) = live_of(n); - assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}"); - } - - /// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation), - /// spans `r` along x and y: the corner is a circular arc of radius `r`. - fn assert_round_corners(n: &Node, back: Affine, r: f64) { - let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") }; - let a: Vec = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect(); - assert_eq!(a.len(), 8, "a rounded rectangle"); - // Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs. - for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] { - let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); - assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}"); - } - } - - const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; - - #[test] - fn uneven_scales_keep_live_corners_circular() { - // A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291). - let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0))); - let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0)); - n.transform(stretch, KEEP_CORNERS); - assert_live(&n, 300.0, 100.0, 20.0); - assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size"); - let b = n.geometric_bounds().unwrap(); - assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}"); - assert_round_corners(&n, Affine::IDENTITY, 20.0); - // Scale Corners on: the radius scales by the mean scale, still circular. - let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); - n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default()); - assert_live(&n, 400.0, 100.0, 40.0); - assert_round_corners(&n, Affine::IDENTITY, 40.0); - // Squashed below the radius, the short sides are fully round; stretched back, it returns. - n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS); - assert_round_corners(&n, Affine::IDENTITY, 25.0); - n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS); - assert_round_corners(&n, Affine::IDENTITY, 40.0); - } - - #[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()); - let mut n = live_rect(100.0, 50.0, 10.0, place); - // Stretched along its own width, as a bounding-box drag of a rotated shape does. - n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS); - assert_live(&n, 200.0, 50.0, 10.0); - assert!(near_xf(live_of(&n).3, place)); - assert_round_corners(&n, place.inverse(), 10.0); - // A reflection stays in `xf`; the corners stay as they were. - n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS); - assert_live(&n, 200.0, 50.0, 10.0); - let xf = live_of(&n).3; - assert!(near(xf.determinant(), -1.0)); - assert_round_corners(&n, xf.inverse(), 10.0); - } - - #[test] - fn shears_and_moves_keep_the_old_transform() { - // Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before. - let rot = Affine::rotate(30f64.to_radians()); - let mut n = live_rect(100.0, 50.0, 10.0, rot); - let before = n.clone(); - let a = Affine::scale_non_uniform(2.0, 1.0); - n.transform(a, Scaling::default()); - assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot)); - let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() }; - assert_eq!(*path, old.transformed(a)); - // A file saved with an uneven scale in `xf` keeps its exact geometry when moved... - let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0); - let mut n = live_rect(100.0, 100.0, 20.0, legacy); - n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS); - assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy)); - // ...and once scaled, has document units and circular corners of its mean radius. - n.transform(Affine::scale(2.0), Scaling::default()); - assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0); - assert!(near(live_of(&n).3.determinant(), 1.0)); - assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); - } - - /// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live - /// Corners see and edit it in document units, with circular corners (#442). - #[test] - fn a_scale_left_in_the_transform_folds_into_the_size() { - let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); - let n = live_rect(50.0, 80.0, 20.0, legacy); - let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; - let folded = live.folded(); - assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); - let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; - // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. - assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); - assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); - let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); - assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); - let corners = crate::LiveCorners::of(&n).unwrap(); - assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units"); - // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. - let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); - let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); - for xf in [place, shear] { - let mut l = live_rect(100.0, 160.0, 20.0, xf); - let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; - assert!(!live.fold_scale()); - assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); - } - } - - #[test] - fn default_transparency() { - let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - assert!(p().has_default_transparency()); - let tweaks: [fn(&mut Node); 7] = [ - |n| n.opacity = 0.5, - |n| n.blend = BlendMode::Multiply, - |n| n.isolate = true, - |n| n.knockout = Knockout::On, - |n| n.knockout = Knockout::Off, - |n| n.knockout_shape = true, - |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), - ]; - for tweak in tweaks { - let mut n = p(); - tweak(&mut n); - assert!(!n.has_default_transparency(), "{n:?}"); - } - } - - #[test] - fn transform_group_recurses() { - let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); - g.transform(Affine::scale(2.0), true); - assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); - let child = &g.children().unwrap()[0]; - assert_eq!(child.appearance.stroke_width(), 2.0); - } - - #[test] - fn unplaced_gradients_pin_only_when_a_refit_would_differ() { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; - let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); - let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); - let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { - (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), - _ => panic!("gradient fill and stroke"), - }; - let mut n = fresh(); - n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); - assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); - let mut n = fresh(); - n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); - let (f, s) = geoms(&n); - // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. - let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); - want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); - let f = f.unwrap(); - assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); - assert!((s.unwrap().length() - 102.0).abs() < 1e-9); - } - - #[test] - fn display_names() { - let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - assert_eq!(p.display_name(), ""); - let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); - assert_eq!(l.display_name(), "Layer 1"); - } - - #[test] - fn visual_bounds_include_stroke() { - let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; - assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); - } - - /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the - /// weight centred, the whole weight outside, nothing inside; scaled with the type. - #[test] - fn type_visual_bounds_grow_with_the_character_stroke() { - use crate::appearance::StrokeAlign; - use crate::text::{CharStyle, TextObject}; - let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; - let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); - t.xf = Affine::scale(2.0) * t.xf; - let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); - let g = node(&t).geometric_bounds().unwrap(); - for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { - t.runs[0].style.stroke_align = align; - assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); - } - t.runs[0].style.stroke = vectorcraft_color::Paint::None; - assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); - } - - /// A group holding a path, a compound, an image and a nested group, as a file writes it. - fn nested_group() -> Node { - let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let image = - ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); - let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); - let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; - Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) - } - - #[test] - fn kinds_read_back_with_type_first_or_anywhere() { - let n = nested_group(); - let json = serde_json::to_string(&n).unwrap(); - assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), n); - // Through a JSON value, whose keys are sorted: `type` comes after `children`. - let v = serde_json::to_value(&n).unwrap(); - assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); - assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); - // `type` last, in the middle, and keys a newer version added (ignored, as before). - let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; - assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); - let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; - assert_eq!( - serde_json::from_str::(kind).unwrap(), - NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } - ); - let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; - assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); - // Defaults as before. - let NodeKind::Layer { printable, preview, template, .. } = - serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() - else { - panic!("a layer") - }; - assert!(printable && preview && !template); - } - - #[test] - fn bad_kinds_are_errors() { - for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { - assert!(serde_json::from_str::(bad).is_err(), "{bad}"); - } - } -} +mod tests; diff --git a/crates/doc/src/node/attributes.rs b/crates/doc/src/node/attributes.rs new file mode 100644 index 000000000..70653d778 --- /dev/null +++ b/crates/doc/src/node/attributes.rs @@ -0,0 +1,133 @@ +use super::kind::NodeKind; +use super::live_shape::LiveShape; +use super::*; + +/// Window → Attributes: an object's settings that don't change how it prints. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObjectAttributes { + /// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_center: Option, + /// Image Map: the clickable area the URL covers in web output. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub image_map: ImageMap, + /// URL the object links to (SVG ``; empty: none). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub url: String, + /// The note shown in the Attributes panel. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, + /// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is + /// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub data: Vec<(String, String)>, + /// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order + /// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as + /// the element's event attributes; nothing in VectorCraft runs them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec<(String, String)>, + /// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG + /// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives + /// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's, + /// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them + /// with it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub foreign: Vec, +} + +/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter= +/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name: +/// "pen", value: "2"}`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForeignAttribute { + /// The namespace's URI, which names it. + pub ns: String, + /// The prefix the file gave the namespace, which export reuses where it can. + pub prefix: String, + /// The attribute's local name. + pub name: String, + /// The attribute's value, as the file had it. + pub value: String, +} + +/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event +/// attributes that run it. +pub const SVG_EVENTS: [&str; 19] = [ + "onfocusin", + "onfocusout", + "onactivate", + "onmousedown", + "onmouseup", + "onclick", + "onmouseover", + "onmousemove", + "onmouseout", + "onkeydown", + "onkeypress", + "onkeyup", + "onload", + "onerror", + "onabort", + "onunload", + "onzoom", + "onresize", + "onscroll", +]; + +/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the +/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another. +pub const MAX_SVG_SCRIPTS: usize = 64; + +/// The Attributes panel's Image Map shapes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageMap { + #[default] + None, + Rectangle, + Polygon, +} + +impl ImageMap { + pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon]; + + pub fn label(self) -> &'static str { + match self { + ImageMap::None => "None", + ImageMap::Rectangle => "Rectangle", + ImageMap::Polygon => "Polygon", + } + } + + /// Parse a label or serialized name, ignoring case. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } +} + +impl Node { + /// The URL this object links to, if any. + pub fn url(&self) -> Option<&str> { + self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) + } + + /// Whether the canvas shows this object's centre point when it is selected: as set in the + /// Attributes panel, else [`Self::shows_center_by_default`]. + pub fn shows_center(&self) -> bool { + self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) + } + + /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. + pub fn shows_center_by_default(&self) -> bool { + matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) + } + + /// Change this object's attributes with `f`; all-default attributes are dropped. + pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { + let mut a = self.extras_mut().attrs.take().unwrap_or_default(); + let r = f(&mut a); + self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); + r + } +} diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs new file mode 100644 index 000000000..959b20a5b --- /dev/null +++ b/crates/doc/src/node/clip.rs @@ -0,0 +1,120 @@ +use super::kind::NodeKind; +use super::*; + +/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans +/// live above this crate (`vectorcraft-pathops`), so callers supply it. +pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath; + +impl Node { + /// The filled regions this object clips to as the clipping path of a clip group, in document + /// space, each with its fill rule: a path, a compound path (with its holes), an image's frame, + /// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible + /// members of a group (their union) and the clipping path of a nested clip group. Live objects + /// are evaluated first. Guides and symbol instances add nothing. + pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> { + let mut out = vec![]; + self.push_clip_shapes(text, &mut out); + out + } + + fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { + match &self.kind { + NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + out.push((bp, *rule)); + } + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + c.push_clip_shapes(text, out); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { + c.push_clip_shapes(text, out); + } + } + NodeKind::Text(_) => { + if let Some(o) = text.and_then(|f| f(self)) { + o.push_clip_shapes(None, out); + } + } + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } + NodeKind::PlacedDocument(p) => { + let frame = shapes::rectangle(p.natural()).transformed(p.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + crate::live::expand_deep(self, text).push_clip_shapes(text, out); + } + } + } + + /// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared + /// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its + /// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to + /// clip by: the clipped art is then hidden. + pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> { + let mut shapes = self.clip_shapes(text); + match shapes.len() { + 0 => None, + 1 => shapes.pop(), + _ => Some((unite(&shapes), FillRule::NonZero)), + } + } + + /// What this object paints as the clipping path of a clip group, shared by the renderer and + /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted + /// over it (unclipped). Each part is this object keeping only those appearance items (type: + /// also only its characters' fills or strokes); `None` where it paints nothing, as right after + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. + pub fn clip_paint(&self) -> ClipPaint { + use crate::appearance::AppearanceItem; + let runs = match &self.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, + NodeKind::Text(t) => Some(&t.runs), + _ => return ClipPaint::default(), + }; + let paints = |fill: bool| { + let item = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, + }; + let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; + self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) + }; + let part = |fill: bool| { + paints(fill).then(|| { + let mut n = self.clone(); + for i in (0..n.appearance.items.len()).rev() { + if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill { + n.appearance.remove_item(i); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + *(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None; + } + } + n + }) + }; + ClipPaint { fill: part(true), stroke: part(false) } + } +} + +/// What a clipping path paints ([`Node::clip_paint`]). +#[derive(Clone, Debug, Default)] +pub struct ClipPaint { + /// The clipping path with only its fills: painted behind the clipped art (inside the clip). + pub fill: Option, + /// The clipping path with only its strokes: painted over the clipped art, not clipped. + pub stroke: Option, +} diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs new file mode 100644 index 000000000..ee1165303 --- /dev/null +++ b/crates/doc/src/node/extras.rs @@ -0,0 +1,171 @@ +use super::attributes::ObjectAttributes; +use super::kind::NodeKind; +use super::live_shape::LiveShape; +use super::opacity::OpacityMask; +use super::*; + +/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without +/// them stay small ([`Node::extras`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NodeExtras { + /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. + pub mask: Option>, + /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows + /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). + pub trace: Option>, + /// Object → Text Wrap: area type below this object (in the same layer) flows around it. + pub wrap: Option, + /// Graph object: the group's children are generated from this spec (Object → Graph). + pub graph: Option>, + /// Index of the graph series this generated group belongs to. Axes, the legend and other + /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. + pub series_index: Option, + /// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale + /// the placed gradients in it with the outlines. + pub type_outlines: bool, + /// Editable Shaper composition; the original art is retained in its source child. + pub shaper: Option>, + /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps + /// that style's look: editing its appearance or transparency breaks the link. + pub graphic_style: Option, + /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). + pub attrs: Option>, + /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with + /// these options. + pub slice: Option>, + /// Object › Perspective: the perspective grid plane the object is attached to. + pub perspective: Option>, + /// Dimension object: the live measurement it draws; its ordinary group children hold its + /// exportable appearance (Object › Dimensions, the Dimension tool). + pub dimension: Option>, + /// 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>, + /// 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 + /// [`crate::objects_on_path`]). + pub objects_on_path: Option>, +} + +/// No extras: what [`Node::extras`] gives for a node without any. +pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { + mask: None, + trace: None, + wrap: None, + graph: None, + series_index: None, + type_outlines: false, + shaper: None, + graphic_style: None, + attrs: None, + slice: None, + perspective: None, + dimension: None, + intertwine: None, + live_paint_gaps: None, + objects_on_path: None, +}; + +/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal +/// none. +#[derive(Clone, Debug, Default)] +pub struct Extras(pub(crate) Option>); + +impl Extras { + fn get(&self) -> &NodeExtras { + self.0.as_deref().unwrap_or(&NO_EXTRAS) + } +} + +impl PartialEq for Extras { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a == b, + _ => self.get() == other.get(), + } + } +} + +impl Node { + /// The rarely set parts of this node (all unset when it has none). + #[inline] + pub fn extras(&self) -> &NodeExtras { + self.extras.get() + } + + /// The rarely set parts of this node, to change: when the change is done (the guard dropped), + /// extras that were set are allocated and extras left all unset are let go. Setting one to + /// `None` on a node without extras allocates nothing. + #[inline] + pub fn extras_mut(&mut self) -> ExtrasMut<'_> { + ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } + } + + /// The rarely set parts of this node, if it has any: to change what is there in place. + #[inline] + pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { + self.extras.0.as_deref_mut() + } + + /// The live shape of a path object, if it is one. + pub fn live_shape(&self) -> Option<&LiveShape> { + match &self.kind { + NodeKind::Path { live, .. } => live.as_deref(), + _ => None, + } + } + + /// The live shape of a path object, if it is one, to change. + pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { + match &mut self.kind { + NodeKind::Path { live, .. } => live.as_deref_mut(), + _ => None, + } + } + + /// This node with opacity mask `mask` (or none). + pub fn with_mask(mut self, mask: Option>) -> Self { + self.extras_mut().mask = mask; + self + } + + /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). + pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { + self.extras.0.as_deref() + } +} + +/// [`Node::extras_mut`]: the node's extras while they change. +pub struct ExtrasMut<'a> { + slot: &'a mut Option>, + /// Stands for the extras of a node without any, boxed into it when dropped if one was set. + local: NodeExtras, +} + +impl std::ops::Deref for ExtrasMut<'_> { + type Target = NodeExtras; + fn deref(&self) -> &NodeExtras { + self.slot.as_deref().unwrap_or(&self.local) + } +} + +impl std::ops::DerefMut for ExtrasMut<'_> { + fn deref_mut(&mut self) -> &mut NodeExtras { + match self.slot.as_deref_mut() { + Some(e) => e, + None => &mut self.local, + } + } +} + +impl Drop for ExtrasMut<'_> { + fn drop(&mut self) { + match self.slot.as_deref() { + Some(e) if *e == NO_EXTRAS => *self.slot = None, + Some(_) => {} + None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), + None => {} + } + } +} diff --git a/crates/doc/src/node/ids.rs b/crates/doc/src/node/ids.rs new file mode 100644 index 000000000..ea4b1b484 --- /dev/null +++ b/crates/doc/src/node/ids.rs @@ -0,0 +1,59 @@ +use super::*; + +/// Stable per-document object id. Never reused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct NodeId(pub u64); + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{}", self.0) + } +} + +/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LayerColor { + Preset(u8), + Custom([u8; 3]), +} + +/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue. +pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [ + ("Light Blue", [0x4f, 0x80, 0xff]), + ("Red", [0xff, 0x4f, 0x4f]), + ("Green", [0x4f, 0xff, 0x4f]), + ("Blue", [0x4f, 0x4f, 0xff]), + ("Yellow", [0xff, 0xff, 0x4f]), + ("Magenta", [0xff, 0x4f, 0xff]), + ("Cyan", [0x4f, 0xff, 0xff]), + ("Gray", [0x80, 0x80, 0x80]), + ("Black", [0x00, 0x00, 0x00]), + ("Orange", [0xff, 0x66, 0x00]), + ("Dark Green", [0x00, 0x80, 0x00]), + ("Teal", [0x00, 0x80, 0x80]), + ("Tan", [0xcc, 0x99, 0x66]), + ("Brown", [0x99, 0x33, 0x00]), + ("Violet", [0x99, 0x33, 0xff]), + ("Gold", [0xff, 0x99, 0x00]), + ("Dark Blue", [0x00, 0x00, 0x80]), + ("Pink", [0xff, 0x99, 0xcc]), + ("Lavender", [0x99, 0x99, 0xff]), + ("Brick Red", [0x99, 0x00, 0x00]), + ("Olive Green", [0x66, 0x66, 0x00]), + ("Peach", [0xff, 0x99, 0x99]), + ("Burgundy", [0x99, 0x00, 0x33]), + ("Grass Green", [0x99, 0xcc, 0x00]), + ("Ochre", [0x99, 0x66, 0x00]), + ("Purple", [0x66, 0x00, 0x99]), + ("Light Gray", [0xbb, 0xbb, 0xbb]), +]; + +impl LayerColor { + pub fn rgb(self) -> [u8; 3] { + match self { + LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1, + LayerColor::Custom(c) => c, + } + } +} diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs new file mode 100644 index 000000000..5bebc4379 --- /dev/null +++ b/crates/doc/src/node/kind.rs @@ -0,0 +1,326 @@ +use super::ids::LayerColor; +use super::live_shape::LiveShape; +use super::*; + +/// Embedded or linked raster image. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageObject { + /// Key into [`crate::Document::images`]. + pub key: String, + pub width: u32, + pub height: u32, + /// Maps pixel space (0..w, 0..h) into the document. + pub xf: Affine, + /// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved + /// before links had details hold just the path. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")] + pub link: Option, + /// Links panel → Placement Options: how a file read again takes this image's place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub placement: crate::links::PlacementOptions, + /// The pixels of an image in art made outside a document (type's outlines, a glyph's art), + /// which has no [`crate::Document::images`] to keep them in. A document takes them in + /// ([`crate::Document::adopt_pixels`]): no image in one holds its own. + #[serde(skip)] + pub pixels: Option>, +} + +/// Written with its `type` first, then its fields (an internally tagged enum); read by +/// [`kind_de`], which takes the fields straight from the file when `type` comes first. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum NodeKind { + /// A top-level layer or sublayer. + Layer { + color: LayerColor, + #[serde(default)] + template: bool, + #[serde(default = "yes")] + printable: bool, + children: Vec>, + /// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + clip: bool, + /// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen + /// (Ctrl-click its eye in the Layers panel). + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + preview: bool, + /// Layer Options → Dim Images to: on screen, images on the layer show faded to this + /// percentage (0–100); `None`: not dimmed. + #[serde(default, skip_serializing_if = "Option::is_none")] + dim_images: Option, + }, + /// A group. With `clip`, the first (bottom-most) child is the clipping path. + Group { + children: Vec>, + #[serde(default)] + clip: bool, + }, + Path { + path: PathData, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + rule: FillRule, + /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. + #[serde(default, skip_serializing_if = "Option::is_none")] + live: Option>, + /// This path acts as a clipping path inside a clip group. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + clipping: bool, + /// Guide (non-printing) path. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + guide: bool, + }, + /// Compound path: children are paths painted as one with the compound's appearance. + Compound { + children: Vec>, + #[serde(default)] + rule: FillRule, + }, + Text(Box), + /// Boxed, as the other large kinds: a node is as large as its largest kind. + Image(Box), + SymbolInstance { + symbol: String, + xf: Affine, + }, + /// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate + /// steps are evaluated on demand (`live::blend_expand`). + Blend { + children: Vec>, + #[serde(default)] + spec: Box, + }, + /// Live envelope distortion of `content`. + Envelope { + content: Vec>, + kind: Box, + /// Envelope Options → Fidelity (0–100). + #[serde(default = "crate::live::default_fidelity")] + fidelity: f64, + /// Edit Contents mode (the content, not the envelope, is edited). + #[serde(default)] + editing: bool, + /// Envelope Options besides Fidelity. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + options: crate::live::EnvelopeOptions, + /// The envelope's own axes (→ the document): the transforms it took since it was made that + /// the page axes can't stand for (rotation, shear, reflection). The content maps from its + /// bounds in this frame, and a warp bends along it. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + frame: Box, + }, + /// Gradient mesh. + Mesh(GradientMesh), + /// Live Repeat (radial / grid / mirror) of source art. + Repeat(RepeatSpec), + /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` + /// file, linked and locked (see [`crate::placed_document`]). + PlacedDocument(Box), + /// Object › Intertwine: the objects keep their own stacking order, except where one of them + /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see + /// [`crate::intertwine`]). + Intertwine { + children: Vec>, + }, +} + +/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every +/// object inside a group, again at each level of nesting) before reading it, since `type` may come +/// anywhere among its fields. Files have it first (it is written first): then the kind is read +/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the +/// fields before `type` are buffered, and only those. +mod kind_de { + use std::fmt; + use std::sync::Arc; + + use serde::de::value::{MapAccessDeserializer, StringDeserializer}; + use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor}; + use serde::{Deserialize, forward_to_deserialize_any}; + use vectorcraft_geom::{Affine, FillRule, PathData}; + + use super::{LayerColor, LiveShape, Node, NodeKind, yes}; + use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; + use crate::pattern::RepeatSpec; + use crate::text::TextObject; + + /// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes + /// note (the compiler checks that every field is here), read externally tagged: the kind from + /// `type`, its fields from the rest of the object ([`Tagged`]). + #[derive(Deserialize)] + #[serde(remote = "NodeKind", rename_all = "lowercase")] + enum KindDef { + Layer { + color: LayerColor, + #[serde(default)] + template: bool, + #[serde(default = "yes")] + printable: bool, + children: Vec>, + #[serde(default)] + clip: bool, + #[serde(default = "yes")] + preview: bool, + #[serde(default)] + dim_images: Option, + }, + Group { + children: Vec>, + #[serde(default)] + clip: bool, + }, + Path { + path: PathData, + #[serde(default)] + rule: FillRule, + #[serde(default)] + live: Option>, + #[serde(default)] + clipping: bool, + #[serde(default)] + guide: bool, + }, + Compound { + children: Vec>, + #[serde(default)] + rule: FillRule, + }, + Text(Box), + Image(Box), + SymbolInstance { + symbol: String, + xf: Affine, + }, + Blend { + children: Vec>, + #[serde(default)] + spec: Box, + }, + Envelope { + content: Vec>, + kind: Box, + #[serde(default = "crate::live::default_fidelity")] + fidelity: f64, + #[serde(default)] + editing: bool, + #[serde(default)] + options: crate::live::EnvelopeOptions, + #[serde(default)] + frame: Box, + }, + Mesh(GradientMesh), + Repeat(RepeatSpec), + PlacedDocument(Box), + Intertwine { + children: Vec>, + }, + } + + impl<'de> Deserialize<'de> for NodeKind { + fn deserialize>(d: D) -> Result { + d.deserialize_map(KindVisitor) + } + } + + struct KindVisitor; + + impl<'de> Visitor<'de> for KindVisitor { + type Value = NodeKind; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an object kind (a map with `type`)") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut before = vec![]; + while let Some(key) = map.next_key::()? { + if key == "type" { + let tag: String = map.next_value()?; + return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); + } + before.push((key, map.next_value::()?)); + } + Err(de::Error::missing_field("type")) + } + } + + /// The fields read before `type` (buffered), then the rest of the object. + struct Buffered { + before: std::vec::IntoIter<(String, serde_json::Value)>, + value: Option, + map: A, + } + + impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered { + type Error = A::Error; + + fn next_key_seed>(&mut self, seed: K) -> Result, A::Error> { + match self.before.next() { + Some((key, value)) => { + self.value = Some(value); + seed.deserialize(StringDeserializer::::new(key)).map(Some) + } + None => self.map.next_key_seed(seed), + } + } + + fn next_value_seed>(&mut self, seed: V) -> Result { + match self.value.take() { + Some(value) => seed.deserialize(value).map_err(de::Error::custom), + None => self.map.next_value_seed(seed), + } + } + } + + /// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`. + struct Tagged { + tag: String, + map: A, + } + + impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged { + type Error = A::Error; + + fn deserialize_any>(self, visitor: V) -> Result { + visitor.visit_enum(self) + } + + forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum + identifier ignored_any + } + } + + impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged { + type Error = A::Error; + type Variant = Fields; + + fn variant_seed>(self, seed: V) -> Result<(V::Value, Fields), A::Error> { + let kind = seed.deserialize(self.tag.into_deserializer())?; + Ok((kind, Fields(self.map))) + } + } + + /// A kind's fields: the rest of the object. + struct Fields(A); + + impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields { + type Error = A::Error; + + fn unit_variant(self) -> Result<(), A::Error> { + Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields")) + } + + fn newtype_variant_seed>(self, seed: T) -> Result { + seed.deserialize(MapAccessDeserializer::new(self.0)) + } + + fn tuple_variant>(self, _: usize, _: V) -> Result { + Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields")) + } + + fn struct_variant>(self, _: &'static [&'static str], visitor: V) -> Result { + visitor.visit_map(self.0) + } + } +} diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs new file mode 100644 index 000000000..65dad904e --- /dev/null +++ b/crates/doc/src/node/live_shape.rs @@ -0,0 +1,346 @@ +use super::*; + +/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these +/// while the shape stays live; editing anchors directly converts it to a plain path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "shape", rename_all = "lowercase")] +pub enum LiveShape { + Rectangle { + /// Untransformed width/height. Transforms fold their scale into these (see + /// [`LiveShape::transform`]), so they are document lengths unless `xf` shears. + w: f64, + h: f64, + /// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as + /// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale. + radii: [f64; 4], + /// Corner kinds, in the same order (Live Corners' corner types). + #[serde(default, skip_serializing_if = "all_round")] + kinds: [CornerKind; 4], + /// When true, `radii` are fractions in 0..1 of each corner's largest possible radius. + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, + /// Maps the untransformed shape (origin at its top-left) into the document. A rotation + /// or reflection plus a move, unless a shear (or a file saved before transforms folded + /// their scale into `w`/`h`) put more in it. + xf: Affine, + }, + Ellipse { + w: f64, + h: f64, + /// Pie start/end angles in degrees (0/360 = full ellipse). + pie: (f64, f64), + xf: Affine, + }, + Polygon { + radius: f64, + /// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read. + #[serde(deserialize_with = "de_sides")] + sides: u32, + xf: Affine, + /// Corner radii in document units, by vertex from the first one clockwise (none: sharp). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + radii: Vec, + /// Corner kinds, in the same order (none: round). + #[serde(default, skip_serializing_if = "all_round")] + kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, + }, + Line { + a: Point, + b: Point, + }, + /// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut + /// outline so the corners stay editable. Without a cut corner it is a plain path again. + Path { + /// The path with its corners uncut, in the document. + base: PathData, + /// Corner radii in document units, by anchor index of `base` (counting every subpath's + /// anchors in order; missing: sharp). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + radii: Vec, + /// Corner kinds, in the same order (missing: round). + #[serde(default, skip_serializing_if = "all_round")] + kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, + }, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +impl LiveShape { + pub fn radii_relative(&self) -> bool { + match self { + LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => { + *radii_relative + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false, + } + } + pub fn label(&self) -> &'static str { + match self { + LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", + LiveShape::Rectangle { .. } => "Rectangle", + LiveShape::Ellipse { .. } => "Ellipse", + LiveShape::Polygon { .. } => "Polygon", + LiveShape::Line { .. } => "Line", + LiveShape::Path { .. } => "Path", + } + } + /// Regenerate the path. + pub fn to_path(&self) -> PathData { + match self { + LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => { + let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); + let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative); + let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); + shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf) + } + LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), + LiveShape::Polygon { radii, kinds, radii_relative, .. } => { + let outline = self.polygon_outline(); + let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative); + cut_corners(&outline, &radii, kinds).0 + } + LiveShape::Line { a, b } => shapes::line(*a, *b), + LiveShape::Path { base, radii, kinds, radii_relative } => { + let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative); + cut_corners(base, &radii, kinds).0 + } + } + } + /// A polygon's outline with its corners uncut, in the document (empty for other shapes). + pub fn polygon_outline(&self) -> PathData { + match self { + LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf), + _ => PathData::default(), + } + } + /// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else + /// the corners past the old last one are sharp. + pub fn set_sides(&mut self, n: u64) { + let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return }; + *sides = n.clamp(3, 1000) as u32; + fn spread(v: &mut Vec, n: usize) { + match v.first().copied() { + Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n], + Some(_) => v.resize(n, T::default()), + None => {} + } + } + spread(radii, *sides as usize); + spread(kinds, *sides as usize); + } + /// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of + /// `xf` (exact while its sides are equal). None for other shapes. + pub fn polygon_radius(&self) -> Option { + let LiveShape::Polygon { radius, xf, .. } = self else { return None }; + Some(radius * xf.determinant().abs().sqrt()) + } + /// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by + /// from straight up (the Rotate field's sense). None for other shapes. + pub fn polygon_angle(&self) -> Option { + let LiveShape::Polygon { .. } = self else { return None }; + // No −0° or 360° from rounding: a polygon drawn upright reads 0°. + let a = (-self.angle_deg()).rem_euclid(360.0); + Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 }) + } + /// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True + /// for other shapes. + pub fn polygon_sides_equal(&self) -> bool { + let LiveShape::Polygon { xf, .. } = self else { return true }; + let [a, b, c, d, _, _] = xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + (sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy + } + /// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]). + pub fn set_polygon_radius(&mut self, r: f64) { + let LiveShape::Polygon { radius, xf, .. } = self else { return }; + let k = xf.determinant().abs().sqrt(); + if k > 1e-12 && r.is_finite() { + *radius = r.max(0.0) / k; + } + } + /// The document radius that makes a polygon's sides `side` long (with its sides equal). + pub fn polygon_radius_for_side(&self, side: f64) -> Option { + let LiveShape::Polygon { sides, .. } = self else { return None }; + Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin())) + } + /// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]). + pub fn set_polygon_angle(&mut self, angle: f64) { + let Some(now) = self.polygon_angle() else { return }; + if let LiveShape::Polygon { xf, .. } = self + && angle.is_finite() + { + let centre = *xf * Point::ZERO; + *xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf; + } + } + /// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from + /// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`]. + pub fn make_sides_equal(&mut self) { + let Some(r) = self.polygon_radius() else { return }; + let LiveShape::Polygon { radius, xf, .. } = self else { return }; + let [a, b, _, _, e, f] = xf.as_coeffs(); + let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY }; + *xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip; + *radius = r; + } + /// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so + /// the caller must regenerate the path with [`LiveShape::to_path`]. + /// + /// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own + /// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps + /// only the rotation, reflection and position. The radii scale by the mean scale (the square + /// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off. + /// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a + /// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a + /// path's corner radii are document lengths: they scale by the mean scale and the corners are + /// cut again, circular whatever `a` does. + pub fn transform(&mut self, a: Affine) -> bool { + match self { + LiveShape::Rectangle { xf, .. } => { + *xf = a * *xf; + !moves_only(a) && self.fold_scale() + } + LiveShape::Ellipse { xf, .. } => { + *xf = a * *xf; + false + } + LiveShape::Polygon { xf, radii, radii_relative, .. } => { + *xf = a * *xf; + scale_radii(radii, a, *radii_relative) + } + LiveShape::Path { base, radii, radii_relative, .. } => { + base.transform(a); + scale_radii(radii, a, *radii_relative) + } + LiveShape::Line { a: p, b } => { + *p = a * *p; + *b = a * *b; + false + } + } + } + /// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so + /// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the + /// document. False (and nothing changes) when there is no scale to move, when `xf` shears or + /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an + /// uneven scale in `xf`: their corners are elliptical until this runs. + pub fn fold_scale(&mut self) -> bool { + let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false }; + let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); + let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); + let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; + let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite(); + let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12; + if !orthogonal || !finite || unscaled { + return false; + } + let k = (sx * sy).sqrt(); + if !*radii_relative { + radii.iter_mut().for_each(|r| *r *= k); + } + *w *= sx; + *h *= sy; + *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); + true + } + /// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document + /// units (what Live Corners show and edit). + pub fn folded(&self) -> Self { + let mut s = self.clone(); + s.fold_scale(); + s + } + /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners + /// keep their size (Scale Corners off). False when nothing changed. + pub fn keep_corners(&mut self, k: f64) -> bool { + let relative = self.radii_relative(); + let radii: &mut [f64] = match self { + LiveShape::Rectangle { radii, .. } => radii, + LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false, + }; + if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { + return false; + } + radii.iter_mut().for_each(|r| *r /= k); + true + } + /// Rotation angle of the live shape in degrees, clockwise on the page (y points down). + pub fn angle_deg(&self) -> f64 { + match self { + LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => { + let c = xf.as_coeffs(); + c[1].atan2(c[0]).to_degrees() + } + LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(), + LiveShape::Path { .. } => 0.0, + } + } +} + +fn all_round(kinds: &[CornerKind]) -> bool { + kinds.iter().all(|k| *k == CornerKind::Round) +} + +/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its +/// corner lists hold one entry per side, so a huge count would allocate gigabytes. +fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + u32::deserialize(d).map(|n| n.clamp(3, 1000)) +} + +/// Whether `a` only moves (no rotation, scale, shear or reflection). +pub(super) fn moves_only(a: Affine) -> bool { + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); + (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12 +} + +/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True +/// when the path must be cut again: some corner is cut and `a` does more than move it (an +/// uneven scale would otherwise make the cuts elliptical). +fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool { + if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) { + return false; + } + let k = a.determinant().abs().sqrt(); + radii.iter_mut().for_each(|r| *r *= k); + true +} + +/// What a transform scales besides geometry, by its mean scale (the square root of its +/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke +/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects +/// and Scale Corners. +#[derive(Clone, Copy, Debug, Default)] +pub struct Scaling { + /// Stroke weights, dash lengths and dash offsets scale. + pub strokes: bool, + /// With `strokes`: scales an effect's distance parameters (the effects catalogue's). + pub effects: Option, + /// Type keeps its character strokes' weight (they otherwise scale with the type). + pub keep_type_strokes: bool, + /// Live corner radii keep their size. + pub keep_corners: bool, + /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform + /// Pattern Tiles is its default): their tiles transform too, instead of staying put. + pub patterns: bool, +} + +impl Scaling { + /// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by. + pub fn factor(a: Affine) -> Option { + let k = a.determinant().abs().sqrt(); + ((k - 1.0).abs() > 1e-9).then_some(k) + } +} + +impl From for Scaling { + fn from(strokes: bool) -> Self { + Self { strokes, ..Self::default() } + } +} diff --git a/crates/doc/src/node/opacity.rs b/crates/doc/src/node/opacity.rs new file mode 100644 index 000000000..0996b8c65 --- /dev/null +++ b/crates/doc/src/node/opacity.rs @@ -0,0 +1,129 @@ +use super::kind::NodeKind; +use super::*; + +/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OpacityMask { + /// Mask art in document coordinates. + pub art: Arc, + /// Clip: outside the mask art is hidden. Off: outside the mask art stays visible. + #[serde(default = "yes")] + pub clip: bool, + /// Invert the mask's luminance. + #[serde(default)] + pub invert: bool, + /// Disabled masks are kept but not applied. + #[serde(default)] + pub disabled: bool, + /// Linked masks move with the object. + #[serde(default = "yes")] + pub linked: bool, +} + +impl OpacityMask { + pub fn new(art: Node, clip: bool) -> Self { + Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true } + } +} + +/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity +/// mask's, whose luminance over the black outside the art is then the art's alpha. +pub fn whiten(n: &mut Node) { + let white = |p: &mut Paint| match p { + Paint::Solid { color, swatch, tint } => { + (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); + } + Paint::Gradient(g) => { + for s in &mut g.gradient.stops { + (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); + } + } + Paint::None | Paint::Pattern { .. } => {} + }; + for item in &mut n.appearance.items { + white(item.paint_mut()); + } + match &mut n.kind { + NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), + NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { + white(&mut r.style.fill); + white(&mut r.style.stroke); + }), + _ => {} + } + if let Some(ch) = n.children_mut() { + for c in ch { + whiten(Arc::make_mut(c)); + } + } +} + +/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a +/// knockout group each child composites against the group's backdrop, so it hides the children +/// below it instead of showing them through its transparency. Neutral passes the enclosing +/// group's setting through to the children. Files from before the three states stored a bool: +/// `true` loads as On, `false` as Neutral. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Knockout { + #[default] + Neutral, + Off, + On, +} + +impl Knockout { + pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On]; + + /// Name in files and command parameters. + pub fn label(self) -> &'static str { + match self { + Knockout::Neutral => "neutral", + Knockout::Off => "off", + Knockout::On => "on", + } + } + /// A name ([`Self::label`], any case) or a bool as older files and commands wrote it. + pub fn from_value(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Bool(b) => Some(Self::from(*b)), + serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())), + _ => None, + } + } + /// The state a click on the checkbox moves to: on → neutral → off → on. + pub fn cycle(self) -> Self { + match self { + Knockout::On => Knockout::Neutral, + Knockout::Neutral => Knockout::Off, + Knockout::Off => Knockout::On, + } + } + /// Whether children of a group with this state knock each other out, inside a group (or page) + /// where that is `enclosing`. + pub fn resolve(self, enclosing: bool) -> bool { + match self { + Knockout::Neutral => enclosing, + Knockout::Off => false, + Knockout::On => true, + } + } +} + +impl From for Knockout { + fn from(b: bool) -> Self { + if b { Knockout::On } else { Knockout::Neutral } + } +} + +impl Serialize for Knockout { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.label()) + } +} + +impl<'de> Deserialize<'de> for Knockout { + fn deserialize>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}"))) + } +} diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs new file mode 100644 index 000000000..6cc0cb3be --- /dev/null +++ b/crates/doc/src/node/ops.rs @@ -0,0 +1,712 @@ +use super::extras::Extras; +use super::ids::{LayerColor, NodeId}; +use super::kind::NodeKind; +use super::live_shape::{Scaling, moves_only}; +use super::opacity::Knockout; +use super::*; + +impl Node { + pub fn new(id: NodeId, kind: NodeKind) -> Self { + Self { + id, + name: None, + visible: true, + locked: false, + opacity: 1.0, + blend: BlendMode::Normal, + isolate: false, + knockout: Knockout::Neutral, + knockout_shape: false, + appearance: Appearance::default().into(), + kind, + bbox_angle: 0.0, + extras: Extras::default(), + } + } + + pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { + let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }); + n.appearance = appearance.into(); + n + } + + pub fn group(id: NodeId, children: Vec>) -> Self { + Self::new(id, NodeKind::Group { children, clip: false }) + } + + pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { + let mut n = Self::new( + id, + NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None }, + ); + n.name = Some(name.to_string()); + n + } + + pub fn children(&self) -> Option<&Vec>> { + match &self.kind { + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Blend { children, .. } + | NodeKind::Envelope { content: children, .. } + | NodeKind::Repeat(RepeatSpec { source: children, .. }) + | NodeKind::Intertwine { children } => Some(children), + _ => None, + } + } + + pub fn children_mut(&mut self) -> Option<&mut Vec>> { + match &mut self.kind { + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Blend { children, .. } + | NodeKind::Envelope { content: children, .. } + | NodeKind::Repeat(RepeatSpec { source: children, .. }) + | NodeKind::Intertwine { children } => Some(children), + _ => None, + } + } + + /// Does this object or layer move with its artboard? Locked and hidden ones only with + /// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with + /// Artboard). + pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool { + locked_and_hidden || (!self.locked && self.visible) + } + + pub fn is_layer(&self) -> bool { + matches!(self.kind, NodeKind::Layer { .. }) + } + + /// A template layer (Layer Options → Template). + pub fn is_template(&self) -> bool { + matches!(self.kind, NodeKind::Layer { template: true, .. }) + } + + /// The art objects of this container: its children, looking through sublayers, which are not + /// objects (bottom first). With `editable`, only the visible, unlocked objects in visible, + /// unlocked, non-template sublayers. + pub fn layer_art(&self, editable: bool) -> Vec { + fn visit(n: &Node, editable: bool, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if editable && (!c.visible || c.locked || c.is_template()) { + continue; + } + if c.is_layer() { + visit(c, editable, out); + } else { + out.push(c.id); + } + } + } + let mut out = vec![]; + visit(self, editable, &mut out); + out + } + + pub fn is_container(&self) -> bool { + self.children().is_some() + } + + /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel + /// fills an object's target circle otherwise). + pub fn has_default_transparency(&self) -> bool { + self.opacity >= 1.0 + && self.blend == BlendMode::Normal + && !self.isolate + && self.knockout == Knockout::Neutral + && !self.knockout_shape + && self.extras().mask.is_none() + } + + /// Kind name as the Layers panel / Properties panel shows it. + pub fn kind_label(&self) -> &'static str { + match &self.kind { + NodeKind::Layer { .. } => "Layer", + NodeKind::Group { clip: true, .. } => "Clip Group", + NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path", + NodeKind::Group { .. } => "Group", + NodeKind::Path { live: Some(l), .. } => l.label(), + NodeKind::Path { guide: true, .. } => "Guide", + NodeKind::Path { .. } => "Path", + NodeKind::Compound { .. } => "Compound Path", + NodeKind::Text(_) => "Type", + NodeKind::Image(_) => "Image", + NodeKind::SymbolInstance { .. } => "Symbol", + NodeKind::Blend { .. } => "Blend", + NodeKind::Envelope { .. } => "Envelope", + NodeKind::Mesh(_) => "Mesh", + NodeKind::Repeat(r) => r.kind.label(), + NodeKind::PlacedDocument(_) => "Placed Document", + NodeKind::Intertwine { .. } => "Intertwine", + } + } + + /// Name shown in the Layers panel: explicit name or ``. + pub fn display_name(&self) -> String { + if let Some(n) = &self.name { + return n.clone(); + } + match &self.kind { + NodeKind::Text(t) => { + let s: String = t.plain_text().chars().take(32).collect(); + if s.is_empty() { "".into() } else { s } + } + _ => format!("<{}>", self.kind_label()), + } + } + + /// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID. + pub fn identify_label(&self, xml_id: bool) -> String { + let name = self.display_name(); + if xml_id { crate::xml_id::from_object_name(&name) } else { name } + } + + /// Path data for path nodes. + pub fn path_data(&self) -> Option<&PathData> { + match &self.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + } + } + + pub fn path_data_mut(&mut self) -> Option<&mut PathData> { + match &mut self.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + } + } + + /// The path a path's or compound path's own strokes follow (a compound's members' together). + pub fn stroke_path(&self) -> Option { + 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())); + Some(bp) + } + _ => None, + } + } + + /// A quick box round everything the object can paint, for culling clicks: paths and compound + /// paths grow their geometric bounds by the farthest their strokes can reach from any path + /// ([`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`]. + pub fn reach_bounds(&self) -> Option { + self.reach(None) + } + + /// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from + /// `symbols`. An instance that only moves its art is measured as the art's visual box moved, + /// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`]) + /// grown by the farthest a stroke in the art can reach. + pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { + symbols.quickly(|| self.reach(Some(symbols))) + } + + /// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given. + pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option { + match &self.kind { + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let o = self.appearance.outset(); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + // The members' reach, grown by the container's own strokes. + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + let o = self.appearance.outset(); + children + .iter() + .skip(usize::from(self.extras().shaper.is_some())) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))) + .map(|b| b.inflate(o, o)) + } + NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))), + // The frame and each shape the image's own fills and strokes paint, a stroke's grown by + // the farthest it can reach from its shape. + NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| { + 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))) + }), + _ => self.visual(symbols), + } + } + + /// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A + /// symbol instance is measured as its transform applied to the 20 × 20 square around the origin + /// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures + /// its art). + pub fn geometric_bounds(&self) -> Option { + self.geometric(None) + } + + /// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to + /// its symbol's art box from `symbols`. An instance is measured as the square when its symbol + /// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and + /// when symbols nest 32 deep in its art. + pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option { + self.geometric(Some(symbols)) + } + + /// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a + /// hidden bitmap for re-tracing/release; only source-image views measure that bitmap. + pub fn editing_children(&self) -> &[Arc] { + let children = self.children().map(Vec::as_slice).unwrap_or_default(); + let trace_source = self.extras().trace.is_some() + && !self.trace_view().shows_source() + && children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_))); + let skip = usize::from(self.extras().shaper.is_some() || trace_source); + children.get(skip..).unwrap_or_default() + } + + /// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols` + /// is given. + pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option { + let union = |nodes: &[Arc]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))); + match &self.kind { + NodeKind::Path { path, .. } => path.bounds(), + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + children.first().and_then(|c| c.geometric(symbols)) + } + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self + .editing_children() + .iter() + .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))), + NodeKind::Text(t) => self.projected(t.bounds()), + NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), + NodeKind::SymbolInstance { symbol, xf } => { + let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric); + self.projected(Some(xf.transform_rect_bbox(art))) + } + NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())), + NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), + NodeKind::Mesh(m) => m.bounds(), + // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the + // copies out by the source's bounds measured without the document. + NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())), + NodeKind::PlacedDocument(p) => Some(p.bounds()), + // The members drawn over the others are drawn where they are. + NodeKind::Intertwine { children } => union(children), + } + } + + /// Visual bounds: what the object paints, its strokes included (paths and compound paths + /// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is + /// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`], + /// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art). + pub fn visual_bounds(&self) -> Option { + self.visual(None) + } + + /// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from + /// `symbols`, the strokes in it included. An instance that only moves its art is measured as + /// the art's visual box moved. Any other instance is measured as its art transformed by it with + /// the strokes at their own weight, as the renderer draws it, and never smaller than its + /// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as + /// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of + /// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In + /// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's + /// geometric box placed by its transform, grown by its own strokes. + pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option { + self.visual(Some(symbols)) + } + + /// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is + /// given. + pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option { + match &self.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + // A clipping path that paints (see `clip_paint`) strokes over the clip edge. + let clip = children.first()?; + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + clip.visual(symbols) + } else { + clip.geometric(symbols) + } + } + NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), + NodeKind::Layer { .. } | NodeKind::Group { .. } => { + // The container's own strokes paint around its members. + let o = self.appearance.outset(); + self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) + } + NodeKind::Blend { children, spec } => { + let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); + let o = crate::live::max_outset(children, symbols); + vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o))) + } + NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => { + let o = crate::live::max_outset(content, symbols); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let b = self.geometric(symbols)?; + Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default())) + } + NodeKind::SymbolInstance { symbol, xf } => { + let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) { + Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric), + Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), + Some((s, art)) => { + let placed = xf.transform_rect_bbox(art.geometric); + match s.placed_visual(symbol, *xf) { + Some(v) => v.union(placed), + None => placed.inflate(art.reach, art.reach), + } + } + None => xf.transform_rect_bbox(SYMBOL_SQUARE), + }; + let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol)); + self.projected(Some(b)).map(|b| b.inflate(o, o)) + } + NodeKind::Text(t) => { + // The characters' strokes paint around the glyphs. + let o = self.appearance.outset().max(t.char_stroke_outset()); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + // An image is measured by its frame, without its own fills and strokes. + NodeKind::Image(_) => self.geometric(symbols), + _ => { + let o = self.appearance.outset(); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + } + } + + /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. + /// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights + /// only, see [`Scaling`]). + pub fn transform(&mut self, a: Affine, scaling: impl Into) { + self.transform_scaled(a, &scaling.into()); + } + + fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { + // A dimension's measurement moves with it. + if let Some(e) = self.extras_if_any() + && let Some(dimension) = &mut e.dimension + { + 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); + } + if !self.is_layer() { + self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); + } + // Type and symbols in perspective keep looking the same, moved by `a`. + self.transform_projection(a); + // Refitting an unplaced gradient only reproduces moves and uniform scales. + if !keeps_gradient_fit(a) { + self.pin_gradients(); + } + let k = Scaling::factor(a); + if let Some(k) = k + && sc.strokes + { + self.appearance.scale_strokes(k); + if let Some(f) = sc.effects { + self.appearance.scale_effects(k, f); + } + } + // Only through `DerefMut` when there is something to change: the appearance is shared + // copy-on-write (`Shared`), and a move must not copy what it leaves as it is. + if self.appearance.gradients().next().is_some() { + self.appearance.transform_gradients(a); + } + if sc.patterns { + crate::pattern::transform_pattern_paints(self, a); + } + match &mut self.kind { + NodeKind::Path { path, live, .. } => { + path.transform(a); + if let Some(l) = live { + // A rectangle scaled unevenly keeps circular corners: its path is regenerated. + let reshaped = l.transform(a); + let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k)); + if reshaped || kept { + *path = l.to_path(); + } + } + } + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Intertwine { children } => { + for c in children.iter_mut() { + Arc::make_mut(c).transform_scaled(a, sc); + } + } + NodeKind::Text(t) => { + t.transform(a); + // Character strokes scale with the type: undo that when strokes keep their weight. + if sc.keep_type_strokes + && let Some(k) = k.filter(|k| *k > 1e-12) + { + t.scale_char_strokes(1.0 / k); + } + } + NodeKind::Image(im) => im.xf = a * im.xf, + NodeKind::PlacedDocument(p) => p.xf = a * p.xf, + NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, + NodeKind::Blend { children, spec } => { + for c in children.iter_mut() { + Arc::make_mut(c).transform_scaled(a, sc); + } + if let Some(s) = &mut spec.spine { + s.transform(a); + } + } + NodeKind::Envelope { content, kind, frame, .. } => { + for c in content.iter_mut() { + Arc::make_mut(c).transform_scaled(a, sc); + } + **frame = crate::live::envelope_frame(a * **frame); + match &mut **kind { + EnvelopeKind::Mesh { points, handles, .. } => { + for p in points.iter_mut() { + *p = a * *p; + } + // Handles are offsets: they take the linear part. + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]); + for h in handles.iter_mut().flatten() { + *h = (lin * h.to_point()).to_vec2(); + } + } + EnvelopeKind::TopObject { path } => path.transform(a), + EnvelopeKind::Warp { .. } => {} + } + } + NodeKind::Mesh(m) => m.transform(a), + NodeKind::Repeat(r) => r.transform(a, *sc), + } + 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); + } + } + + /// Visit this node and all descendants depth first (paint order). + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { + f(self); + if let Some(ch) = self.children() { + for c in ch { + c.walk(f); + } + } + } + + /// Count of nodes in this subtree. + pub fn count(&self) -> usize { + let mut n = 0; + self.walk(&mut |_| n += 1); + n + } + + /// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying + /// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks. + pub fn count_with_masks(&self) -> usize { + let mut n = 0; + self.walk(&mut |c| { + n += 1; + if let Some(m) = &c.extras().mask { + n += m.art.count_with_masks(); + } + }); + n + } + + /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform + /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. + pub fn pin_gradients(&mut self) { + if self.appearance.has_unplaced_gradient() + && let Some(b) = self.geometric_bounds() + { + self.appearance.pin_gradients(b); + } + } + + /// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and + /// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active + /// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise + /// type objects paint their runs (the first run speaks for all) in text space, fitted to the + /// layout bounds; everything else paints its fill or stroke in the document, fitted to the + /// geometric bounds. Strokes fit the box grown by half their weight. + pub fn proxy_paint(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { + let item = self.appearance.item_of_kind(item, !stroke); + if item.is_none() + && let NodeKind::Text(t) = &self.kind + { + let st = &t.runs.first()?.style; + let b = t.local_bounds(); + return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); + } + // An object without bounds (an empty path) still shows its paints, fitted to an empty box. + let b = self.geometric_bounds().unwrap_or(Rect::ZERO); + if stroke { + self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) + } else { + self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b)) + } + } + + /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates + /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. + pub fn proxy_gradient(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { + let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; + let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; + let mut geom = g.resolve(b); + if to_doc != Affine::IDENTITY { + geom.transform(to_doc, g.gradient.kind); + } + Some((g, geom)) + } + + /// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates + /// (the automatic ones while unplaced), and the length its spreads are fractions of there + /// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits. + pub fn proxy_freeform(&self, stroke: bool, item: Option) -> Option<(vectorcraft_color::Freeform, f64)> { + let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; + let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; + if g.gradient.kind != vectorcraft_color::GradientKind::Freeform { + return None; + } + let mut f = g.freeform_on(b).into_owned(); + let mut scale = vectorcraft_color::freeform::spread_scale(b); + if to_doc != Affine::IDENTITY { + f.transform(to_doc); + scale *= to_doc.determinant().abs().sqrt(); + } + Some((f, scale)) + } +} + +impl Node { + /// Whether the children of this group or layer knock each other out, inside a group (or page) + /// where that is `enclosing`. + pub fn knocks_out(&self, enclosing: bool) -> bool { + matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing) + } + + /// A plain group or layer that is neutral to knockout and has no transparency or appearance + /// of its own: inside a knockout group its children composite as the group's own children. + pub fn passes_knockout_through(&self) -> bool { + matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. }) + && self.has_default_transparency() + && self.appearance.items.is_empty() + && self.appearance.effects.is_empty() + } + + /// The visible elements of a knockout group made of `children` (bottom first): neutral plain + /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. + pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { + fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { + for c in children.iter().filter(|c| c.visible) { + match c.children() { + Some(ch) if c.passes_knockout_through() => push(ch, out), + _ => out.push(c), + } + } + } + let mut out = Vec::with_capacity(children.len()); + push(children, &mut out); + out + } + + /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + pub fn clips(&self) -> bool { + matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + } + + /// Make this group or layer clip its children by its first child, or stop it. + pub fn set_clips(&mut self, on: bool) { + if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind { + *clip = on; + } + } + + /// Whether blending inside this object reaches the art below it unless the object isolates + /// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke), + /// directly or through children that don't isolate their own blending. + pub fn blends_through(&self) -> bool { + self.blends_through_with(&mut |c| c.blends_through()) + } + + /// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache). + pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc) -> bool) -> bool { + match self.children() { + Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner), + _ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal), + } + } + + /// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child). + pub fn children_blend(children: &[Arc], inner: &mut dyn FnMut(&Arc) -> bool) -> bool { + children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c)))) + } +} + +impl Node { + /// The Appearance panel's row for what this object's own fills and strokes paint around: + /// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters" + /// for type; `None` for objects whose fills and strokes paint their own geometry. + pub fn contents_label(&self) -> Option<&'static str> { + match self.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"), + NodeKind::Text(_) => Some("Characters"), + _ => None, + } + } +} + +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. + pub fn shows_transparency(&self) -> 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); + self.visible + && (self.opacity < 1.0 + || self.blend != BlendMode::Normal + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) + || 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()))) + } +} + +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? +fn keeps_gradient_fit(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let eps = 1e-12 * m0.abs().max(1.0); + m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps +} + +impl Node { + /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true + /// everywhere for objects without them, such as type, whose boxes stand in). + pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> { + let shapes = self.clip_shapes(None); + move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) + } +} diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs new file mode 100644 index 000000000..3e7a399f2 --- /dev/null +++ b/crates/doc/src/node/tests.rs @@ -0,0 +1,435 @@ +use super::*; + +/// A node writes its rarely set parts among the others, in the file's field order, and reads +/// them back into its extras; a node without any keeps none. +#[test] +fn extras_are_written_flat_in_order() { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + n.name = Some("n".into()); + n.knockout_shape = true; + n.bbox_angle = 30.0; + { + let mut e = n.extras_mut(); + e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); + e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); + e.series_index = Some(2); + e.type_outlines = true; + e.graphic_style = Some(3); + e.attrs = Some(Box::default()); + e.slice = Some(Box::default()); + e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); + e.live_paint_gaps = Some(Box::default()); + } + /// The top-level keys of a JSON object, in the order written. + struct Keys(Vec); + impl<'de> Deserialize<'de> for Keys { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Keys; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object") + } + fn visit_map>(self, mut a: A) -> Result { + let mut out = vec![]; + while let Some(k) = a.next_key::()? { + a.next_value::()?; + out.push(k); + } + Ok(Keys(out)) + } + } + d.deserialize_map(V) + } + } + let text = serde_json::to_string(&n).unwrap(); + let keys = serde_json::from_str::(&text).unwrap().0; + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let order = [ + "id", + "name", + "knockout_shape", + "appearance", + "mask", + "trace", + "series_index", + "type_outlines", + "kind", + "graphic_style", + "attrs", + "slice", + "bbox_angle", + "perspective", + "live_paint_gaps", + ]; + assert_eq!(keys, order); + assert_eq!(serde_json::from_value::(v).unwrap(), n); + let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); + assert!(back.extras_box().is_none()); + // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. + n.extras_mut().mask = None; + assert!(n.extras_box().is_some()); + *n.extras_mut() = NodeExtras::default(); + assert!(n.extras_box().is_none()); + n.extras_mut().mask = None; + assert!(n.extras_box().is_none()); + assert_eq!(n.extras(), &NodeExtras::default()); +} + +#[test] +fn rect_live_shape_roundtrip() { + let l = LiveShape::Rectangle { + w: 10.0, + h: 20.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((5.0, 5.0)), + }; + assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); + assert_eq!(l.label(), "Rectangle"); +} + +#[test] +fn corner_kinds_save_only_when_some_corner_isnt_round() { + let mut l = LiveShape::Rectangle { + w: 100.0, + h: 50.0, + radii: [0.0, 10.0, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + // Older files read as round. + let json = serde_json::to_value(&l).unwrap(); + assert!(json.get("kinds").is_none(), "{json}"); + assert_eq!(serde_json::from_value::(json).unwrap(), l); + if let LiveShape::Rectangle { kinds, .. } = &mut l { + kinds[1] = CornerKind::Chamfer; + } + let json = serde_json::to_value(&l).unwrap(); + assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); + assert_eq!(serde_json::from_value::(json).unwrap(), l); + // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. + let p: LiveShape = serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); + assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); + let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); + let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; + let json = serde_json::to_value(&path).unwrap(); + assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); + assert_eq!(serde_json::from_value::(json).unwrap(), path); + assert_eq!(path.label(), "Path"); +} + +/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one +/// with four billion made its outline and corner lists allocate tens of gigabytes. +#[test] +fn a_polygon_from_a_file_has_3_to_1000_sides() { + let read = |sides: u32| { + let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]}); + serde_json::from_value::(json).unwrap() + }; + let sides_of = |l: &LiveShape| match l { + LiveShape::Polygon { sides, .. } => *sides, + _ => panic!("not a polygon: {l:?}"), + }; + let huge = read(4_000_000_000); + assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]); + assert_eq!(sides_of(&huge), 1000); + assert_eq!(huge.polygon_outline().anchor_count(), 1000); +} + +#[test] +fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. + let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); + let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; + assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); + p.set_polygon_angle(45.0); + assert!(close(p.polygon_angle().unwrap(), 45.0)); + let centre = p.to_path().bounds().unwrap().center(); + assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}"); + p.set_polygon_radius(30.0); + assert!(close(p.polygon_radius().unwrap(), 30.0)); + // A hexagon's side is as long as its radius. + assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0)); + // Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius. + p.transform(Affine::scale_non_uniform(2.0, 1.0)); + assert!(!p.polygon_sides_equal()); + let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap()); + p.make_sides_equal(); + assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a)); + let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") }; + assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0)); + // Other shapes have none of it. + let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None)); +} + +/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. +fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; + let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + n +} + +fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { + let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; + (*w, *h, *radii, *xf) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn near_xf(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) +} + +/// Size and radius of a live rectangle, within rounding. +fn assert_live(n: &Node, w: f64, h: f64, r: f64) { + let (lw, lh, radii, _) = live_of(n); + assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}"); +} + +/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation), +/// spans `r` along x and y: the corner is a circular arc of radius `r`. +fn assert_round_corners(n: &Node, back: Affine, r: f64) { + let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") }; + let a: Vec = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect(); + assert_eq!(a.len(), 8, "a rounded rectangle"); + // Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs. + for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] { + let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); + assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}"); + } +} + +const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; + +#[test] +fn uneven_scales_keep_live_corners_circular() { + // A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291). + let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0))); + let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0)); + n.transform(stretch, KEEP_CORNERS); + assert_live(&n, 300.0, 100.0, 20.0); + assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size"); + let b = n.geometric_bounds().unwrap(); + assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}"); + assert_round_corners(&n, Affine::IDENTITY, 20.0); + // Scale Corners on: the radius scales by the mean scale, still circular. + let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); + n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default()); + assert_live(&n, 400.0, 100.0, 40.0); + assert_round_corners(&n, Affine::IDENTITY, 40.0); + // Squashed below the radius, the short sides are fully round; stretched back, it returns. + n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS); + assert_round_corners(&n, Affine::IDENTITY, 25.0); + n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS); + assert_round_corners(&n, Affine::IDENTITY, 40.0); +} + +#[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()); + let mut n = live_rect(100.0, 50.0, 10.0, place); + // Stretched along its own width, as a bounding-box drag of a rotated shape does. + n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS); + assert_live(&n, 200.0, 50.0, 10.0); + assert!(near_xf(live_of(&n).3, place)); + assert_round_corners(&n, place.inverse(), 10.0); + // A reflection stays in `xf`; the corners stay as they were. + n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS); + assert_live(&n, 200.0, 50.0, 10.0); + let xf = live_of(&n).3; + assert!(near(xf.determinant(), -1.0)); + assert_round_corners(&n, xf.inverse(), 10.0); +} + +#[test] +fn shears_and_moves_keep_the_old_transform() { + // Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before. + let rot = Affine::rotate(30f64.to_radians()); + let mut n = live_rect(100.0, 50.0, 10.0, rot); + let before = n.clone(); + let a = Affine::scale_non_uniform(2.0, 1.0); + n.transform(a, Scaling::default()); + assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot)); + let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() }; + assert_eq!(*path, old.transformed(a)); + // A file saved with an uneven scale in `xf` keeps its exact geometry when moved... + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0); + let mut n = live_rect(100.0, 100.0, 20.0, legacy); + n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS); + assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy)); + // ...and once scaled, has document units and circular corners of its mean radius. + n.transform(Affine::scale(2.0), Scaling::default()); + assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0); + assert!(near(live_of(&n).3.determinant(), 1.0)); + assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); +} + +/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live +/// Corners see and edit it in document units, with circular corners (#442). +#[test] +fn a_scale_left_in_the_transform_folds_into_the_size() { + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); + let n = live_rect(50.0, 80.0, 20.0, legacy); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; + let folded = live.folded(); + assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); + let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; + // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. + assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); + assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); + let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); + assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); + let corners = crate::LiveCorners::of(&n).unwrap(); + assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units"); + // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. + let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); + let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); + for xf in [place, shear] { + let mut l = live_rect(100.0, 160.0, 20.0, xf); + let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; + assert!(!live.fold_scale()); + assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); + } +} + +#[test] +fn default_transparency() { + let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert!(p().has_default_transparency()); + let tweaks: [fn(&mut Node); 7] = [ + |n| n.opacity = 0.5, + |n| n.blend = BlendMode::Multiply, + |n| n.isolate = true, + |n| n.knockout = Knockout::On, + |n| n.knockout = Knockout::Off, + |n| n.knockout_shape = true, + |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + ]; + for tweak in tweaks { + let mut n = p(); + tweak(&mut n); + assert!(!n.has_default_transparency(), "{n:?}"); + } +} + +#[test] +fn transform_group_recurses() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); + g.transform(Affine::scale(2.0), true); + assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); + let child = &g.children().unwrap()[0]; + assert_eq!(child.appearance.stroke_width(), 2.0); +} + +#[test] +fn unplaced_gradients_pin_only_when_a_refit_would_differ() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; + let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); + let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { + (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), + _ => panic!("gradient fill and stroke"), + }; + let mut n = fresh(); + n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); + assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); + let mut n = fresh(); + n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); + let (f, s) = geoms(&n); + // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. + let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + let f = f.unwrap(); + assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); + assert!((s.unwrap().length() - 102.0).abs() < 1e-9); +} + +#[test] +fn display_names() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert_eq!(p.display_name(), ""); + let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); + assert_eq!(l.display_name(), "Layer 1"); +} + +#[test] +fn visual_bounds_include_stroke() { + let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; + assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); +} + +/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the +/// weight centred, the whole weight outside, nothing inside; scaled with the type. +#[test] +fn type_visual_bounds_grow_with_the_character_stroke() { + use crate::appearance::StrokeAlign; + use crate::text::{CharStyle, TextObject}; + let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); + t.xf = Affine::scale(2.0) * t.xf; + let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + let g = node(&t).geometric_bounds().unwrap(); + for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { + t.runs[0].style.stroke_align = align; + assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); + } + t.runs[0].style.stroke = vectorcraft_color::Paint::None; + assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); +} + +/// A group holding a path, a compound, an image and a nested group, as a file writes it. +fn nested_group() -> Node { + let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); + let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); + let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; + Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) +} + +#[test] +fn kinds_read_back_with_type_first_or_anywhere() { + let n = nested_group(); + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), n); + // Through a JSON value, whose keys are sorted: `type` comes after `children`. + let v = serde_json::to_value(&n).unwrap(); + assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); + assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); + // `type` last, in the middle, and keys a newer version added (ignored, as before). + let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; + assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); + let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; + assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) }); + let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; + assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); + // Defaults as before. + let NodeKind::Layer { printable, preview, template, .. } = + serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() + else { + panic!("a layer") + }; + assert!(printable && preview && !template); +} + +#[test] +fn bad_kinds_are_errors() { + for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } +} diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs new file mode 100644 index 000000000..43fcfea92 --- /dev/null +++ b/crates/doc/src/node/wire.rs @@ -0,0 +1,181 @@ +use super::attributes::ObjectAttributes; +use super::extras::{Extras, NO_EXTRAS, NodeExtras}; +use super::ids::NodeId; +use super::kind::NodeKind; +use super::opacity::{Knockout, OpacityMask}; +use super::*; + +/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from +/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. +impl Serialize for Node { + fn serialize(&self, s: S) -> Result { + use crate::skip::{is_default, is_one, is_true}; + use serde::ser::SerializeStruct; + let e = self.extras(); + // As the derive did: the fields written, then each field written or skipped in order. + let skips = [ + false, + self.name.is_none(), + is_true(&self.visible), + is_default(&self.locked), + is_one(&self.opacity), + is_default(&self.blend), + is_default(&self.isolate), + is_default(&self.knockout), + is_default(&self.knockout_shape), + is_default(&self.appearance), + e.mask.is_none(), + e.trace.is_none(), + e.wrap.is_none(), + e.graph.is_none(), + e.series_index.is_none(), + is_default(&e.type_outlines), + e.shaper.is_none(), + false, + e.graphic_style.is_none(), + e.attrs.is_none(), + e.slice.is_none(), + is_default(&self.bbox_angle), + e.perspective.is_none(), + e.dimension.is_none(), + e.intertwine.is_none(), + e.live_paint_gaps.is_none(), + e.objects_on_path.is_none(), + ]; + let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; + let mut skip = skips.into_iter(); + macro_rules! field { + ($name:literal, $v:expr) => { + if skip.next().unwrap_or(false) { + st.skip_field($name)?; + } else { + st.serialize_field($name, $v)?; + } + }; + } + field!("id", &self.id); + field!("name", &self.name); + field!("visible", &self.visible); + field!("locked", &self.locked); + field!("opacity", &self.opacity); + field!("blend", &self.blend); + field!("isolate", &self.isolate); + field!("knockout", &self.knockout); + field!("knockout_shape", &self.knockout_shape); + field!("appearance", &self.appearance); + field!("mask", &e.mask); + field!("trace", &e.trace); + field!("wrap", &e.wrap); + field!("graph", &e.graph); + field!("series_index", &e.series_index); + field!("type_outlines", &e.type_outlines); + field!("shaper", &e.shaper); + field!("kind", &self.kind); + field!("graphic_style", &e.graphic_style); + field!("attrs", &e.attrs); + field!("slice", &e.slice); + field!("bbox_angle", &self.bbox_angle); + field!("perspective", &e.perspective); + field!("dimension", &e.dimension); + field!("intertwine", &e.intertwine); + field!("live_paint_gaps", &e.live_paint_gaps); + field!("objects_on_path", &e.objects_on_path); + st.end() + } +} + +/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node +/// (and its extras, allocated only when one is set) without a copy of what they own. +#[derive(Deserialize)] +#[serde(rename = "Node")] +pub(super) struct NodeWire { + id: NodeId, + #[serde(default)] + name: Option, + #[serde(default = "yes")] + visible: bool, + #[serde(default)] + locked: bool, + #[serde(default = "one")] + opacity: f32, + #[serde(default)] + blend: BlendMode, + #[serde(default)] + isolate: bool, + #[serde(default)] + knockout: Knockout, + #[serde(default)] + knockout_shape: bool, + #[serde(default)] + appearance: crate::Shared, + #[serde(default)] + mask: Option>, + #[serde(default)] + trace: Option>, + #[serde(default)] + wrap: Option, + #[serde(default)] + graph: Option>, + #[serde(default)] + series_index: Option, + #[serde(default)] + type_outlines: bool, + #[serde(default)] + shaper: Option>, + kind: NodeKind, + #[serde(default)] + graphic_style: Option, + #[serde(default)] + attrs: Option>, + #[serde(default)] + slice: Option>, + #[serde(default)] + bbox_angle: f64, + #[serde(default)] + perspective: Option>, + #[serde(default)] + dimension: Option>, + #[serde(default)] + intertwine: Option>, + #[serde(default)] + live_paint_gaps: Option>, + #[serde(default)] + objects_on_path: Option>, +} + +impl From for Node { + fn from(w: NodeWire) -> Self { + let extras = NodeExtras { + mask: w.mask, + trace: w.trace, + wrap: w.wrap, + graph: w.graph, + series_index: w.series_index, + type_outlines: w.type_outlines, + shaper: w.shaper, + graphic_style: w.graphic_style, + attrs: w.attrs, + slice: w.slice, + perspective: w.perspective, + dimension: w.dimension, + intertwine: w.intertwine, + live_paint_gaps: w.live_paint_gaps, + objects_on_path: w.objects_on_path, + }; + Node { + id: w.id, + name: w.name, + visible: w.visible, + locked: w.locked, + opacity: w.opacity, + blend: w.blend, + isolate: w.isolate, + knockout: w.knockout, + knockout_shape: w.knockout_shape, + appearance: w.appearance, + kind: w.kind, + bbox_angle: w.bbox_angle, + extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), + } + } +} diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index a822728a3..056c8c888 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -142,8 +142,10 @@ impl Document { let ids: Vec = list.as_array().into_iter().flatten().filter_map(|v| v.as_u64()).map(NodeId).collect(); // The old list marked spot-linked blacks too (so the spot names don't matter). let op = OverprintBlack { spot: true, ..Default::default() }; - for id in ids { - if let Some(n) = self.node_mut(id) { + // One walk of the tree finds them all (a missing id costs a walk each, and a list can repeat it). + let paths = self.index_paths(ids); + for path in paths.values() { + if let Some(n) = self.node_mut_at(path) { op.apply(n, &[]); } } diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index da310eb65..a0b19b844 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -632,7 +632,12 @@ impl RepeatSpec { /// Where the instances go, counting toward the budget the Repeats in the art of the symbols /// of `doc` that instances in the source show (none without a document). pub fn transforms(&self, doc: Option<&Document>) -> Vec { - let mut v = repeat_transforms(&self.kind, self.source_bounds()); + self.transforms_from(doc, self.source_bounds()) + } + /// [`Self::transforms`] with the source's bounds (as [`Self::source_bounds`] measures them) + /// already at hand: measuring them again, at every level of nested Repeats, doubles the work. + fn transforms_from(&self, doc: Option<&Document>, bounds: Option) -> Vec { + let mut v = repeat_transforms(&self.kind, bounds); // Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500 // grid of one is 62.5 billion instances): keep the first that fit the budget. v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source))); @@ -681,7 +686,9 @@ impl RepeatSpec { /// The bounds of all instances of a source measuring `source`, each placed by its transform /// ([`Self::transforms`] for `doc`). pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option { - self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source)))) + // Without a document the source's bounds are the ones `source` is: not measured twice. + let transforms = if doc.is_none() { self.transforms_from(None, Some(source)) } else { self.transforms(doc) }; + transforms.into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source)))) } /// Apply a document transform (source + arrangement parameters). pub fn transform(&mut self, a: Affine, scaling: impl Into) { diff --git a/crates/doc/src/range.rs b/crates/doc/src/range.rs index eab847204..87db16f20 100644 --- a/crates/doc/src/range.rs +++ b/crates/doc/src/range.rs @@ -17,6 +17,7 @@ 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(); 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,7 +31,7 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { return Err(format!("range `{s}`: `{part}` runs backwards")); } for i in a - 1..b { - if !out.contains(&i) { + if seen.insert(i) { out.push(i); } } diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs new file mode 100644 index 000000000..e9af0e682 --- /dev/null +++ b/crates/doc/src/records.rs @@ -0,0 +1,123 @@ +use super::*; + +/// 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. +#[derive(Default)] +pub struct NewArtIds(pub(crate) std::sync::RwLock>>); + +impl NewArtIds { + /// The objects. + pub fn get(&self) -> Arc> { + self.0.read().map(|g| g.clone()).unwrap_or_default() + } + /// Make `ids` the objects. + pub fn set(&self, ids: Arc>) { + if let Ok(mut g) = self.0.write() { + *g = ids; + } + } +} + +impl Clone for NewArtIds { + fn clone(&self) -> Self { + NewArtIds(std::sync::RwLock::new(self.get())) + } +} + +/// Editing state, not document content: two documents are equal whatever new art they name. +impl PartialEq for NewArtIds { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for NewArtIds { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("NewArtIds").field(&self.get()).finish() + } +} + +/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct MaskEdit { + pub object: NodeId, + pub layer: NodeId, +} + +/// 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 { + pub name: String, + pub center: Point, + pub zoom: f64, + #[serde(default)] + pub rotation: f64, +} + +impl SavedView { + /// Most views a document keeps (the View menu lists every one). + pub const MAX: usize = 25; + /// Longest name, in characters. + pub const MAX_NAME: usize = 255; + + /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). + pub fn clean_name(name: &str) -> Option { + let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); + (!name.is_empty()).then_some(name) + } + + /// The first "View N" none of `saved` is named. + pub fn default_name(saved: &[SavedView]) -> String { + (1..=saved.len() + 1).map(|i| format!("View {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() + } + + /// Can the canvas show this view: a finite centre and a finite, positive zoom. + pub fn is_usable(&self) -> bool { + self.center.x.is_finite() && self.center.y.is_finite() && self.zoom.is_finite() && self.zoom > 0.0 + } +} + +/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at +/// the bottom of the Select menu. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedSelection { + pub name: String, + pub objects: Vec, +} + +impl SavedSelection { + /// Most saved selections a document keeps (the Select menu lists every one). + pub const MAX: usize = 25; + /// Longest name, in characters. + pub const MAX_NAME: usize = 255; + + /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). + pub fn clean_name(name: &str) -> Option { + let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); + (!name.is_empty()).then_some(name) + } + + /// The first "Selection N" none of `saved` is named. + pub fn default_name(saved: &[SavedSelection]) -> String { + (1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() + } +} + +/// A symbol definition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Symbol { + pub name: String, + pub art: Arc, +} + +/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageBlob { + pub mime: String, + #[serde(skip)] + pub bytes: Arc>, + /// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only + /// linked images show this blob, and what `bytes` hold while the linked file can't be read. + #[serde(skip)] + pub proxy: Option>>, +} diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index d3860fc58..b5a1ffe32 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -212,7 +212,8 @@ pub struct DocSetup { pub bleed: [f64; 4], /// Show Images in Outline Mode. pub outline_images: bool, - /// Highlight text whose font is missing (drawn in a substitute font). + /// The Highlight Substituted Fonts value older builds kept in Document Setup. Files keep it as + /// they were saved; the screen follows Preferences › Type › Highlight Substituted Fonts. pub highlight_substituted_fonts: bool, /// Highlight glyphs the font lacks (drawn from a fallback font). pub highlight_substituted_glyphs: bool, diff --git a/crates/doc/src/skip.rs b/crates/doc/src/skip.rs new file mode 100644 index 000000000..15f294c00 --- /dev/null +++ b/crates/doc/src/skip.rs @@ -0,0 +1,15 @@ +pub fn is_default(v: &T) -> bool { + *v == T::default() +} +pub fn is_true(v: &bool) -> bool { + *v +} +pub fn is_one(v: &f32) -> bool { + *v == 1.0 +} +pub fn is_empty_vec(v: &[T]) -> bool { + v.is_empty() +} +pub fn is_empty_map(v: &serde_json::Map) -> bool { + v.is_empty() +} diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index 8deba728f..33299a309 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -274,6 +274,10 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec { return vec![]; } let inside: Vec = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect(); + // Every band scans every cut: past a bound the layout is refused, not truncated. + if inside.len() > MAX_AUTO_CUTS { + return vec![]; + } let mut ys: Vec = inside.iter().flat_map(|r| [r.y0, r.y1]).chain([region.y0, region.y1]).collect(); ys.sort_by(f64::total_cmp); ys.dedup_by(|a, b| (*a - *b).abs() <= EPS); @@ -298,6 +302,9 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec { // Spans continuing an open slice extend it; the others close. let mut next = Vec::with_capacity(free.len()); for (a, b) in free { + if done.len() + open.len() + next.len() >= MAX_AUTO_SLICES { + break; + } match open.iter().position(|r: &Rect| (r.x0 - a).abs() <= EPS && (r.x1 - b).abs() <= EPS) { Some(i) => { let mut r = open.swap_remove(i); @@ -309,7 +316,18 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec { } done.append(&mut open); open = next; + // Crossing strips make a cell of every crossing: the layout stops, short, past a bound. + if done.len() + open.len() >= MAX_AUTO_SLICES { + break; + } } done.append(&mut open); + done.truncate(MAX_AUTO_SLICES); done } + +/// The most slices automatic layout makes. +const MAX_AUTO_SLICES: usize = 10_000; + +/// The most cut rectangles automatic layout works through. +const MAX_AUTO_CUTS: usize = 2_000; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs new file mode 100644 index 000000000..a13fb2168 --- /dev/null +++ b/crates/doc/src/tests.rs @@ -0,0 +1,418 @@ +use super::*; +use vectorcraft_geom::shapes; + +mod lookups; +mod slices; +mod units; + +/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch +/// are binary fractions now, and the minor ticks between them stay binary too; every other +/// unit is still read in tenths. +#[test] +fn an_inch_ruler_steps_in_binary_fractions() { + let step = |u: Unit, zoom: f64| u.ruler_step(zoom); + // Every inch step at or below an inch is 1/2^k, never a tenth or a fifth. + for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] { + let s = step(Unit::Inches, zoom); + if s <= 1.0 { + let k = s.recip(); + assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k"); + assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k"); + } + // A label step always leaves at least 50 px, which is what the ladder is picked for. + assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}"); + } + // The steps the report named: tenths became quarters, eighths and sixteenths. + assert_eq!(step(Unit::Inches, 5.0), 0.25); + assert_eq!(step(Unit::Inches, 10.0), 0.125); + assert_eq!(step(Unit::Inches, 20.0), 0.0625); + // An inch and wider is unchanged. + assert_eq!(step(Unit::Inches, 1.0), 1.0); + // Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in + // further only adds ticks. A label therefore never needs more than the six characters of + // "0.0625", which the left ruler has room for when it spells one out a character per line. + for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] { + assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}"); + } + // The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and + // 128ths the report expects to appear unlabelled when zoomed in close. + assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0); + // The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere. + assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125); + assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5); + assert_eq!(Unit::Points.ruler_minor(100.0), 10.0); + // Other units keep the decimal ladder, so a tenth and a fifth still appear. + assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); +} + +fn doc_with_rects() -> (Document, NodeId, NodeId) { + let mut d = Document::new(612.0, 792.0); + let layer = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap(); + (d, a, b) +} + +#[test] +fn new_document_has_layer_and_artboard() { + let d = Document::new(612.0, 792.0); + assert_eq!(d.layers.len(), 1); + assert_eq!(d.layers[0].display_name(), "Layer 1"); + assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0)); +} + +#[test] +fn art_on_artboard_takes_unlocked_objects_wholly_inside() { + let (mut d, a, b) = doc_with_rects(); + let wide = Rect::new(-1.0, -1.0, 40.0, 15.0); + assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]); + assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside"); + // Flush with the artboard's edges counts as inside: the right and bottom ones too (#890). + for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] { + assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}"); + } + d.node_mut(a).unwrap().locked = true; + assert_eq!(d.art_on_artboard(wide, false), vec![b]); + // Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on. + d.node_mut(b).unwrap().visible = false; + assert!(d.art_on_artboard(wide, false).is_empty()); + assert_eq!(d.art_on_artboard(wide, true), vec![a, b]); + Arc::make_mut(&mut d.layers[0]).locked = true; + assert!(d.art_on_artboard(wide, false).is_empty()); + assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too"); +} + +#[test] +fn art_on_artboard_tolerates_roundoff_at_each_edge() { + let (d, a, _) = doc_with_rects(); + let roundoff = 1e-12; + for board in [ + Rect::new(roundoff, 0.0, 10.0, 10.0), + Rect::new(0.0, roundoff, 10.0, 10.0), + Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0), + Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff), + ] { + assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}"); + } + // A real overhang remains outside. + for board in + [Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)] + { + assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}"); + } +} + +/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one +/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with +/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one. +/// Unlocking the artboard gives the art back. +#[test] +fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + d.artboards.push(Artboard { + id: 2, + name: "Artboard 2".into(), + rect: Rect::new(700.0, 0.0, 900.0, 200.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + let rect = |d: &mut Document, r: Rect, appearance: Appearance| { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap(); + id + }; + let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art()); + // A group with one member on the artboard and one right of it. + let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let members = [(m1, 750.0), (m2, 950.0)] + .map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art()))); + d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap(); + // Unstroked and flush with the artboard's left edge, from outside. + let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default()); + let opt = HitOptions::default(); + let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None)); + let all = Rect::new(-10.0, -10.0, 1000.0, 800.0); + assert_eq!(top(&d, 720.0, 20.0), Some(c)); + d.artboards[1].locked = true; + for id in [c, g, m1, m2] { + assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard"); + } + for id in [a, b, e, layer] { + assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't"); + } + assert_eq!(d.selectable_art(), [a, b, e]); + assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c"); + assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard"); + let isolated = HitOptions { scope: Some(g), ..opt }; + assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock"); + assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty()); + assert_eq!(marquee(&d, all, None, false), [a, b, e]); + assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too"); + // Deselected in one pass, with the key object and the anchors. + let mut sel = Selection::default(); + sel.set([a, c]); + sel.key = Some(c); + sel.anchors.insert(c, [(0, 0)].into()); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true)); + // A targeted layer stays targeted, with its art off the locked artboard selected. + sel.set_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer))); + // Shift-clicking the layer's target circle removes its art off the locked artboard, and + // a second Shift-click adds it again. + sel.toggle_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.target), (vec![], None)); + sel.toggle_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!(sel.objects, [a, b, e]); + // A sublayer with art on both artboards: its art off the locked artboard can still be clicked. + let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] { + d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())).unwrap(); + } + assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None)); + assert!(!d.on_locked_artboard(sub)); + let mut both = Selection::default(); + both.set([s1, s2]); + both.deselect_on_locked_artboards(&d); + assert_eq!(both.objects, [s1]); + // New art on it is editable and can be clicked while it is new art. + d.new_art.set(Arc::new([c].into())); + assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art"); + assert_eq!(top(&d, 720.0, 20.0), Some(c)); + d.new_art.set(Default::default()); + d.artboards[1].locked = false; + assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]); + assert_eq!(top(&d, 955.0, 55.0), Some(g)); + assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]); +} + +#[test] +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) }); + 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. + assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]); +} + +#[test] +fn artboard_at_counts_the_right_and_bottom_edges() { + // Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it. + let mut d = Document::new(100.0, 100.0); + for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] { + let mut a = d.artboards[0].clone(); + a.rect = rect; + d.artboards.push(a); + } + let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false)); + // Each artboard's own right and bottom edges, edges with roundoff, and points beyond them. + assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge"); + assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge"); + assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner"); + assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge"); + assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge"); + assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge"); + assert_eq!(held(&d, 200.001, 50.0), (None, None)); + assert_eq!(held(&d, 50.0, 200.001), (None, None)); + // On an edge two artboards share, the artboard to the right of it or below it holds the + // point, in either list order, except at the end of the edge that lies on that artboard's + // bottom or right edge. + assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1))); + assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2))); + // The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top + // right corner: the first holds the point, or the last when `topmost`. + assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner"); + d.artboards.swap(0, 1); + assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0))); + // Where two artboards overlap, the topmost holds the point, or the first one when not + // `topmost`. + let mut inner = d.artboards[1].clone(); + inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0); + d.artboards.push(inner); + assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1))); + assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3"); +} + +#[test] +fn find_and_paths() { + let (d, a, b) = doc_with_rects(); + assert_eq!(d.index_path(b), Some(vec![0, 1])); + assert_eq!(d.parent_of(a), Some(d.layers[0].id)); + assert_eq!(d.layer_of(b), Some(d.layers[0].id)); + assert!(d.node(NodeId(999)).is_none()); + assert_eq!(d.node_count(), 3); +} + +#[test] +fn copy_on_write_shares_untouched() { + let (d, a, b) = doc_with_rects(); + let mut e = d.clone(); + e.node_mut(a).unwrap().name = Some("A".into()); + // b's Arc is shared between both versions; a's is not. + let lb = &d.layers[0].children().unwrap()[1]; + let eb = &e.layers[0].children().unwrap()[1]; + assert!(Arc::ptr_eq(lb, eb)); + assert_eq!(d.node(a).unwrap().name, None); + assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A")); + let _ = b; +} + +#[test] +fn move_and_remove() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(d.index_path(a), Some(vec![0, 1])); + assert_eq!(d.index_path(b), Some(vec![0, 0])); + let r = d.remove(a).unwrap(); + assert_eq!(r.id, a); + assert!(d.node(a).is_none()); + assert!(d.move_node(layer, Some(layer), 0).is_err()); +} + +#[test] +fn layers_and_names() { + let mut d = Document::new(100.0, 100.0); + let l2 = d.add_layer(None); + assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2"); + assert_eq!(d.default_layer(), Some(l2)); + assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1); +} + +/// #1718: a file whose ids or counters are at their type's largest value still hands out ids +/// nothing in it uses (and never 0), instead of overflowing. +#[test] +fn exhausted_ids_in_a_file_dont_run_out() { + let (d, a, b) = doc_with_rects(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(u64::MAX); + // Object `a` gets the largest id there is. + fn renumber(v: &mut serde_json::Value, from: u64) { + match v { + serde_json::Value::Object(o) => { + if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) { + o.insert("id".into(), serde_json::json!(u64::MAX)); + } + o.values_mut().for_each(|c| renumber(c, from)); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)), + _ => {} + } + } + renumber(&mut v["layers"], a.0); + let mut d: Document = serde_json::from_value(v).unwrap(); + assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept"); + d.fix_next_id(); + let fresh: Vec = (0..3).map(|_| d.alloc_id()).collect(); + assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}"); + assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]); + // A counter left at the end by other means stays there rather than wrapping to 0. + d.next_id = u64::MAX; + assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX)); + d.reserve_ids(u64::MAX); + // Artboards and graphic styles: one past the largest, else the smallest id free. + assert_eq!(free_id([1, 2, 7].into_iter()), 8); + assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3); + assert_eq!(free_id([u32::MAX].into_iter()), 1); + d.artboards[0].id = u32::MAX; + assert_eq!(d.next_artboard_id(), 1); +} + +#[test] +fn serde_roundtrip() { + let (d, _, _) = doc_with_rects(); + let s = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&s).unwrap(); + assert_eq!(back.node_count(), d.node_count()); + assert_eq!(back.peek_next_id(), d.peek_next_id()); +} + +#[test] +fn reid_gives_fresh_ids() { + let (mut d, a, b) = doc_with_rects(); + let g = d.alloc_id(); + let na = (*d.remove(a).unwrap()).clone(); + let nb = (*d.remove(b).unwrap()).clone(); + let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]); + let copy = d.reid(&group); + assert_ne!(copy.id, g); + let ids: Vec = copy.children().unwrap().iter().map(|c| c.id).collect(); + assert!(!ids.contains(&a) && !ids.contains(&b)); +} + +#[test] +fn setup_round_trips_and_old_files_load() { + let (mut d, _, _) = doc_with_rects(); + // A document without a setup writes no `setup` key (old readers and files stay unchanged). + assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\"")); + d.setup.bleed = [9.0, 9.0, 0.0, 18.0]; + d.setup.grid_size = GridSize::Large; + d.setup.typographers_quotes = false; + d.setup.quotes = setup::language_quotes("German").unwrap(); + d.setup.flattener_preset = Some("High Resolution".into()); + d.setup.background = Background::White; + d.setup.export_text = ExportText::Appearance; + let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back.setup, d.setup); + // Files written before Document Setup existed load with the defaults. + let mut v = serde_json::to_value(&d).unwrap(); + v.as_object_mut().unwrap().remove("setup"); + let old: Document = serde_json::from_value(v).unwrap(); + assert_eq!(old.setup, DocSetup::default()); +} + +#[test] +fn char_position_round_trips() { + let st = + CharStyle { position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), small_caps: Some(75.0), ..CharStyle::default() }; + let v = serde_json::to_value(&st).unwrap(); + assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0})); + assert_eq!(serde_json::from_value::(v).unwrap(), st); + let plain = serde_json::to_value(CharStyle::default()).unwrap(); + assert!(plain.get("position").is_none() && plain.get("small_caps").is_none()); + let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0); + assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); +} + +#[test] +fn units() { + assert_eq!(Unit::Inches.parse("1"), Some(72.0)); + assert_eq!(Unit::Points.parse("1in"), Some(72.0)); + assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0)); + assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); + assert_eq!(Unit::Points.parse("10+5"), Some(15.0)); + assert_eq!(Unit::Points.parse("100/4"), Some(25.0)); + assert_eq!(Unit::Points.parse("-5"), Some(-5.0)); + assert_eq!(Unit::Points.parse("abc"), None); + assert_eq!(Unit::Points.format(12.5), "12.5 pt"); + assert_eq!(Unit::Inches.format(144.0), "2 in"); +} + +#[test] +fn negative_picas_and_points_sign_applies_to_whole_value() { + assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0)); + assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0)); + assert_eq!(Unit::Points.parse("-2p"), Some(-24.0)); + assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); +} + +#[test] +fn pica_form_rejects_exponent_notation() { + for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] { + assert_eq!(Unit::Points.parse(s), None, "{s}"); + } +} diff --git a/crates/doc/src/tests_lookups.rs b/crates/doc/src/tests/lookups.rs similarity index 99% rename from crates/doc/src/tests_lookups.rs rename to crates/doc/src/tests/lookups.rs index 0fda214ee..2f83b65c5 100644 --- a/crates/doc/src/tests_lookups.rs +++ b/crates/doc/src/tests/lookups.rs @@ -2,7 +2,7 @@ //! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes //! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and //! `move_node` leave one node at a time. -use super::*; +use crate::*; use vectorcraft_geom::shapes; /// Layers with paths and groups nested `depth` deep; every 7th id is reused (a damaged file), so diff --git a/crates/doc/src/tests_slices.rs b/crates/doc/src/tests/slices.rs similarity index 100% rename from crates/doc/src/tests_slices.rs rename to crates/doc/src/tests/slices.rs diff --git a/crates/doc/src/tests_units.rs b/crates/doc/src/tests/units.rs similarity index 98% rename from crates/doc/src/tests_units.rs rename to crates/doc/src/tests/units.rs index 5b3319f18..909a0adf3 100644 --- a/crates/doc/src/tests_units.rs +++ b/crates/doc/src/tests/units.rs @@ -3,7 +3,7 @@ use proptest::prelude::*; -use super::Unit; +use crate::Unit; #[test] fn every_unit_round_trips_through_its_field_text() { @@ -133,7 +133,7 @@ fn a_negative_operand_after_an_operator_is_a_sign() { assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0)); assert_eq!(Unit::Points.parse("5+-3"), Some(2.0)); assert_eq!(Unit::Points.parse("10-2"), Some(8.0)); - assert_eq!(super::parse_number("90*-1"), Some(-90.0)); + assert_eq!(crate::parse_number("90*-1"), Some(-90.0)); } #[test] diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 9ffac32f9..a3ab2ccc5 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -6,1610 +6,22 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Justify { - /// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for - /// left-to-right paragraphs, right for right-to-left ones. New type's alignment. - Auto, - #[default] - Left, - Center, - Right, - JustifyLeft, - JustifyCenter, - JustifyRight, - JustifyAll, -} - -/// Character attributes (the Character panel). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct CharStyle { - pub font_family: String, - #[serde(default = "regular")] - pub font_style: String, - /// Which installed version of the font (its version string) when several versions of the - /// family and style are installed and the text was set in one the family and style alone - /// wouldn't pick (an imported file's type matched to it); None = the one they pick. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_version: Option, - /// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag - /// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of - /// each axis they name; the other axes keep the style's settings. Empty: the style as it is. - #[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub font_axes: std::collections::BTreeMap, - /// Size in points. - pub size: f64, - /// Leading in points; None = Auto (120% of size). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub leading: Option, - /// Tracking in 1/1000 em. - #[serde(default)] - pub tracking: f64, - /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kerning: Option, - #[serde(default)] - pub baseline_shift: f64, - #[serde(default = "hundred")] - pub h_scale: f64, - #[serde(default = "hundred")] - pub v_scale: f64, - #[serde(default)] - pub rotation: f64, - pub fill: Paint, - #[serde(default)] - pub stroke: Paint, - #[serde(default)] - pub stroke_width: f64, - #[serde(default)] - pub underline: bool, - #[serde(default)] - pub strikethrough: bool, - #[serde(default)] - pub all_caps: bool, - /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub features: Vec, - /// Character style (Character Styles panel) these attributes come from; None = Normal. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub style_name: Option, - /// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint_fill: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint_stroke: bool, - /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type - /// selected); defaults as for object strokes. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub stroke_cap: LineCap, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub stroke_join: LineJoin, - #[serde(default = "ten", skip_serializing_if = "is_ten")] - pub stroke_miter_limit: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stroke_dash: Option, - /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the - /// glyph outlines, inside or outside them, as for object strokes; the type stays live. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub stroke_align: StrokeAlign, - /// Superscript or subscript (Character panel). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub position: CharPosition, - /// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the - /// size (Document Setup → Type → Small Caps); None = off. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub small_caps: Option, - /// Character Alignment (Character panel menu): where characters smaller than the largest on - /// their line line up with it. - #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] - pub char_align: CharAlign, - /// Character Direction (Character panel menu, Middle Eastern options): these characters are - /// set left to right or right to left inside their paragraph, each stretch of them a Unicode - /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives - /// them. - #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] - pub char_direction: Option, - /// 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`. - #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] - pub proportional_metrics: bool, - /// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters - /// stand side by side across one em of the column. Horizontal type keeps the mark and does not - /// move them. Two or three half-width digits do this without the mark. - /// - #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] - pub tate_chu_yoko: 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, - /// 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, -} - -/// 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). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Digits { - /// Each digit as typed. - #[default] - Default, - /// Western digits 0–9 (the digits Arabic typography calls Arabic). - Arabic, - /// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic. - Hindi, - /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. - Farsi, -} - -impl Digits { - /// 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 { - let value = match c { - '0'..='9' => c as u32 - '0' as u32, - '\u{660}'..='\u{669}' => c as u32 - 0x660, - '\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0, - _ => return c, - }; - let base = match self { - Digits::Default => return c, - Digits::Arabic => '0' as u32, - Digits::Hindi => 0x660, - Digits::Farsi => 0x6F0, - }; - char::from_u32(base + value).unwrap_or(c) - } -} - -/// Where a character smaller than the largest on its line lines up with it: on the Roman -/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic -/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the -/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CharAlign { - #[default] - RomanBaseline, - EmBoxTop, - EmBoxCenter, - EmBoxBottom, - IcfTop, - IcfBottom, -} - -/// Superscript or subscript proportions in percent of the font size (Document Setup → Type). -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct ScriptMetrics { - /// Glyph size. - pub size: f64, - /// Baseline offset: up for superscript, down for subscript. - pub position: f64, -} - -impl ScriptMetrics { - pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; - /// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to - /// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside - /// its range becomes the nearer end. - pub fn in_range(self) -> Self { - Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) } - } -} - -/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for -/// `smallCapsSize`. A size outside it becomes the nearer end. -fn small_caps_in_range(size: f64) -> f64 { - size.clamp(1.0, 200.0) -} - -impl Default for ScriptMetrics { - fn default() -> Self { - Self::DEFAULT - } -} - -/// Character position (Character panel Superscript / Subscript). The proportions are the -/// document's when the position is applied, and follow later Document Setup changes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum CharPosition { - #[default] - Normal, - Superscript(ScriptMetrics), - Subscript(ScriptMetrics), -} - -impl CharPosition { - /// Glyph scale and baseline shift (points, positive = up) for text of `size` points. - pub fn scale_shift(self, size: f64) -> (f64, f64) { - match self { - CharPosition::Normal => (1.0, 0.0), - CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size), - CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size), - } - } - /// `normal`, `superscript` or `subscript`. - pub fn id(self) -> &'static str { - match self { - CharPosition::Normal => "normal", - CharPosition::Superscript(_) => "superscript", - CharPosition::Subscript(_) => "subscript", - } - } - /// Returns this position with its proportions kept by [`ScriptMetrics::in_range`]. - pub fn in_range(self) -> Self { - match self { - CharPosition::Normal => CharPosition::Normal, - CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()), - CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()), - } - } -} - -fn ten() -> f64 { - 10.0 -} -fn is_ten(v: &f64) -> bool { - *v == 10.0 -} -fn regular() -> String { - "Regular".into() -} -fn hundred() -> f64 { - 100.0 -} -fn yes() -> bool { - true -} - -impl Default for CharStyle { - fn default() -> Self { - Self { - font_family: "Source Sans 3".into(), - font_style: "Regular".into(), - font_version: None, - font_axes: std::collections::BTreeMap::new(), - size: 12.0, - leading: None, - tracking: 0.0, - kerning: None, - baseline_shift: 0.0, - h_scale: 100.0, - v_scale: 100.0, - rotation: 0.0, - fill: Paint::solid(Color::BLACK), - stroke: Paint::None, - stroke_width: 0.0, - underline: false, - strikethrough: false, - all_caps: false, - features: vec![], - style_name: None, - overprint_fill: false, - overprint_stroke: false, - stroke_cap: LineCap::Butt, - stroke_join: LineJoin::Miter, - stroke_miter_limit: 10.0, - stroke_dash: None, - stroke_align: StrokeAlign::Center, - position: CharPosition::Normal, - char_align: CharAlign::RomanBaseline, - char_direction: None, - proportional_metrics: false, - tate_chu_yoko: false, - small_caps: None, - digits: Digits::Default, - no_break: false, - } - } -} - -impl CharStyle { - pub fn effective_leading(&self) -> f64 { - self.leading.unwrap_or(self.size * 1.2) - } - /// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size - /// 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. - 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 }; - self.size = within(self.size, default.size); - 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) }; - } - /// Do the characters draw a stroke (a paint and a positive weight)? - pub fn has_stroke(&self) -> bool { - !self.stroke.is_none() && self.stroke_width > 0.0 - } - /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, - /// alignment and overprint), so type strokes share the object strokes' geometry, rendering - /// and export. - pub fn stroke_layer(&self) -> StrokeLayer { - StrokeLayer { - overprint: self.overprint_stroke, - cap: self.stroke_cap, - join: self.stroke_join, - miter_limit: self.stroke_miter_limit, - dash: self.stroke_dash.clone(), - align: self.stroke_align, - ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) - } - } - /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the - /// character stroke (the options characters have no use for, such as arrowheads and width - /// profiles, are dropped). - pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { - self.stroke = st.paint.clone(); - self.stroke_width = st.width; - self.stroke_cap = st.cap; - self.stroke_join = st.join; - self.stroke_miter_limit = st.miter_limit; - self.stroke_dash = st.dash.clone(); - self.stroke_align = st.align; - } - /// How far the character stroke paints past the glyph outlines (text space): half the weight - /// centred, the whole weight outside, nothing inside (miter spikes aside). - pub fn stroke_outset(&self) -> f64 { - if !self.has_stroke() { - return 0.0; - } - match self.stroke_align { - StrokeAlign::Center => self.stroke_width / 2.0, - StrokeAlign::Outside => self.stroke_width, - StrokeAlign::Inside => 0.0, - } - } - /// The character fill as a fill layer, overprinting as the characters do. - fn fill_layer(&self) -> FillLayer { - FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } - } - /// The characters' paint as an object appearance (their outlines'): the fill, plus the - /// stroke when it draws one, overprinting as the characters do. - pub fn appearance(&self) -> Appearance { - let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; - if self.has_stroke() { - a.items.push(AppearanceItem::Stroke(self.stroke_layer())); - } - a - } - /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has - /// no paint): the appearance the Eyedropper and graphic styles take from type. - pub fn basic_appearance(&self) -> Appearance { - Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } - } - /// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and - /// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`. - pub fn keep_scripts_in_range(&mut self) { - self.position = self.position.in_range(); - self.small_caps = self.small_caps.map(small_caps_in_range); - } -} - -impl crate::Document { - /// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup` - /// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph - /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts - /// back (opacity-mask art included), in graphs' label styles and in the character styles. It - /// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`]. - pub fn keep_scripts_in_range(&mut self) { - let mut fix = |n: &mut crate::Node| { - if let crate::NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - r.style.keep_scripts_in_range(); - } - } - if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { - let l = &mut g.label_styles; - for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { - st.keep_scripts_in_range(); - } - } - }; - 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 { - crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); - } - // Character styles store the attributes they set by their serialized names. - for def in self.char_styles.iter_mut() { - if let Some(v) = def.attrs.get_mut("position") - && let Ok(p) = serde_json::from_value::(v.clone()) - && p.in_range() != p - { - *v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null); - } - if let Some(v) = def.attrs.get_mut("small_caps") - && let Some(size) = v.as_f64() - && small_caps_in_range(size) != size - { - *v = small_caps_in_range(size).into(); - } - } - } -} - -/// Tab stop alignment (Tabs panel). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TabAlign { - #[default] - Left, - Center, - Right, - /// Aligns on the first `align_on` character (a decimal point by default). - Decimal, -} - -/// A tab stop, measured from the left edge of the text (area type: the frame's left edge). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TabStop { - pub position: f64, - #[serde(default)] - pub align: TabAlign, - /// Leader characters repeated across the tab's gap (e.g. ". "). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub leader: String, - /// Decimal tabs align on this character. - #[serde(default = "default_align_on")] - pub align_on: char, -} - -fn default_align_on() -> char { - '.' -} - -/// Distance between default tab stops when no explicit stop applies (½ inch). -pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; - -/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Bullet { - /// A filled dot (•). - #[default] - Dot, - /// A dash (–). - Dash, - /// A hollow circle (◦). - Circle, - /// A filled square (▪). - Square, -} - -impl Bullet { - /// The character the layout draws for the bullet. - pub fn character(self) -> char { - match self { - Bullet::Dot => '\u{2022}', - Bullet::Dash => '\u{2013}', - Bullet::Circle => '\u{25E6}', - Bullet::Square => '\u{25AA}', - } - } -} - -/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered -/// List presets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NumberStyle { - /// 1, 2, 3… - #[default] - Arabic, - /// A, B, C… Z, AA, BB… - UpperLetters, - /// a, b, c… z, aa, bb… - LowerLetters, - /// I, II, III, IV… - UpperRoman, - /// i, ii, iii, iv… - LowerRoman, - /// 01, 02… 99, 100… - TwoDigits, - /// 001, 002… 999, 1000… - ThreeDigits, -} - -/// Past this number, roman numerals and letters are written in Arabic digits. -const MAX_LETTERS_OR_ROMAN: u32 = 3999; - -impl NumberStyle { - /// Every style, in the order the Numbered List presets list them. - pub const ALL: [NumberStyle; 7] = [ - NumberStyle::Arabic, - NumberStyle::UpperLetters, - NumberStyle::LowerLetters, - NumberStyle::UpperRoman, - NumberStyle::LowerRoman, - NumberStyle::TwoDigits, - NumberStyle::ThreeDigits, - ]; - - /// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then - /// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD, - /// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and - /// keep every digit of a longer number. - pub fn format(self, n: u32) -> String { - let letters_or_roman = - matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman); - if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) { - return n.to_string(); - } - match self { - NumberStyle::Arabic => n.to_string(), - NumberStyle::TwoDigits => format!("{n:02}"), - NumberStyle::ThreeDigits => format!("{n:03}"), - NumberStyle::UpperLetters => letters(n), - NumberStyle::LowerLetters => letters(n).to_lowercase(), - NumberStyle::UpperRoman => roman(n), - NumberStyle::LowerRoman => roman(n).to_lowercase(), - } - } -} - -/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on. -fn letters(n: u32) -> String { - let n = n - 1; - let letter = char::from(b'A' + (n % 26) as u8); - std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect() -} - -/// `n` (1 to 3999) in capital roman numerals. -fn roman(mut n: u32) -> String { - const DIGITS: [(u32, &str); 13] = [ - (1000, "M"), - (900, "CM"), - (500, "D"), - (400, "CD"), - (100, "C"), - (90, "XC"), - (50, "L"), - (40, "XL"), - (10, "X"), - (9, "IX"), - (5, "V"), - (4, "IV"), - (1, "I"), - ]; - let mut s = String::new(); - for (value, digits) in DIGITS { - while n >= value { - s.push_str(digits); - n -= value; - } - } - s -} - -/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the -/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's -/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual -/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after -/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a -/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves -/// out as their defaults, and a build ignores the fields it doesn't know. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(default)] -pub struct ParaList { - /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for - /// when the list is made bulleted again. - pub bullet: Bullet, - /// A numbered list's number style. A bulleted list has none, and the file saves it only for a - /// numbered list. - #[serde(skip_serializing_if = "Option::is_none")] - pub number: Option, -} - -impl ParaList { - /// A bulleted list with `bullet`. - pub fn bulleted(bullet: Bullet) -> Self { - Self { bullet, number: None } - } - - /// A numbered list with `style`. - pub fn numbered(style: NumberStyle) -> Self { - Self { bullet: Bullet::default(), number: Some(style) } - } - - /// The characters of the marker the layout draws before the paragraph: the bullet, or for a - /// numbered list number `n` in its style followed by a period. - pub fn marker(&self, n: u32) -> String { - match self.number { - Some(style) => format!("{}.", style.format(n)), - None => self.bullet.character().to_string(), - } - } - - /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and - /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. - /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has - /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the - /// count as it is; a numbered paragraph adds one to the count and is numbered with it. - pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { - let Some(list) = list else { - *count = 0; - return None; - }; - if empty { - return None; - } - if list.number.is_some() { - *count = count.saturating_add(1); - } - Some(list.marker(*count)) - } - - /// The style the marker is set in: `first` (the style of the paragraph's first character) - /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// tate-chu-yoko, underline and strikethrough. - pub fn marker_style(first: &CharStyle) -> CharStyle { - CharStyle { - baseline_shift: 0.0, - rotation: 0.0, - position: CharPosition::Normal, - tracking: 0.0, - kerning: None, - tate_chu_yoko: false, - underline: false, - strikethrough: false, - ..first.clone() - } - } -} - -/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the -/// marker hangs in the first line's 18 pt before the text. -pub const LIST_HANGING_INDENT: f64 = 18.0; - -/// Paragraph composer (Paragraph panel menu): how lines are broken. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Composer { - /// Break each line as soon as it is full. - SingleLine, - /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word - /// spacing, ragged lines an even rag. - #[default] - EveryLine, -} - -impl Composer { - fn is_default(&self) -> bool { - *self == Self::default() - } -} - -/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic -/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from -/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen -/// limit, no hyphenation zone, and capitalized words hyphenated. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct HyphenationOptions { - /// Words Longer Than: only words with more letters than this are hyphenated. - pub words_longer_than: u32, - /// After First: the fewest letters a word keeps before the hyphen. - pub after_first: u32, - /// Before Last: the fewest letters a word keeps after the hyphen. - pub before_last: u32, - /// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen; - /// 0 sets no limit. - pub hyphen_limit: u32, - /// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next - /// word already ends within this distance of its right edge breaks before that word instead - /// of hyphenating it. 0 allows every hyphenation. - pub zone: f64, - /// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not - /// hyphenated. Words in capitals only are never hyphenated. - pub capitalized: bool, -} - -impl Default for HyphenationOptions { - fn default() -> Self { - Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true } - } -} - -/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SingleWordJustify { - /// Spread across the line, between its letters. - #[default] - FullJustify, - AlignLeft, - AlignCenter, - AlignRight, -} - -/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent. -/// -/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired -/// applies to every line of the paragraph, justified or not: word spaces are that share of the -/// font's word space, each letter is followed by that share of a word space, and glyphs are that -/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only -/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph -/// scaling 100/100/100, Auto Leading 120 and Full Justify. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct JustificationOptions { - /// Word Spacing, percent of the font's word space (0..1000). - pub word_spacing: [f64; 3], - /// Letter Spacing, percent of the font's word space added after each letter (-100..500). - pub letter_spacing: [f64; 3], - /// Glyph Scaling, percent of the glyphs' width (50..200). - pub glyph_scaling: [f64; 3], - /// Auto Leading: the leading of characters whose leading is Auto, in percent of their size - /// (0..500). - pub auto_leading: f64, - /// Single Word Justification: how a justified line holding one word is set. - pub single_word: SingleWordJustify, -} - -impl JustificationOptions { - /// Word Spacing's range (percent). - pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0); - /// Letter Spacing's range (percent). - pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0); - /// Glyph Scaling's range (percent). - pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0); - /// Auto Leading's range (percent). - pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0); - - /// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change - /// in a line's natural widths)? - pub fn desired_is_default(&self) -> bool { - let d = Self::default(); - self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1] - } - - /// Keeps options read from a file to what `text.setFormat` can set: a value that is not a - /// finite number becomes its default, every value is clamped to its range, and a Minimum, - /// Desired and Maximum out of order go back to their defaults together. - pub fn keep_in_range(&mut self) { - let d = Self::default(); - for (v, dv, (lo, hi)) in [ - (&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING), - (&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING), - (&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING), - ] { - for (x, dx) in v.iter_mut().zip(dv) { - *x = if x.is_finite() { x.clamp(lo, hi) } else { dx }; - } - if !(v[0] <= v[1] && v[1] <= v[2]) { - *v = dv; - } - } - let (lo, hi) = Self::AUTO_LEADING; - self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading }; - } - - /// A paragraph's leading for characters of `size` whose leading is Auto. - pub fn auto_leading_for(&self, size: f64) -> f64 { - size * self.auto_leading / 100.0 - } -} - -impl Default for JustificationOptions { - fn default() -> Self { - Self { - word_spacing: [80.0, 100.0, 133.0], - letter_spacing: [0.0, 0.0, 0.0], - glyph_scaling: [100.0, 100.0, 100.0], - auto_leading: 120.0, - single_word: SingleWordJustify::FullJustify, - } - } -} - -/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of -/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair -/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter -/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font -/// draws them, at most an em long. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Kashida { - /// No kashidas: justified lines widen their word spaces. - #[default] - None, - Short, - Medium, - Long, - Stylistic, -} - -impl Kashida { - /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. - pub fn max_em(self) -> f64 { - match self { - Kashida::None => 0.0, - Kashida::Short => 0.25, - Kashida::Medium => 0.5, - Kashida::Long | Kashida::Stylistic => 1.0, - } - } -} - -/// Paragraph attributes (the Paragraph panel). -/// -/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before -/// [`Justify::Auto`]. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(from = "ParaStyleFile", into = "ParaStyleFile")] -pub struct ParaStyle { - pub justify: Justify, - pub left_indent: f64, - pub right_indent: f64, - pub first_line_indent: f64, - pub space_before: f64, - pub space_after: f64, - pub hyphenate: bool, - /// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…). - pub hyphenation: HyphenationOptions, - /// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification - /// (Paragraph panel menu › Justification…). - pub justification: JustificationOptions, - /// Line breaking: Single-line or Every-line Composer. - pub composer: Composer, - /// Tab stops (Tabs panel), sorted by position. - pub tabs: Vec, - /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not - /// in a list. - pub list: Option, - /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. - pub style_name: Option, - /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made - /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before - /// it and imported text (already set) keep [`Mojikumi::None`]. - pub mojikumi: Mojikumi, - /// Paragraph direction (Paragraph panel): the base direction of each paragraph for - /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or - /// Arabic: right to left). - pub direction: Option, - /// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top - /// of one line's ideographic em box to the next. - pub leading_model: LeadingModel, - /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line - /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before - /// it and imported text keep [`Burasagari::None`]. - pub burasagari: Burasagari, - /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a - /// line. - pub kinsoku: Kinsoku, - /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and - /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, - /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. - pub roman_hanging: bool, - /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with - /// kashidas before they widen their word spaces. - pub kashida: Kashida, -} - -impl ParaStyle { - /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to - /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range - /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is - /// not a number becomes 0. - pub fn keep_spacing_in_range(&mut self) { - let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) }; - for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] { - *v = within(*v, -crate::MAX_COORD, crate::MAX_COORD); - } - for t in &mut self.tabs { - t.position = within(t.position, 0.0, 100_000.0); - } - } - - /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own - /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their - /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is - /// not a finite number). - pub fn effective_leading(&self, st: &CharStyle) -> f64 { - let auto = self.justification.auto_leading; - if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { - return st.effective_leading(); - } - let l = self.justification.auto_leading_for(st.size); - if l.is_finite() { l } else { st.effective_leading() } - } -} - -/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph -/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto -/// read the alignment and ignore the flag, and builds with it read Auto back. -#[derive(Serialize, Deserialize)] -struct ParaStyleFile { - #[serde(default)] - justify: Justify, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - justify_auto: bool, - #[serde(default)] - left_indent: f64, - #[serde(default)] - right_indent: f64, - #[serde(default)] - first_line_indent: f64, - #[serde(default)] - space_before: f64, - #[serde(default)] - space_after: f64, - #[serde(default)] - hyphenate: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - hyphenation: HyphenationOptions, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - justification: JustificationOptions, - #[serde(default, skip_serializing_if = "Composer::is_default")] - composer: Composer, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tabs: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - list: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - style_name: Option, - #[serde(default, skip_serializing_if = "Mojikumi::is_none")] - mojikumi: Mojikumi, - #[serde(default, skip_serializing_if = "Option::is_none")] - direction: Option, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - leading_model: LeadingModel, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - burasagari: Burasagari, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - kinsoku: Kinsoku, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - roman_hanging: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - kashida: Kashida, -} - -impl From for ParaStyleFile { - fn from(p: ParaStyle) -> Self { - let justify_auto = p.justify == Justify::Auto; - let justify = match p.justify { - Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right, - Justify::Auto => Justify::Left, - j => j, - }; - let ParaStyle { - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - justification, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - .. - } = p; - Self { - justify, - justify_auto, - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - justification, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - } - } -} - -impl From for ParaStyle { - fn from(f: ParaStyleFile) -> Self { - let justify = if f.justify_auto { Justify::Auto } else { f.justify }; - let mut justification = f.justification; - justification.keep_in_range(); - let ParaStyleFile { - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - .. - } = f; - Self { - justify, - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - justification, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - } - } -} - -/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set -/// in ([`CharStyle::char_direction`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ParaDirection { - LeftToRight, - RightToLeft, -} - -/// How a paragraph's leading is measured. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LeadingModel { - /// From one line's baseline to the next's: a line's leading is the space above it, and area - /// type's first baseline follows Area Type Options › First Baseline. - #[default] - RomanBaseline, - /// From the top of one line's ideographic em box (the right side, in vertical type) to the - /// next's: a line's leading is the space below it, and area type's first line touches the top - /// of the frame. Japanese layout's usual model. - EmBoxTop, -} - -/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half -/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's -/// after it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Mojikumi { - /// Every character takes its full advance. - #[default] - None, - /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, - /// comma or full stop ending a line is set half width. - LineEndHalf, -} - -impl Mojikumi { - pub fn is_none(&self) -> bool { - *self == Mojikumi::None - } -} - -/// Which Japanese characters may not start or end a line (kinsoku shori). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Kinsoku { - /// No kinsoku: a line may break between any two characters that allow a break. - None, - /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small - /// kana and the like don't start a line; opening brackets don't end one. - #[default] - Hard, - /// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line - /// (JLREQ's level 3 line-breaking rules, Appendix C.3). - Soft, -} - -/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) -/// stands outside the line's measure, in the space its punctuation spacing gives it (half width -/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown -/// as None / Regular / Force. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Burasagari { - /// A comma or full stop that doesn't fit goes to the next line with the character before it. - #[default] - None, - /// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside. - Standard, - /// A comma or full stop ending a line always hangs, and the rest of the line fills the measure. - Forced, -} - -/// Area Type Options "First Baseline" offset. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FirstBaseline { - /// The tallest glyph ascent touches the frame top (Illustrator's default). - #[default] - Ascent, - CapHeight, - XHeight, - /// The first line's leading. - Leading, - /// Exactly `first_baseline_min` below the top. - Fixed, -} - -/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum VerticalAlign { - /// Lines start at the top of the cell (the default). - #[default] - Top, - /// The block of lines is centred in the cell. - Center, - /// The last line's descent touches the cell bottom. - Bottom, - /// The first line stays at the top, the last one moves to the bottom and the space left over - /// is shared equally between the lines (no paragraph spacing limit). - Justify, -} - -impl VerticalAlign { - pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify]; - pub fn id(self) -> &'static str { - match self { - VerticalAlign::Top => "top", - VerticalAlign::Center => "center", - VerticalAlign::Bottom => "bottom", - VerticalAlign::Justify => "justify", - } - } -} - -/// Text Wrap Options of a wrap object (Object → Text Wrap). -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TextWrap { - /// Distance kept between the text and the object, in points (Illustrator's default 6 pt). - pub offset: f64, - /// Invert Wrap: text flows inside the object instead of around it. - pub invert: bool, -} - -impl Default for TextWrap { - fn default() -> Self { - Self { offset: 6.0, invert: false } - } -} - -/// A resolved wrap shape on an area text object: the outline of a wrap object in text space. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct WrapShape { - pub path: PathData, - #[serde(flatten)] - pub wrap: TextWrap, -} - -/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PathEffect { - /// Glyphs rotate with the path (the default). - #[default] - Rainbow, - /// Vertical edges stay vertical; the baseline follows the path. - Skew, - /// Horizontal edges stay horizontal; vertical edges are perpendicular to the path. - #[serde(rename = "3dRibbon")] - Ribbon3d, - /// No rotation: the left end of each glyph's baseline sits on the path. - StairStep, - /// The baseline centre sits on the path and glyphs point away from the path's centre. - Gravity, -} - -impl PathEffect { - pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity]; - pub fn id(self) -> &'static str { - match self { - PathEffect::Rainbow => "rainbow", - PathEffect::Skew => "skew", - PathEffect::Ribbon3d => "3dRibbon", - PathEffect::StairStep => "stairStep", - PathEffect::Gravity => "gravity", - } - } - pub fn parse(s: &str) -> Option { - let k = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); - Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d)) - } -} - -/// Type on a Path Options › Align to Path: which height of the type runs along the path. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PathAlign { - /// The font's top edge: the type hangs below the path. - Ascender, - /// The font's bottom edge: the type stands above the path. - Descender, - /// Halfway between the ascender and the descender. - Center, - /// The baseline (the default). - #[default] - Baseline, -} - -impl PathAlign { - pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline]; - pub fn id(self) -> &'static str { - match self { - PathAlign::Ascender => "ascender", - PathAlign::Descender => "descender", - PathAlign::Center => "center", - PathAlign::Baseline => "baseline", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) - } -} - -/// Area Type Options: rows and columns, gutters, inset and first baseline of area type. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct AreaOptions { - #[serde(deserialize_with = "de_rows_or_columns")] - pub rows: usize, - #[serde(deserialize_with = "de_rows_or_columns")] - pub columns: usize, - /// Gutter between rows/columns in points. - pub gutter: f64, - /// Inset from the frame edges in points. - pub inset: f64, - pub first_baseline: FirstBaseline, - /// Minimum first-baseline offset in points. - pub first_baseline_min: f64, - /// Vertical alignment of the lines in each row/column. - pub vertical_align: VerticalAlign, - /// 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, -} - -impl Default for AreaOptions { - fn default() -> Self { - Self { - rows: 1, - columns: 1, - gutter: 18.0, - inset: 0.0, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - vertical_align: VerticalAlign::Top, - fit: AreaFit::None, - } - } -} - -impl AreaOptions { - /// The most rows, and the most columns, area type can have. - pub const MAX_ROWS_COLUMNS: usize = 100; -} - -/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to -/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`]. -fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX); - Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS)) -} - -/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or -/// `{"shrinkText": {"minPercent": 50}}`. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AreaFit { - /// The frame keeps its size; text that doesn't fit overflows. - #[default] - None, - /// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after - /// each edit, for rectangular frames of horizontal type in one row. - AutoHeight, - /// Text that overflows is scaled down (size, leading and baseline shift; not paragraph - /// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time. - ShrinkText { - #[serde(rename = "minPercent", default = "AreaFit::default_min_percent")] - min_percent: f64, - }, -} - -impl AreaFit { - /// The smallest Shrink Text to Fit percentage allowed. - pub const MIN_PERCENT: f64 = 10.0; - /// The Shrink Text to Fit percentage new settings start with. - pub const DEFAULT_MIN_PERCENT: f64 = 50.0; - fn default_min_percent() -> f64 { - Self::DEFAULT_MIN_PERCENT - } - /// Its id: `none`, `autoHeight` or `shrinkText`. - pub fn id(self) -> &'static str { - match self { - AreaFit::None => "none", - AreaFit::AutoHeight => "autoHeight", - AreaFit::ShrinkText { .. } => "shrinkText", - } - } - /// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent` - /// for Shrink Text (clamped to 10..100; a non-finite value gives the default). - pub fn parse(id: &str, min_percent: Option) -> Option { - match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { - "none" | "off" => Some(AreaFit::None), - "autoheight" | "autosize" => Some(AreaFit::AutoHeight), - "shrinktext" | "shrinktexttofit" | "shrink" => { - Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) }) - } - _ => None, - } - } - /// A Shrink Text minimum percentage within 10..100 (the default when not finite). - pub fn clamp_percent(p: f64) -> f64 { - if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT } - } - /// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text. - pub fn min_scale(self) -> Option { - match self { - AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0), - _ => None, - } - } -} - -/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or -/// [`ParaStyle`] fields, by their serialized names). Text using it records the name. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TextStyleDef { - pub name: String, - #[serde(default)] - pub attrs: serde_json::Map, -} - -/// A run of text sharing one character style. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TextRun { - pub text: String, - pub style: CharStyle, - /// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows - /// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets - /// the size and tracking; fills and strokes don't apply to the art. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inline: Option, -} - -impl TextRun { - /// A plain text run. - pub fn new(text: impl Into, style: CharStyle) -> Self { - Self { text: text.into(), style, inline: None } - } - /// An inline graphic run showing `art`. - pub fn inline(art: InlineArt, style: CharStyle) -> Self { - Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) } - } -} - -/// The object replacement character: the plain text of an inline graphic run. -pub const INLINE_CHAR: char = '\u{FFFC}'; - -/// Largest inline graphic scale (× the run's size) accepted from input. -pub const INLINE_MAX_SCALE: f64 = 100.0; - -/// A document symbol placed inline in text ([`TextRun::inline`]). -/// -/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's -/// font size, its left edge at the pen position, and its vertical centre on the middle of the -/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap -/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at -/// the default scale, stays inside the font's ascent and descent so the leading doesn't change. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct InlineArt { - /// Name of the document symbol ([`crate::Symbol::name`]). - pub symbol: String, - /// Art height as a multiple of the run's font size. - #[serde(default = "one")] - pub scale: f64, - /// Extra raise in points (positive = up). - #[serde(default)] - pub baseline_shift: f64, - /// The symbol art's visual bounds at its natural size, resolved from the document's symbols by - /// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the - /// layout then reserves a one-em square and nothing is drawn. - #[serde(skip)] - pub bounds: Option, -} - -fn one() -> f64 { - 1.0 -} - -fn fifty() -> f64 { - 50.0 -} - -impl InlineArt { - pub fn new(symbol: impl Into) -> Self { - Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } - } - /// `scale`, made finite and positive (untrusted input). - pub fn safe_scale(&self) -> f64 { - if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 } - } - /// `baseline_shift`, made finite (untrusted input). - pub fn safe_shift(&self) -> f64 { - if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum TextKind { - /// Point type: anchored at the baseline origin of the first line. - Point, - /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). - Area { frame: PathData }, - /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the - /// path length; no `end`: the end of the path, or once round a closed path). See - /// [`TextKind::path_span`]. - OnPath { - path: PathData, - start: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - end: Option, - }, -} - -impl TextKind { - /// Type on a path's span: from its start to its end bracket, as fractions of its path's length. - /// Round a closed path the end may be past 1 (the span runs on past the path's start), a full - /// turn without an end or with the end at the start. None for other type. - pub fn path_span(&self) -> Option<(f64, f64)> { - let TextKind::OnPath { path, start, end } = self else { return None }; - let s = fraction(*start); - Some(if path.is_closed() { - let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0)); - (s, s + if run < 1e-9 { 1.0 } else { run }) - } else { - (s, end.map_or(1.0, |e| fraction(e).max(s))) - }) - } -} - -/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the -/// document → text map). False when an anchor doesn't exist. -fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { - // A distance: only the linear part of the document → text map applies. - let local = to_text * d.to_point() - to_text * Point::ZERO; - refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) -} - -/// `x` as a fraction of a path's length (0..1; 0 when not finite). -fn fraction(x: f64) -> f64 { - if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 } -} - -/// `path` run the other way, each point as far from its new start as it was from its old end -/// (the subpaths in reverse order, a closed one keeping its first anchor first). -fn reverse_from_end(path: &mut PathData) { - path.subpaths.reverse(); - for sp in &mut path.subpaths { - sp.reverse(); - if sp.closed && !sp.anchors.is_empty() { - sp.anchors.rotate_right(1); - } - } -} - -/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in -/// vertical type (the Japanese convention), or below (left in vertical type). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RubyPosition { - #[default] - Above, - Below, -} - -/// How ruby text lines up over its base text when the two are different widths. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RubyAlign { - /// Centred over the base text. - #[default] - Center, - /// Starting at the base text's start (left in horizontal type, top in vertical). - Start, - /// Ending at the base text's end (right in horizontal type, bottom in vertical). - End, -} - -/// Ruby (furigana, phonetic guide): a reading set over a range of the text. -/// -/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as -/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; -/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and -/// allowed to overhang neighbouring characters when the reading is longer than its base. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RubySpan { - /// Byte offset of the first base character in the plain text. - pub start: usize, - /// Byte offset just past the last base character. - pub end: usize, - /// The reading, set in the base characters' font (its size comes from `size_percent`). - pub text: String, - #[serde(default)] - pub position: RubyPosition, - #[serde(default)] - pub align: RubyAlign, - /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). - #[serde(default = "fifty")] - pub size_percent: f64, - /// Let a reading wider than its base hang over the neighbouring characters instead of being - /// squeezed to the base's width. - #[serde(default = "yes")] - pub overhang: bool, -} - -impl RubySpan { - /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). - pub fn new(start: usize, end: usize, text: impl Into) -> Self { - Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } - } - /// `size_percent` made finite and usable: 1–100% (untrusted input). - pub fn safe_size_percent(&self) -> f64 { - if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } - } -} +mod area; +mod char_style; +mod object; +mod para_style; +mod runs; +mod tabs_lists; + +pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape}; +use char_style::yes; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, 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}; /// A text object. `runs` split into paragraphs at `\n`. /// @@ -1700,789 +112,5 @@ pub struct TextStyleScale { pub horizontal: f64, } -impl TextObject { - /// None for collapsed or unrepresentable transforms, whose type dimensions cannot - /// be edited by compensating the stored attributes. - pub fn style_scale(&self) -> Option { - let [a, b, c, d, e, f] = self.xf.as_coeffs(); - if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { - return None; - } - let (sx, sy) = (a.hypot(b), c.hypot(d)); - let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); - let horizontal = sx / sy; - if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { - return None; - } - // Avoid a pure rotation turning otherwise identical fields into mixed values. - let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; - Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) - } - - /// Character attributes as presented by controls (stored runs stay local for layout - /// and rich-text clipboard operations). Explicit vertical character scale stays separate. - pub fn effective_char_style(&self, style: &CharStyle) -> Option { - let scale = self.style_scale()?; - let mut style = style.clone(); - style.size *= scale.points; - style.leading = style.leading.map(|v| v * scale.points); - style.baseline_shift *= 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()) { - return None; - } - Some(style) - } - - /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that - /// scale from `xf`, so panels show document-point sizes without a separate transform factor. - /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable - /// transform). - pub fn scale_text_attributes(&mut self) -> bool { - let Some(scale) = self.style_scale() else { return false }; - if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { - return false; - } - let [a, b, c, d, _, _] = self.xf.as_coeffs(); - let (sx, sy) = (a.hypot(b), c.hypot(d)); - if sx <= 1e-12 || sy <= 1e-12 { - return false; - } - let mut baked = Vec::with_capacity(self.runs.len()); - for run in &self.runs { - let Some(eff) = self.effective_char_style(&run.style) else { return false }; - baked.push(eff); - } - for (run, style) in self.runs.iter_mut().zip(baked) { - run.style = style; - } - // Character strokes are drawn in text space: widen them by the vertical scale we remove. - self.scale_char_strokes(1.0 / sy); - self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); - self.cached_bounds = None; - self.cached_baselines.clear(); - true - } - - pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { - Self { - vertical: false, - kind: TextKind::Point, - xf: Affine::translate(origin.to_vec2()), - runs: vec![TextRun::new(text, style)], - ruby: Vec::new(), - para: ParaStyle::default(), - paras: Vec::new(), - first_para_continues: false, - area: AreaOptions::default(), - path_effect: PathEffect::default(), - path_align: PathAlign::default(), - path_spacing: 0.0, - wrap: Vec::new(), - optical_margin: false, - system_layout: false, - fractional_widths: true, - list_count: 0, - cached_bounds: None, - cached_baselines: Vec::new(), - } - } - pub fn plain_text(&self) -> String { - self.runs.iter().map(|r| r.text.as_str()).collect() - } - /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. - /// - /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a - /// range past the text or an inverted one would lay out nothing (or panic in a silly place). - pub fn normalize_ruby(&mut self) { - let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); - for r in &mut self.ruby { - r.start = r.start.min(len); - r.end = r.end.min(len); - if r.start > r.end { - std::mem::swap(&mut r.start, &mut r.end); - } - r.text = r.text.trim().to_string(); - } - self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); - self.ruby.sort_by_key(|r| (r.start, r.end)); - } - /// The ruby span covering byte `byte` of the plain text, if any. - pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { - self.ruby.iter().find(|r| r.start <= byte && byte < r.end) - } - /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. - pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { - let (a, b) = (start.min(end), start.max(end)); - self.ruby.iter().filter(move |r| r.start < b && a < r.end) - } - /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with - /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into - /// keep their start and end up where the replaced part ended. Call it after every edit of the - /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. - pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { - let (start, end) = (start.min(end), start.max(end)); - let delta = inserted as i64 - (end.saturating_sub(start)) as i64; - let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; - for r in &mut self.ruby { - if r.end <= start { - continue; - } - if r.start >= end { - r.start = shift(r.start); - r.end = shift(r.end); - } else { - r.start = r.start.min(start); - r.end = shift(r.end).max(r.start); - } - } - self.normalize_ruby(); - } - /// Number of paragraphs (`\n`-separated; empty text has one). - pub fn paragraph_count(&self) -> usize { - 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() - } - /// Attributes of paragraph `i` (the last paragraph's past the end). - pub fn para_at(&self, i: usize) -> &ParaStyle { - self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) - } - /// Every paragraph's attributes, one per paragraph. - pub fn paragraph_styles(&self) -> Vec { - (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() - } - /// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its - /// last entry, a long one is cut). - pub fn set_paragraph_styles(&mut self, styles: Vec) { - if let Some(first) = styles.first() { - self.para = first.clone(); - } - self.paras = styles; - self.normalize_paras(); - } - /// The same attributes for every paragraph. - pub fn set_all_paras(&mut self, style: ParaStyle) { - self.para = style; - self.paras.clear(); - } - /// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the - /// last), `paras[0] == para`, and empty when every paragraph is alike. - pub fn normalize_paras(&mut self) { - if self.paras.is_empty() { - return; - } - let n = self.paragraph_count(); - let last = self.paras.last().cloned().unwrap_or_default(); - self.paras.resize(n, last); - if let Some(first) = self.paras.first_mut() { - first.clone_from(&self.para); - } - if self.paras.iter().all(|p| *p == self.para) { - self.paras.clear(); - } - } - /// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret - /// touches its paragraph). - pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range { - let text = self.plain_text(); - let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len())); - let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count()); - index(a)..index(b) + 1 - } - /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). - pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { - match range { - None => { - f(&mut self.para); - for p in &mut self.paras { - f(p); - } - } - Some(r) => { - let mut v = self.paragraph_styles(); - for p in v.iter_mut().take(r.end).skip(r.start) { - f(p); - } - self.set_paragraph_styles(v); - } - } - self.normalize_paras(); - } - /// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames. - pub fn para_styles_mut(&mut self) -> impl Iterator { - std::iter::once(&mut self.para).chain(self.paras.iter_mut()) - } - /// Every stored paragraph style (`para` and the per-paragraph ones). - pub fn para_styles(&self) -> impl Iterator { - std::iter::once(&self.para).chain(self.paras.iter()) - } - /// Update the paragraph styles for replacing bytes `start..end` of the current plain text by - /// `insert` (call before changing the runs). The paragraph the range starts in keeps its - /// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that - /// style too (Return continues the paragraph it splits); later paragraphs keep theirs. - pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) { - if self.paras.is_empty() { - return; - } - let r = self.paragraphs_in(start, end); - let v = self.paragraph_styles(); - let Some(keep) = v.get(r.start).cloned() else { return }; - let added = insert.bytes().filter(|&b| b == b'\n').count(); - let mut out: Vec = v.iter().take(r.start + 1).cloned().collect(); - out.extend(std::iter::repeat_n(keep, added)); - out.extend(v.iter().skip(r.end).cloned()); - self.paras = out; - if let Some(first) = self.paras.first() { - self.para = first.clone(); - } - } - pub fn first_style(&self) -> CharStyle { - self.runs.first().map(|r| r.style.clone()).unwrap_or_default() - } - /// Approximate bounds when no layout cache is available (0.55 em average advance). - pub fn estimate_bounds(&self) -> Rect { - let st = self.first_style(); - let text = self.plain_text(); - let lines: Vec<&str> = text.split('\n').collect(); - let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55; - let lead = st.effective_leading(); - Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64) - } - pub fn bounds(&self) -> Option { - match &self.kind { - TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)), - TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)), - TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), - } - } - /// How far the characters' strokes paint past their glyphs, in document space: they scale - /// with the type. - pub fn char_stroke_outset(&self) -> f64 { - let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); - self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k - } - /// How far the characters' strokes can paint past their glyphs, their miter spikes and square - /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must - /// redraw. They stretch with the type, so this is as far as its transform takes them on either - /// axis. The visual bounds take [`Self::char_stroke_outset`]. - pub fn char_stroke_reach(&self) -> f64 { - let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); - let [a, b, c, d, ..] = self.xf.as_coeffs(); - reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) - } - pub fn transform(&mut self, a: Affine) { - self.xf = a * self.xf; - } - /// Type on a path's path in document space; None for other type. - pub fn type_path(&self) -> Option { - match &self.kind { - TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)), - _ => None, - } - } - /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its - /// centre bracket dragged across the path): the path runs the other way and the brackets swap - /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. - pub fn flip_on_path(&mut self) -> bool { - let Some((s, e)) = self.kind.path_span() else { return false }; - let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; - reverse_from_end(path); - if path.is_closed() { - *start = (1.0 - e).rem_euclid(1.0); - *end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0)); - } else { - *start = 1.0 - e; - *end = (s > 1e-9).then_some(1.0 - s); - } - true - } - /// Area type's frame (the type area) in document space; None for other type. - pub fn area_frame(&self) -> Option { - match &self.kind { - TextKind::Area { frame } => Some(frame.transformed(self.xf)), - _ => None, - } - } - /// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size - /// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for - /// other type, a degenerate `xf` or a frame that would not be finite. - pub fn transform_area(&mut self, a: Affine) -> bool { - let xf = self.xf; - self.reshape_area(|frame, to_text| { - frame.transform(to_text * a * xf); - true - }) - } - /// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with - /// their handles (a Direct Selection drag of a frame corner or edge). False, changing - /// nothing, when an anchor doesn't exist, or as [`Self::transform_area`]. - pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { - self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d)) - } - /// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space, - /// with their handles (a Direct Selection drag of an anchor); the brackets keep their - /// fractions of the path. False, changing nothing, for other type, when an anchor doesn't - /// exist, a degenerate `xf` or a path that would not be finite. - pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { - matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) - } - /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it - /// when `f` succeeds and the result is finite. - fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { - matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f) - } - /// [`Self::reshape_area`] for the area frame or type on a path's path. - fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { - let det = self.xf.determinant(); - if !det.is_finite() || det.abs() < 1e-12 { - return false; - } - let to_text = self.xf.inverse(); - let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false }; - let mut next = frame.clone(); - if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { - return false; - } - *frame = next; - true - } - /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so - /// `1 / scale` keeps their weight through a transform that scales the type). - pub fn scale_char_strokes(&mut self, s: f64) { - for r in &mut self.runs { - r.style.stroke_width *= s; - if let Some(d) = &mut r.style.stroke_dash { - d.scale(s); - } - } - } - /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run - /// gradient fits, as the renderers lay it out. - pub fn local_bounds(&self) -> Rect { - self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) - } - /// The baselines in text space (the layout cache): for point type with none cached, the first - /// one across [`Self::local_bounds`] (down the first column's centre line for vertical type). - pub fn baselines(&self) -> impl Iterator + '_ { - let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| { - let b = self.local_bounds(); - if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) } - }); - self.cached_baselines.iter().copied().chain(first) - } - /// Keeps every run's size, leading, baseline shift and scales in range - /// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab - /// stops ([`ParaStyle::keep_spacing_in_range`]). - pub fn keep_type_values_in_range(&mut self) { - for r in &mut self.runs { - r.style.keep_dimensions_in_range(); - } - for p in self.para_styles_mut() { - p.keep_spacing_in_range(); - } - } -} - -impl crate::Document { - /// Keeps the character dimensions, indents, paragraph spacing and tab stops in range - /// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph - /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts - /// back (opacity-mask art included), and the same attributes of the character and paragraph - /// styles. It recurses through the art: call it on a document no deeper than - /// [`crate::depth::MAX_DEPTH`]. - pub fn keep_type_values_in_range(&mut self) { - let mut fix = |n: &mut crate::Node| { - if let crate::NodeKind::Text(t) = &mut n.kind { - t.keep_type_values_in_range(); - } - }; - 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 { - crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); - } - // Styles store the attributes they set by their serialized names (a number, or null for - // Auto leading); a number is rewritten only when it changes. - for def in self.char_styles.iter_mut() { - let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64); - let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() }; - st.size = num("size").unwrap_or(st.size); - 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.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)), - ("leading", st.leading), - ]; - for (k, v) in kept { - if let Some(old) = def.attrs.get_mut(k) - && old.is_number() - && old.as_f64() != v - { - *old = v.into(); - } - } - } - for def in self.para_styles.iter_mut() { - for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] { - if let Some(v) = def.attrs.get_mut(k) - && let Some(x) = v.as_f64() - && x.abs() > crate::MAX_COORD - { - *v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into(); - } - } - for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() { - if let Some(v) = t.get_mut("position") - && let Some(x) = v.as_f64() - && !(0.0..=100_000.0).contains(&x) - { - *v = x.clamp(0.0, 100_000.0).into(); - } - } - } - } - - /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches - /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art - /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry - /// ranges from outside: a range past the text, an inverted one or an empty reading would lay - /// out nothing. It recurses through the art: call it on a document no deeper than - /// [`crate::depth::MAX_DEPTH`]. - pub fn fix_ruby(&mut self) { - let mut fix = |n: &mut crate::Node| { - if let crate::NodeKind::Text(t) = &mut n.kind { - t.normalize_ruby(); - } - }; - 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 { - crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - /// Character Direction is saved only when set, and styles written before it existed read as - /// Default. - #[test] - fn character_direction_is_saved_only_when_set() { - let st = CharStyle::default(); - assert_eq!(st.char_direction, None); - assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); - for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { - let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; - assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); - assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); - } - let old = serde_json::to_value(&st).unwrap(); - assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); - } - - /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. - #[test] - fn ruby_is_saved_and_missing_ruby_reads_as_none() { - let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); - t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; - let json = serde_json::to_string(&t).unwrap(); - assert!(json.contains("かんじ")); - let back: TextObject = serde_json::from_str(&json).unwrap(); - assert_eq!(back.ruby, t.ruby); - assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); - // Old files have no `ruby` key: none. - let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); - old.as_object_mut().unwrap().remove("ruby"); - assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); - // A damaged file's range past the text and its empty reading are clamped away. - let mut damaged = t.clone(); - damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; - damaged.normalize_ruby(); - assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); - } - - /// Digits map every digit of the three sets into the chosen one, leave the rest alone, and - /// are saved only when set. - #[test] - fn digits_map_between_the_numeral_systems() { - let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; - for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { - assert_eq!(map(Digits::Default, s), s); - assert_eq!(map(Digits::Arabic, s), "0123456789"); - assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩"); - assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹"); - } - // Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets. - assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔"); - let st = CharStyle::default(); - assert!(serde_json::to_value(&st).unwrap().get("digits").is_none()); - let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() }; - assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi"); - // Styles written before the attribute existed (no `digits` key) read as Default. - let old = serde_json::to_value(&st).unwrap(); - assert_eq!(serde_json::from_value::(old).unwrap().digits, Digits::Default); - assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); - } - - /// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. - #[test] - fn paragraph_effective_leading_follows_auto_leading() { - let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() }; - let mut para = ParaStyle::default(); - assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%"); - para.justification.auto_leading = 200.0; - assert_eq!(para.effective_leading(&st), 20.0); - para.justification.auto_leading = 0.0; - assert_eq!(para.effective_leading(&st), 0.0); - let set = CharStyle { leading: Some(14.0), ..st.clone() }; - assert_eq!(para.effective_leading(&set), 14.0); - para.justification.auto_leading = f64::NAN; - assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number"); - } - - /// A file can't feed layout justification values `text.setFormat` would refuse: each is - /// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and - /// Maximum out of order go back to their defaults. - #[test] - fn justification_options_read_from_a_file_stay_in_range() { - let d = JustificationOptions::default(); - let read: ParaStyle = serde_json::from_value(serde_json::json!({ - "justification": { - "word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0], - "glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12 - } - })) - .unwrap(); - let j = read.justification; - assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]); - assert_eq!(j.letter_spacing, d.letter_spacing, "out of order"); - assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]); - assert_eq!(j.auto_leading, 0.0); - let mut j = JustificationOptions { - word_spacing: [f64::NAN, 100.0, f64::INFINITY], - letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY], - auto_leading: f64::NAN, - ..d.clone() - }; - j.keep_in_range(); - assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading)); - let mut same = d.clone(); - same.keep_in_range(); - assert_eq!(same, d); - } - - /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, - /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds - /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and - /// starts again after a paragraph in no list. A list from before number styles reads as - /// bulleted, and a bulleted list saves no number style. - #[test] - fn number_styles_write_their_numbers_and_roll_over() { - let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::>(); - assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]); - assert_eq!( - numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]), - ["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"] - ); - assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]); - assert_eq!( - numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]), - ["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"] - ); - assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]); - assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]); - assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]); - assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); - assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); - let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); - let mut count = 0; - let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] - .iter() - .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) - .collect(); - assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); - assert_eq!(count, 1); - let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); - assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); - assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); - assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); - assert_eq!( - serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(), - serde_json::json!({"bullet": "dot", "number": "lowerRoman"}) - ); - } - - #[test] - fn character_controls_reject_collapsed_and_nonfinite_transforms() { - let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); - for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { - t.xf = Affine::new(matrix); - assert!(t.effective_char_style(&t.first_style()).is_none()); - } - t.xf = Affine::rotate(0.83); - assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); - t.xf = Affine::scale(1e308); - assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); - } - - #[test] - fn point_text_basics() { - let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); - assert_eq!(t.plain_text(), "Hello"); - let b = t.bounds().unwrap(); - assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); - assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); - } - - fn justified(j: Justify) -> ParaStyle { - ParaStyle { justify: j, ..ParaStyle::default() } - } - - #[test] - fn paragraph_styles_split_merge_and_normalize() { - let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); - assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); - assert_eq!(t.paragraphs_in(0, 0), 0..1); - assert_eq!(t.paragraphs_in(4, 4), 1..2); - assert_eq!(t.paragraphs_in(2, 9), 0..3); - assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); - t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); - let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); - // Return inside "two": the new paragraph continues its style. - t.splice_paras(5, 5, "\n"); - t.runs[0].text.insert(5, '\n'); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); - // Deleting the break between "one" and "t": the first paragraph's style wins. - t.splice_paras(3, 4, ""); - t.runs[0].text.remove(3); - assert_eq!(t.plain_text(), "onet\nwo\nthree"); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); - // An edit that skips the splice is repaired by normalizing (the last style continues). - t.runs[0].text.push_str("\nfour"); - t.normalize_paras(); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); - // `para` is paragraph 0's; all alike collapses to `para` alone. - t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); - assert_eq!(t.para.justify, Justify::Right); - t.edit_paras(None, |p| p.justify = Justify::Right); - assert!(t.paras.is_empty() && t.para.justify == Justify::Right); - // A short list repeats its last entry, a long one is cut. - t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); - t.set_paragraph_styles(vec![justified(Justify::Center); 9]); - assert!(t.paras.is_empty() && t.para.justify == Justify::Center); - // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. - t.edit_paras(Some(3..4), |p| p.space_before = 4.0); - let json = serde_json::to_value(&t).unwrap(); - assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); - let mut old = json.clone(); - old.as_object_mut().unwrap().remove("paras"); - let back: TextObject = serde_json::from_value(old).unwrap(); - assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); - let back: TextObject = serde_json::from_value(json).unwrap(); - assert_eq!(back.para_at(3).space_before, 4.0); - } - - /// 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()); - t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; - t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); - t - } - - #[test] - fn area_resizes_its_frame_and_keeps_its_type() { - let mut t = area_type(); - assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); - // Bottom-right handle to (200, 140), about the top-left corner. - let o = Affine::translate((40.0, 40.0)); - assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); - assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); - let b = t.bounds().unwrap(); - assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); - assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); - // Point type has no area; a degenerate transform or a non-finite frame changes nothing. - let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); - assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); - let before = t.clone(); - assert!(!t.transform_area(Affine::scale(f64::INFINITY))); - t.xf = Affine::scale(0.0); - assert!(!t.transform_area(Affine::scale(2.0))); - t.xf = before.xf; - assert_eq!(t, before); - } - - #[test] - fn area_anchors_move_in_document_space() { - let mut t = area_type(); - // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. - assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); - let TextKind::Area { frame } = &t.kind else { panic!() }; - assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); - assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); - // A missing anchor changes nothing. - let before = t.clone(); - assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); - assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); - assert_eq!(t, before); - } - - /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore - /// `justify_auto`); this build reads Auto back. - #[test] - fn auto_alignment_saves_readable_by_older_builds() { - #[derive(Deserialize, Debug, PartialEq)] - enum OldJustify { - Left, - Center, - Right, - JustifyLeft, - JustifyCenter, - JustifyRight, - JustifyAll, - } - #[derive(Deserialize)] - struct OldPara { - justify: OldJustify, - } - #[derive(Deserialize)] - struct OldText { - para: OldPara, - } - for (direction, physical) in - [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] - { - let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); - t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; - let json = serde_json::to_string(&t).unwrap(); - let old: OldText = serde_json::from_str(&json).unwrap(); - assert_eq!(old.para.justify, physical, "{json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), t); - } - // Other alignments save as before, without the flag. - let p = ParaStyle { justify: Justify::Right, ..Default::default() }; - let json = serde_json::to_string(&p).unwrap(); - assert!(!json.contains("justify_auto"), "{json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), p); - // Files from before the flag keep their alignment. - assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); - } -} +mod tests; diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs new file mode 100644 index 000000000..5897d957a --- /dev/null +++ b/crates/doc/src/text/area.rs @@ -0,0 +1,228 @@ +use super::para_style::FirstBaseline; +use super::*; + +/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VerticalAlign { + /// Lines start at the top of the cell (the default). + #[default] + Top, + /// The block of lines is centred in the cell. + Center, + /// The last line's descent touches the cell bottom. + Bottom, + /// The first line stays at the top, the last one moves to the bottom and the space left over + /// is shared equally between the lines (no paragraph spacing limit). + Justify, +} + +impl VerticalAlign { + pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify]; + pub fn id(self) -> &'static str { + match self { + VerticalAlign::Top => "top", + VerticalAlign::Center => "center", + VerticalAlign::Bottom => "bottom", + VerticalAlign::Justify => "justify", + } + } +} + +/// Text Wrap Options of a wrap object (Object → Text Wrap). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextWrap { + /// Distance kept between the text and the object, in points (Illustrator's default 6 pt). + pub offset: f64, + /// Invert Wrap: text flows inside the object instead of around it. + pub invert: bool, +} + +impl Default for TextWrap { + fn default() -> Self { + Self { offset: 6.0, invert: false } + } +} + +/// A resolved wrap shape on an area text object: the outline of a wrap object in text space. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WrapShape { + pub path: PathData, + #[serde(flatten)] + pub wrap: TextWrap, +} + +/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathEffect { + /// Glyphs rotate with the path (the default). + #[default] + Rainbow, + /// Vertical edges stay vertical; the baseline follows the path. + Skew, + /// Horizontal edges stay horizontal; vertical edges are perpendicular to the path. + #[serde(rename = "3dRibbon")] + Ribbon3d, + /// No rotation: the left end of each glyph's baseline sits on the path. + StairStep, + /// The baseline centre sits on the path and glyphs point away from the path's centre. + Gravity, +} + +impl PathEffect { + pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity]; + pub fn id(self) -> &'static str { + match self { + PathEffect::Rainbow => "rainbow", + PathEffect::Skew => "skew", + PathEffect::Ribbon3d => "3dRibbon", + PathEffect::StairStep => "stairStep", + PathEffect::Gravity => "gravity", + } + } + pub fn parse(s: &str) -> Option { + let k = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); + Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d)) + } +} + +/// Type on a Path Options › Align to Path: which height of the type runs along the path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathAlign { + /// The font's top edge: the type hangs below the path. + Ascender, + /// The font's bottom edge: the type stands above the path. + Descender, + /// Halfway between the ascender and the descender. + Center, + /// The baseline (the default). + #[default] + Baseline, +} + +impl PathAlign { + pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline]; + pub fn id(self) -> &'static str { + match self { + PathAlign::Ascender => "ascender", + PathAlign::Descender => "descender", + PathAlign::Center => "center", + PathAlign::Baseline => "baseline", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// Area Type Options: rows and columns, gutters, inset and first baseline of area type. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AreaOptions { + #[serde(deserialize_with = "de_rows_or_columns")] + pub rows: usize, + #[serde(deserialize_with = "de_rows_or_columns")] + pub columns: usize, + /// Gutter between rows/columns in points. + pub gutter: f64, + /// Inset from the frame edges in points. + pub inset: f64, + pub first_baseline: FirstBaseline, + /// Minimum first-baseline offset in points. + pub first_baseline_min: f64, + /// Vertical alignment of the lines in each row/column. + pub vertical_align: VerticalAlign, + /// 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, +} + +impl Default for AreaOptions { + fn default() -> Self { + Self { + rows: 1, + columns: 1, + gutter: 18.0, + inset: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + vertical_align: VerticalAlign::Top, + fit: AreaFit::None, + } + } +} + +impl AreaOptions { + /// The most rows, and the most columns, area type can have. + pub const MAX_ROWS_COLUMNS: usize = 100; +} + +/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to +/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`]. +fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX); + Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS)) +} + +/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or +/// `{"shrinkText": {"minPercent": 50}}`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AreaFit { + /// The frame keeps its size; text that doesn't fit overflows. + #[default] + None, + /// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after + /// each edit, for rectangular frames of horizontal type in one row. + AutoHeight, + /// Text that overflows is scaled down (size, leading and baseline shift; not paragraph + /// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time. + ShrinkText { + #[serde(rename = "minPercent", default = "AreaFit::default_min_percent")] + min_percent: f64, + }, +} + +impl AreaFit { + /// The smallest Shrink Text to Fit percentage allowed. + pub const MIN_PERCENT: f64 = 10.0; + /// The Shrink Text to Fit percentage new settings start with. + pub const DEFAULT_MIN_PERCENT: f64 = 50.0; + fn default_min_percent() -> f64 { + Self::DEFAULT_MIN_PERCENT + } + /// Its id: `none`, `autoHeight` or `shrinkText`. + pub fn id(self) -> &'static str { + match self { + AreaFit::None => "none", + AreaFit::AutoHeight => "autoHeight", + AreaFit::ShrinkText { .. } => "shrinkText", + } + } + /// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent` + /// for Shrink Text (clamped to 10..100; a non-finite value gives the default). + pub fn parse(id: &str, min_percent: Option) -> Option { + match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "none" | "off" => Some(AreaFit::None), + "autoheight" | "autosize" => Some(AreaFit::AutoHeight), + "shrinktext" | "shrinktexttofit" | "shrink" => { + Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) }) + } + _ => None, + } + } + /// A Shrink Text minimum percentage within 10..100 (the default when not finite). + pub fn clamp_percent(p: f64) -> f64 { + if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT } + } + /// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text. + pub fn min_scale(self) -> Option { + match self { + AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0), + _ => None, + } + } +} diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs new file mode 100644 index 000000000..a19b5f71c --- /dev/null +++ b/crates/doc/src/text/char_style.rs @@ -0,0 +1,432 @@ +use super::para_style::ParaDirection; +use super::*; + +/// Character attributes (the Character panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CharStyle { + pub font_family: String, + #[serde(default = "regular")] + pub font_style: String, + /// Which installed version of the font (its version string) when several versions of the + /// family and style are installed and the text was set in one the family and style alone + /// wouldn't pick (an imported file's type matched to it); None = the one they pick. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_version: Option, + /// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag + /// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of + /// each axis they name; the other axes keep the style's settings. Empty: the style as it is. + #[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub font_axes: std::collections::BTreeMap, + /// Size in points. + pub size: f64, + /// Leading in points; None = Auto (120% of size). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub leading: Option, + /// Tracking in 1/1000 em. + #[serde(default)] + pub tracking: f64, + /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kerning: Option, + #[serde(default)] + pub baseline_shift: f64, + #[serde(default = "hundred")] + pub h_scale: f64, + #[serde(default = "hundred")] + pub v_scale: f64, + #[serde(default)] + pub rotation: f64, + pub fill: Paint, + #[serde(default)] + pub stroke: Paint, + #[serde(default)] + pub stroke_width: f64, + #[serde(default)] + pub underline: bool, + #[serde(default)] + pub strikethrough: bool, + #[serde(default)] + pub all_caps: bool, + /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns + /// a feature on, `"-liga"` off. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub features: Vec, + /// Character style (Character Styles panel) these attributes come from; None = Normal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub style_name: Option, + /// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint_fill: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint_stroke: bool, + /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type + /// selected); defaults as for object strokes. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_cap: LineCap, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_join: LineJoin, + #[serde(default = "ten", skip_serializing_if = "is_ten")] + pub stroke_miter_limit: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke_dash: Option, + /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the + /// glyph outlines, inside or outside them, as for object strokes; the type stays live. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_align: StrokeAlign, + /// Superscript or subscript (Character panel). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub position: CharPosition, + /// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the + /// size (Document Setup → Type → Small Caps); None = off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub small_caps: Option, + /// Character Alignment (Character panel menu): where characters smaller than the largest on + /// their line line up with it. + #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] + pub char_align: CharAlign, + /// Character Direction (Character panel menu, Middle Eastern options): these characters are + /// set left to right or right to left inside their paragraph, each stretch of them a Unicode + /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives + /// them. + #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] + pub char_direction: Option, + /// 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`. + #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] + pub proportional_metrics: bool, + /// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters + /// stand side by side across one em of the column. Horizontal type keeps the mark and does not + /// move them. Two or three half-width digits do this without the mark. + /// + #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] + pub tate_chu_yoko: 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, + /// 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, +} + +/// 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). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Digits { + /// Each digit as typed. + #[default] + Default, + /// Western digits 0–9 (the digits Arabic typography calls Arabic). + Arabic, + /// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic. + Hindi, + /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. + Farsi, +} + +impl Digits { + /// 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 { + let value = match c { + '0'..='9' => c as u32 - '0' as u32, + '\u{660}'..='\u{669}' => c as u32 - 0x660, + '\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0, + _ => return c, + }; + let base = match self { + Digits::Default => return c, + Digits::Arabic => '0' as u32, + Digits::Hindi => 0x660, + Digits::Farsi => 0x6F0, + }; + char::from_u32(base + value).unwrap_or(c) + } +} + +/// Where a character smaller than the largest on its line lines up with it: on the Roman +/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic +/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the +/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CharAlign { + #[default] + RomanBaseline, + EmBoxTop, + EmBoxCenter, + EmBoxBottom, + IcfTop, + IcfBottom, +} + +/// Superscript or subscript proportions in percent of the font size (Document Setup → Type). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct ScriptMetrics { + /// Glyph size. + pub size: f64, + /// Baseline offset: up for superscript, down for subscript. + pub position: f64, +} + +impl ScriptMetrics { + pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; + /// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to + /// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside + /// its range becomes the nearer end. + pub fn in_range(self) -> Self { + Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) } + } +} + +/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for +/// `smallCapsSize`. A size outside it becomes the nearer end. +fn small_caps_in_range(size: f64) -> f64 { + size.clamp(1.0, 200.0) +} + +impl Default for ScriptMetrics { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Character position (Character panel Superscript / Subscript). The proportions are the +/// document's when the position is applied, and follow later Document Setup changes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CharPosition { + #[default] + Normal, + Superscript(ScriptMetrics), + Subscript(ScriptMetrics), +} + +impl CharPosition { + /// Glyph scale and baseline shift (points, positive = up) for text of `size` points. + pub fn scale_shift(self, size: f64) -> (f64, f64) { + match self { + CharPosition::Normal => (1.0, 0.0), + CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size), + CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size), + } + } + /// `normal`, `superscript` or `subscript`. + pub fn id(self) -> &'static str { + match self { + CharPosition::Normal => "normal", + CharPosition::Superscript(_) => "superscript", + CharPosition::Subscript(_) => "subscript", + } + } + /// Returns this position with its proportions kept by [`ScriptMetrics::in_range`]. + pub fn in_range(self) -> Self { + match self { + CharPosition::Normal => CharPosition::Normal, + CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()), + CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()), + } + } +} + +fn ten() -> f64 { + 10.0 +} + +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} + +fn regular() -> String { + "Regular".into() +} + +fn hundred() -> f64 { + 100.0 +} + +pub(super) fn yes() -> bool { + true +} + +impl Default for CharStyle { + fn default() -> Self { + Self { + font_family: "Source Sans 3".into(), + font_style: "Regular".into(), + font_version: None, + font_axes: std::collections::BTreeMap::new(), + size: 12.0, + leading: None, + tracking: 0.0, + kerning: None, + baseline_shift: 0.0, + h_scale: 100.0, + v_scale: 100.0, + rotation: 0.0, + fill: Paint::solid(Color::BLACK), + stroke: Paint::None, + stroke_width: 0.0, + underline: false, + strikethrough: false, + all_caps: false, + features: vec![], + style_name: None, + overprint_fill: false, + overprint_stroke: false, + stroke_cap: LineCap::Butt, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 10.0, + stroke_dash: None, + stroke_align: StrokeAlign::Center, + position: CharPosition::Normal, + char_align: CharAlign::RomanBaseline, + char_direction: None, + proportional_metrics: false, + tate_chu_yoko: false, + small_caps: None, + digits: Digits::Default, + no_break: false, + } + } +} + +impl CharStyle { + pub fn effective_leading(&self) -> f64 { + self.leading.unwrap_or(self.size * 1.2) + } + /// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size + /// 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. + 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 }; + self.size = within(self.size, default.size); + 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) }; + } + /// Do the characters draw a stroke (a paint and a positive weight)? + pub fn has_stroke(&self) -> bool { + !self.stroke.is_none() && self.stroke_width > 0.0 + } + /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, + /// alignment and overprint), so type strokes share the object strokes' geometry, rendering + /// and export. + pub fn stroke_layer(&self) -> StrokeLayer { + StrokeLayer { + overprint: self.overprint_stroke, + cap: self.stroke_cap, + join: self.stroke_join, + miter_limit: self.stroke_miter_limit, + dash: self.stroke_dash.clone(), + align: self.stroke_align, + ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) + } + } + /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the + /// character stroke (the options characters have no use for, such as arrowheads and width + /// profiles, are dropped). + pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { + self.stroke = st.paint.clone(); + self.stroke_width = st.width; + self.stroke_cap = st.cap; + self.stroke_join = st.join; + self.stroke_miter_limit = st.miter_limit; + self.stroke_dash = st.dash.clone(); + self.stroke_align = st.align; + } + /// How far the character stroke paints past the glyph outlines (text space): half the weight + /// centred, the whole weight outside, nothing inside (miter spikes aside). + pub fn stroke_outset(&self) -> f64 { + if !self.has_stroke() { + return 0.0; + } + match self.stroke_align { + StrokeAlign::Center => self.stroke_width / 2.0, + StrokeAlign::Outside => self.stroke_width, + StrokeAlign::Inside => 0.0, + } + } + /// The character fill as a fill layer, overprinting as the characters do. + fn fill_layer(&self) -> FillLayer { + FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } + } + /// The characters' paint as an object appearance (their outlines'): the fill, plus the + /// stroke when it draws one, overprinting as the characters do. + pub fn appearance(&self) -> Appearance { + let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; + if self.has_stroke() { + a.items.push(AppearanceItem::Stroke(self.stroke_layer())); + } + a + } + /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has + /// no paint): the appearance the Eyedropper and graphic styles take from type. + pub fn basic_appearance(&self) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } + } + /// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and + /// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`. + pub fn keep_scripts_in_range(&mut self) { + self.position = self.position.in_range(); + self.small_caps = self.small_caps.map(small_caps_in_range); + } +} + +impl crate::Document { + /// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup` + /// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph + /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts + /// back (opacity-mask art included), in graphs' label styles and in the character styles. It + /// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`]. + pub fn keep_scripts_in_range(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + r.style.keep_scripts_in_range(); + } + } + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + let l = &mut g.label_styles; + for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { + st.keep_scripts_in_range(); + } + } + }; + 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 { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + // Character styles store the attributes they set by their serialized names. + for def in self.char_styles.iter_mut() { + if let Some(v) = def.attrs.get_mut("position") + && let Ok(p) = serde_json::from_value::(v.clone()) + && p.in_range() != p + { + *v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null); + } + if let Some(v) = def.attrs.get_mut("small_caps") + && let Some(size) = v.as_f64() + && small_caps_in_range(size) != size + { + *v = small_caps_in_range(size).into(); + } + } + } +} diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs new file mode 100644 index 000000000..58caa1602 --- /dev/null +++ b/crates/doc/src/text/object.rs @@ -0,0 +1,493 @@ +use super::area::{AreaOptions, PathAlign, PathEffect}; +use super::char_style::CharStyle; +use super::para_style::ParaStyle; +use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; +use super::tabs_lists::ParaList; +use super::*; + +impl TextObject { + /// None for collapsed or unrepresentable transforms, whose type dimensions cannot + /// be edited by compensating the stored attributes. + pub fn style_scale(&self) -> Option { + let [a, b, c, d, e, f] = self.xf.as_coeffs(); + if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { + return None; + } + let (sx, sy) = (a.hypot(b), c.hypot(d)); + let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); + let horizontal = sx / sy; + if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { + return None; + } + // Avoid a pure rotation turning otherwise identical fields into mixed values. + let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; + Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) + } + + /// Character attributes as presented by controls (stored runs stay local for layout + /// and rich-text clipboard operations). Explicit vertical character scale stays separate. + pub fn effective_char_style(&self, style: &CharStyle) -> Option { + let scale = self.style_scale()?; + let mut style = style.clone(); + style.size *= scale.points; + style.leading = style.leading.map(|v| v * scale.points); + style.baseline_shift *= 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()) { + return None; + } + Some(style) + } + + /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that + /// scale from `xf`, so panels show document-point sizes without a separate transform factor. + /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable + /// transform). + pub fn scale_text_attributes(&mut self) -> bool { + let Some(scale) = self.style_scale() else { return false }; + if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { + return false; + } + let [a, b, c, d, _, _] = self.xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if sx <= 1e-12 || sy <= 1e-12 { + return false; + } + let mut baked = Vec::with_capacity(self.runs.len()); + for run in &self.runs { + let Some(eff) = self.effective_char_style(&run.style) else { return false }; + baked.push(eff); + } + for (run, style) in self.runs.iter_mut().zip(baked) { + run.style = style; + } + // Character strokes are drawn in text space: widen them by the vertical scale we remove. + self.scale_char_strokes(1.0 / sy); + self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); + self.cached_bounds = None; + self.cached_baselines.clear(); + true + } + + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { + Self { + vertical: false, + kind: TextKind::Point, + xf: Affine::translate(origin.to_vec2()), + runs: vec![TextRun::new(text, style)], + ruby: Vec::new(), + para: ParaStyle::default(), + paras: Vec::new(), + first_para_continues: false, + area: AreaOptions::default(), + path_effect: PathEffect::default(), + path_align: PathAlign::default(), + path_spacing: 0.0, + wrap: Vec::new(), + optical_margin: false, + system_layout: false, + fractional_widths: true, + list_count: 0, + cached_bounds: None, + cached_baselines: Vec::new(), + } + } + pub fn plain_text(&self) -> String { + self.runs.iter().map(|r| r.text.as_str()).collect() + } + /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. + /// + /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a + /// range past the text or an inverted one would lay out nothing (or panic in a silly place). + pub fn normalize_ruby(&mut self) { + let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); + for r in &mut self.ruby { + r.start = r.start.min(len); + r.end = r.end.min(len); + if r.start > r.end { + std::mem::swap(&mut r.start, &mut r.end); + } + r.text = r.text.trim().to_string(); + } + self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); + self.ruby.sort_by_key(|r| (r.start, r.end)); + } + /// The ruby span covering byte `byte` of the plain text, if any. + pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { + self.ruby.iter().find(|r| r.start <= byte && byte < r.end) + } + /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. + pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { + let (a, b) = (start.min(end), start.max(end)); + self.ruby.iter().filter(move |r| r.start < b && a < r.end) + } + /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with + /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into + /// keep their start and end up where the replaced part ended. Call it after every edit of the + /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. + pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { + let (start, end) = (start.min(end), start.max(end)); + let delta = inserted as i64 - (end.saturating_sub(start)) as i64; + let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; + for r in &mut self.ruby { + if r.end <= start { + continue; + } + if r.start >= end { + r.start = shift(r.start); + r.end = shift(r.end); + } else { + r.start = r.start.min(start); + r.end = shift(r.end).max(r.start); + } + } + self.normalize_ruby(); + } + /// Number of paragraphs (`\n`-separated; empty text has one). + pub fn paragraph_count(&self) -> usize { + 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() + } + /// Attributes of paragraph `i` (the last paragraph's past the end). + pub fn para_at(&self, i: usize) -> &ParaStyle { + self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) + } + /// Every paragraph's attributes, one per paragraph. + pub fn paragraph_styles(&self) -> Vec { + (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() + } + /// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its + /// last entry, a long one is cut). + pub fn set_paragraph_styles(&mut self, styles: Vec) { + if let Some(first) = styles.first() { + self.para = first.clone(); + } + self.paras = styles; + self.normalize_paras(); + } + /// The same attributes for every paragraph. + pub fn set_all_paras(&mut self, style: ParaStyle) { + self.para = style; + self.paras.clear(); + } + /// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the + /// last), `paras[0] == para`, and empty when every paragraph is alike. + pub fn normalize_paras(&mut self) { + if self.paras.is_empty() { + return; + } + let n = self.paragraph_count(); + let last = self.paras.last().cloned().unwrap_or_default(); + self.paras.resize(n, last); + if let Some(first) = self.paras.first_mut() { + first.clone_from(&self.para); + } + if self.paras.iter().all(|p| *p == self.para) { + self.paras.clear(); + } + } + /// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret + /// touches its paragraph). + pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range { + let text = self.plain_text(); + let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len())); + let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count()); + index(a)..index(b) + 1 + } + /// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte + /// where their paragraph starts: when the plain text from the start of the paragraph up to + /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise. + pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> { + let text = self.plain_text(); + let before = text.get(..at)?; + let start = before.rfind('\n').map_or(0, |i| i + 1); + let list = ParaList::typed(before[start..].strip_suffix(' ')?)?; + let index = before.bytes().filter(|&b| b == b'\n').count(); + self.para_at(index).list.is_none().then_some((start, list)) + } + /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). + pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { + match range { + None => { + f(&mut self.para); + for p in &mut self.paras { + f(p); + } + } + Some(r) => { + let mut v = self.paragraph_styles(); + for p in v.iter_mut().take(r.end).skip(r.start) { + f(p); + } + self.set_paragraph_styles(v); + } + } + self.normalize_paras(); + } + /// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames. + pub fn para_styles_mut(&mut self) -> impl Iterator { + std::iter::once(&mut self.para).chain(self.paras.iter_mut()) + } + /// Every stored paragraph style (`para` and the per-paragraph ones). + pub fn para_styles(&self) -> impl Iterator { + std::iter::once(&self.para).chain(self.paras.iter()) + } + /// Update the paragraph styles for replacing bytes `start..end` of the current plain text by + /// `insert` (call before changing the runs). The paragraph the range starts in keeps its + /// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that + /// style too (Return continues the paragraph it splits); later paragraphs keep theirs. + pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) { + if self.paras.is_empty() { + return; + } + let r = self.paragraphs_in(start, end); + let v = self.paragraph_styles(); + let Some(keep) = v.get(r.start).cloned() else { return }; + let added = insert.bytes().filter(|&b| b == b'\n').count(); + let mut out: Vec = v.iter().take(r.start + 1).cloned().collect(); + out.extend(std::iter::repeat_n(keep, added)); + out.extend(v.iter().skip(r.end).cloned()); + self.paras = out; + if let Some(first) = self.paras.first() { + self.para = first.clone(); + } + } + pub fn first_style(&self) -> CharStyle { + self.runs.first().map(|r| r.style.clone()).unwrap_or_default() + } + /// Approximate bounds when no layout cache is available (0.55 em average advance). + pub fn estimate_bounds(&self) -> Rect { + let st = self.first_style(); + let text = self.plain_text(); + let lines: Vec<&str> = text.split('\n').collect(); + let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55; + let lead = st.effective_leading(); + Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64) + } + pub fn bounds(&self) -> Option { + match &self.kind { + TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)), + TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)), + TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), + } + } + /// How far the characters' strokes paint past their glyphs, in document space: they scale + /// with the type. + pub fn char_stroke_outset(&self) -> f64 { + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k + } + /// How far the characters' strokes can paint past their glyphs, their miter spikes and square + /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must + /// redraw. They stretch with the type, so this is as far as its transform takes them on either + /// axis. The visual bounds take [`Self::char_stroke_outset`]. + pub fn char_stroke_reach(&self) -> f64 { + let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let [a, b, c, d, ..] = self.xf.as_coeffs(); + reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) + } + pub fn transform(&mut self, a: Affine) { + self.xf = a * self.xf; + } + /// Type on a path's path in document space; None for other type. + pub fn type_path(&self) -> Option { + match &self.kind { + TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)), + _ => None, + } + } + /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its + /// centre bracket dragged across the path): the path runs the other way and the brackets swap + /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. + pub fn flip_on_path(&mut self) -> bool { + let Some((s, e)) = self.kind.path_span() else { return false }; + let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; + reverse_from_end(path); + if path.is_closed() { + *start = (1.0 - e).rem_euclid(1.0); + *end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0)); + } else { + *start = 1.0 - e; + *end = (s > 1e-9).then_some(1.0 - s); + } + true + } + /// Area type's frame (the type area) in document space; None for other type. + pub fn area_frame(&self) -> Option { + match &self.kind { + TextKind::Area { frame } => Some(frame.transformed(self.xf)), + _ => None, + } + } + /// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size + /// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for + /// other type, a degenerate `xf` or a frame that would not be finite. + pub fn transform_area(&mut self, a: Affine) -> bool { + let xf = self.xf; + self.reshape_area(|frame, to_text| { + frame.transform(to_text * a * xf); + true + }) + } + /// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with + /// their handles (a Direct Selection drag of a frame corner or edge). False, changing + /// nothing, when an anchor doesn't exist, or as [`Self::transform_area`]. + pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { + self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d)) + } + /// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space, + /// with their handles (a Direct Selection drag of an anchor); the brackets keep their + /// fractions of the path. False, changing nothing, for other type, when an anchor doesn't + /// exist, a degenerate `xf` or a path that would not be finite. + pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { + matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) + } + /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it + /// when `f` succeeds and the result is finite. + fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { + matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f) + } + /// [`Self::reshape_area`] for the area frame or type on a path's path. + fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { + let det = self.xf.determinant(); + if !det.is_finite() || det.abs() < 1e-12 { + return false; + } + let to_text = self.xf.inverse(); + let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false }; + let mut next = frame.clone(); + if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return false; + } + *frame = next; + true + } + /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so + /// `1 / scale` keeps their weight through a transform that scales the type). + pub fn scale_char_strokes(&mut self, s: f64) { + for r in &mut self.runs { + r.style.stroke_width *= s; + if let Some(d) = &mut r.style.stroke_dash { + d.scale(s); + } + } + } + /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run + /// gradient fits, as the renderers lay it out. + pub fn local_bounds(&self) -> Rect { + self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) + } + /// The baselines in text space (the layout cache): for point type with none cached, the first + /// one across [`Self::local_bounds`] (down the first column's centre line for vertical type). + pub fn baselines(&self) -> impl Iterator + '_ { + let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| { + let b = self.local_bounds(); + if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) } + }); + self.cached_baselines.iter().copied().chain(first) + } + /// Keeps every run's size, leading, baseline shift and scales in range + /// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab + /// stops ([`ParaStyle::keep_spacing_in_range`]). + pub fn keep_type_values_in_range(&mut self) { + for r in &mut self.runs { + r.style.keep_dimensions_in_range(); + } + for p in self.para_styles_mut() { + p.keep_spacing_in_range(); + } + } +} + +impl crate::Document { + /// Keeps the character dimensions, indents, paragraph spacing and tab stops in range + /// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph + /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts + /// back (opacity-mask art included), and the same attributes of the character and paragraph + /// styles. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn keep_type_values_in_range(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.keep_type_values_in_range(); + } + }; + 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 { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + // Styles store the attributes they set by their serialized names (a number, or null for + // Auto leading); a number is rewritten only when it changes. + for def in self.char_styles.iter_mut() { + let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64); + let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() }; + st.size = num("size").unwrap_or(st.size); + 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.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)), + ("leading", st.leading), + ]; + for (k, v) in kept { + if let Some(old) = def.attrs.get_mut(k) + && old.is_number() + && old.as_f64() != v + { + *old = v.into(); + } + } + } + for def in self.para_styles.iter_mut() { + for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] { + if let Some(v) = def.attrs.get_mut(k) + && let Some(x) = v.as_f64() + && x.abs() > crate::MAX_COORD + { + *v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into(); + } + } + for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() { + if let Some(v) = t.get_mut("position") + && let Some(x) = v.as_f64() + && !(0.0..=100_000.0).contains(&x) + { + *v = x.clamp(0.0, 100_000.0).into(); + } + } + } + } + + /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches + /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art + /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry + /// ranges from outside: a range past the text, an inverted one or an empty reading would lay + /// out nothing. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn fix_ruby(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.normalize_ruby(); + } + }; + 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 { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + } +} diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs new file mode 100644 index 000000000..6a01a1815 --- /dev/null +++ b/crates/doc/src/text/para_style.rs @@ -0,0 +1,517 @@ +use super::char_style::CharStyle; +use super::tabs_lists::{ParaList, TabStop}; +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Justify { + /// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for + /// left-to-right paragraphs, right for right-to-left ones. New type's alignment. + Auto, + #[default] + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, +} + +/// Paragraph composer (Paragraph panel menu): how lines are broken. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Composer { + /// Break each line as soon as it is full. + SingleLine, + /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word + /// spacing, ragged lines an even rag. + #[default] + EveryLine, +} + +impl Composer { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + +/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic +/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from +/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen +/// limit, no hyphenation zone, and capitalized words hyphenated. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct HyphenationOptions { + /// Words Longer Than: only words with more letters than this are hyphenated. + pub words_longer_than: u32, + /// After First: the fewest letters a word keeps before the hyphen. + pub after_first: u32, + /// Before Last: the fewest letters a word keeps after the hyphen. + pub before_last: u32, + /// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen; + /// 0 sets no limit. + pub hyphen_limit: u32, + /// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next + /// word already ends within this distance of its right edge breaks before that word instead + /// of hyphenating it. 0 allows every hyphenation. + pub zone: f64, + /// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not + /// hyphenated. Words in capitals only are never hyphenated. + pub capitalized: bool, +} + +impl Default for HyphenationOptions { + fn default() -> Self { + Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true } + } +} + +/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SingleWordJustify { + /// Spread across the line, between its letters. + #[default] + FullJustify, + AlignLeft, + AlignCenter, + AlignRight, +} + +/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent. +/// +/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired +/// applies to every line of the paragraph, justified or not: word spaces are that share of the +/// font's word space, each letter is followed by that share of a word space, and glyphs are that +/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only +/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph +/// scaling 100/100/100, Auto Leading 120 and Full Justify. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct JustificationOptions { + /// Word Spacing, percent of the font's word space (0..1000). + pub word_spacing: [f64; 3], + /// Letter Spacing, percent of the font's word space added after each letter (-100..500). + pub letter_spacing: [f64; 3], + /// Glyph Scaling, percent of the glyphs' width (50..200). + pub glyph_scaling: [f64; 3], + /// Auto Leading: the leading of characters whose leading is Auto, in percent of their size + /// (0..500). + pub auto_leading: f64, + /// Single Word Justification: how a justified line holding one word is set. + pub single_word: SingleWordJustify, +} + +impl JustificationOptions { + /// Word Spacing's range (percent). + pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0); + /// Letter Spacing's range (percent). + pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0); + /// Glyph Scaling's range (percent). + pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0); + /// Auto Leading's range (percent). + pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0); + + /// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change + /// in a line's natural widths)? + pub fn desired_is_default(&self) -> bool { + let d = Self::default(); + self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1] + } + + /// Keeps options read from a file to what `text.setFormat` can set: a value that is not a + /// finite number becomes its default, every value is clamped to its range, and a Minimum, + /// Desired and Maximum out of order go back to their defaults together. + pub fn keep_in_range(&mut self) { + let d = Self::default(); + for (v, dv, (lo, hi)) in [ + (&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING), + (&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING), + (&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING), + ] { + for (x, dx) in v.iter_mut().zip(dv) { + *x = if x.is_finite() { x.clamp(lo, hi) } else { dx }; + } + if !(v[0] <= v[1] && v[1] <= v[2]) { + *v = dv; + } + } + let (lo, hi) = Self::AUTO_LEADING; + self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading }; + } + + /// A paragraph's leading for characters of `size` whose leading is Auto. + pub fn auto_leading_for(&self, size: f64) -> f64 { + size * self.auto_leading / 100.0 + } +} + +impl Default for JustificationOptions { + fn default() -> Self { + Self { + word_spacing: [80.0, 100.0, 133.0], + letter_spacing: [0.0, 0.0, 0.0], + glyph_scaling: [100.0, 100.0, 100.0], + auto_leading: 120.0, + single_word: SingleWordJustify::FullJustify, + } + } +} + +/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of +/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair +/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter +/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font +/// draws them, at most an em long. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kashida { + /// No kashidas: justified lines widen their word spaces. + #[default] + None, + Short, + Medium, + Long, + Stylistic, +} + +impl Kashida { + /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. + pub fn max_em(self) -> f64 { + match self { + Kashida::None => 0.0, + Kashida::Short => 0.25, + Kashida::Medium => 0.5, + Kashida::Long | Kashida::Stylistic => 1.0, + } + } +} + +/// Paragraph attributes (the Paragraph panel). +/// +/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before +/// [`Justify::Auto`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(from = "ParaStyleFile", into = "ParaStyleFile")] +pub struct ParaStyle { + pub justify: Justify, + pub left_indent: f64, + pub right_indent: f64, + pub first_line_indent: f64, + pub space_before: f64, + pub space_after: f64, + pub hyphenate: bool, + /// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…). + pub hyphenation: HyphenationOptions, + /// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification + /// (Paragraph panel menu › Justification…). + pub justification: JustificationOptions, + /// Line breaking: Single-line or Every-line Composer. + pub composer: Composer, + /// Tab stops (Tabs panel), sorted by position. + pub tabs: Vec, + /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not + /// in a list. + pub list: Option, + /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. + pub style_name: Option, + /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made + /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before + /// it and imported text (already set) keep [`Mojikumi::None`]. + pub mojikumi: Mojikumi, + /// Paragraph direction (Paragraph panel): the base direction of each paragraph for + /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or + /// Arabic: right to left). + pub direction: Option, + /// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top + /// of one line's ideographic em box to the next. + pub leading_model: LeadingModel, + /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line + /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before + /// it and imported text keep [`Burasagari::None`]. + pub burasagari: Burasagari, + /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a + /// line. + pub kinsoku: Kinsoku, + /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and + /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, + /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. + pub roman_hanging: bool, + /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with + /// kashidas before they widen their word spaces. + pub kashida: Kashida, +} + +impl ParaStyle { + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to + /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range + /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is + /// not a number becomes 0. + pub fn keep_spacing_in_range(&mut self) { + let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) }; + for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] { + *v = within(*v, -crate::MAX_COORD, crate::MAX_COORD); + } + for t in &mut self.tabs { + t.position = within(t.position, 0.0, 100_000.0); + } + } + + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own + /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their + /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is + /// not a finite number). + pub fn effective_leading(&self, st: &CharStyle) -> f64 { + let auto = self.justification.auto_leading; + if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { + return st.effective_leading(); + } + let l = self.justification.auto_leading_for(st.size); + if l.is_finite() { l } else { st.effective_leading() } + } +} + +/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph +/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto +/// read the alignment and ignore the flag, and builds with it read Auto back. +#[derive(Serialize, Deserialize)] +struct ParaStyleFile { + #[serde(default)] + justify: Justify, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + justify_auto: bool, + #[serde(default)] + left_indent: f64, + #[serde(default)] + right_indent: f64, + #[serde(default)] + first_line_indent: f64, + #[serde(default)] + space_before: f64, + #[serde(default)] + space_after: f64, + #[serde(default)] + hyphenate: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + hyphenation: HyphenationOptions, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + justification: JustificationOptions, + #[serde(default, skip_serializing_if = "Composer::is_default")] + composer: Composer, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tabs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + list: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + style_name: Option, + #[serde(default, skip_serializing_if = "Mojikumi::is_none")] + mojikumi: Mojikumi, + #[serde(default, skip_serializing_if = "Option::is_none")] + direction: Option, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + leading_model: LeadingModel, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + burasagari: Burasagari, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kinsoku: Kinsoku, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + roman_hanging: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kashida: Kashida, +} + +impl From for ParaStyleFile { + fn from(p: ParaStyle) -> Self { + let justify_auto = p.justify == Justify::Auto; + let justify = match p.justify { + Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right, + Justify::Auto => Justify::Left, + j => j, + }; + let ParaStyle { + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + justification, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + .. + } = p; + Self { + justify, + justify_auto, + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + justification, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + } + } +} + +impl From for ParaStyle { + fn from(f: ParaStyleFile) -> Self { + let justify = if f.justify_auto { Justify::Auto } else { f.justify }; + let mut justification = f.justification; + justification.keep_in_range(); + let ParaStyleFile { + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + .. + } = f; + Self { + justify, + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + justification, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + } + } +} + +/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set +/// in ([`CharStyle::char_direction`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ParaDirection { + LeftToRight, + RightToLeft, +} + +/// How a paragraph's leading is measured. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LeadingModel { + /// From one line's baseline to the next's: a line's leading is the space above it, and area + /// type's first baseline follows Area Type Options › First Baseline. + #[default] + RomanBaseline, + /// From the top of one line's ideographic em box (the right side, in vertical type) to the + /// next's: a line's leading is the space below it, and area type's first line touches the top + /// of the frame. Japanese layout's usual model. + EmBoxTop, +} + +/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half +/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's +/// after it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mojikumi { + /// Every character takes its full advance. + #[default] + None, + /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, + /// comma or full stop ending a line is set half width. + LineEndHalf, +} + +impl Mojikumi { + pub fn is_none(&self) -> bool { + *self == Mojikumi::None + } +} + +/// Which Japanese characters may not start or end a line (kinsoku shori). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kinsoku { + /// No kinsoku: a line may break between any two characters that allow a break. + None, + /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small + /// kana and the like don't start a line; opening brackets don't end one. + #[default] + Hard, + /// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line + /// (JLREQ's level 3 line-breaking rules, Appendix C.3). + Soft, +} + +/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) +/// stands outside the line's measure, in the space its punctuation spacing gives it (half width +/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown +/// as None / Regular / Force. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Burasagari { + /// A comma or full stop that doesn't fit goes to the next line with the character before it. + #[default] + None, + /// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside. + Standard, + /// A comma or full stop ending a line always hangs, and the rest of the line fills the measure. + Forced, +} + +/// Area Type Options "First Baseline" offset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FirstBaseline { + /// The tallest glyph ascent touches the frame top (Illustrator's default). + #[default] + Ascent, + CapHeight, + XHeight, + /// The first line's leading. + Leading, + /// Exactly `first_baseline_min` below the top. + Fixed, +} diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs new file mode 100644 index 000000000..4e3de9338 --- /dev/null +++ b/crates/doc/src/text/runs.rs @@ -0,0 +1,207 @@ +use super::char_style::CharStyle; +use super::*; + +/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or +/// [`ParaStyle`] fields, by their serialized names). Text using it records the name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextStyleDef { + pub name: String, + #[serde(default)] + pub attrs: serde_json::Map, +} + +/// A run of text sharing one character style. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextRun { + pub text: String, + pub style: CharStyle, + /// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows + /// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets + /// the size and tracking; fills and strokes don't apply to the art. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inline: Option, +} + +impl TextRun { + /// A plain text run. + pub fn new(text: impl Into, style: CharStyle) -> Self { + Self { text: text.into(), style, inline: None } + } + /// An inline graphic run showing `art`. + pub fn inline(art: InlineArt, style: CharStyle) -> Self { + Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) } + } +} + +/// The object replacement character: the plain text of an inline graphic run. +pub const INLINE_CHAR: char = '\u{FFFC}'; + +/// Largest inline graphic scale (× the run's size) accepted from input. +pub const INLINE_MAX_SCALE: f64 = 100.0; + +/// A document symbol placed inline in text ([`TextRun::inline`]). +/// +/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's +/// font size, its left edge at the pen position, and its vertical centre on the middle of the +/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap +/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at +/// the default scale, stays inside the font's ascent and descent so the leading doesn't change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineArt { + /// Name of the document symbol ([`crate::Symbol::name`]). + pub symbol: String, + /// Art height as a multiple of the run's font size. + #[serde(default = "one")] + pub scale: f64, + /// Extra raise in points (positive = up). + #[serde(default)] + pub baseline_shift: f64, + /// The symbol art's visual bounds at its natural size, resolved from the document's symbols by + /// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the + /// layout then reserves a one-em square and nothing is drawn. + #[serde(skip)] + pub bounds: Option, +} + +fn one() -> f64 { + 1.0 +} + +fn fifty() -> f64 { + 50.0 +} + +impl InlineArt { + pub fn new(symbol: impl Into) -> Self { + Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } + } + /// `scale`, made finite and positive (untrusted input). + pub fn safe_scale(&self) -> f64 { + if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 } + } + /// `baseline_shift`, made finite (untrusted input). + pub fn safe_shift(&self) -> f64 { + if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum TextKind { + /// Point type: anchored at the baseline origin of the first line. + Point, + /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). + Area { frame: PathData }, + /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the + /// path length; no `end`: the end of the path, or once round a closed path). See + /// [`TextKind::path_span`]. + OnPath { + path: PathData, + start: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + end: Option, + }, +} + +impl TextKind { + /// Type on a path's span: from its start to its end bracket, as fractions of its path's length. + /// Round a closed path the end may be past 1 (the span runs on past the path's start), a full + /// turn without an end or with the end at the start. None for other type. + pub fn path_span(&self) -> Option<(f64, f64)> { + let TextKind::OnPath { path, start, end } = self else { return None }; + let s = fraction(*start); + Some(if path.is_closed() { + let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0)); + (s, s + if run < 1e-9 { 1.0 } else { run }) + } else { + (s, end.map_or(1.0, |e| fraction(e).max(s))) + }) + } +} + +/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the +/// document → text map). False when an anchor doesn't exist. +pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { + // A distance: only the linear part of the document → text map applies. + let local = to_text * d.to_point() - to_text * Point::ZERO; + refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) +} + +/// `x` as a fraction of a path's length (0..1; 0 when not finite). +fn fraction(x: f64) -> f64 { + if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 } +} + +/// `path` run the other way, each point as far from its new start as it was from its old end +/// (the subpaths in reverse order, a closed one keeping its first anchor first). +pub(super) fn reverse_from_end(path: &mut PathData) { + path.subpaths.reverse(); + for sp in &mut path.subpaths { + sp.reverse(); + if sp.closed && !sp.anchors.is_empty() { + sp.anchors.rotate_right(1); + } + } +} + +/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in +/// vertical type (the Japanese convention), or below (left in vertical type). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyPosition { + #[default] + Above, + Below, +} + +/// How ruby text lines up over its base text when the two are different widths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyAlign { + /// Centred over the base text. + #[default] + Center, + /// Starting at the base text's start (left in horizontal type, top in vertical). + Start, + /// Ending at the base text's end (right in horizontal type, bottom in vertical). + End, +} + +/// Ruby (furigana, phonetic guide): a reading set over a range of the text. +/// +/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as +/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; +/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and +/// allowed to overhang neighbouring characters when the reading is longer than its base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RubySpan { + /// Byte offset of the first base character in the plain text. + pub start: usize, + /// Byte offset just past the last base character. + pub end: usize, + /// The reading, set in the base characters' font (its size comes from `size_percent`). + pub text: String, + #[serde(default)] + pub position: RubyPosition, + #[serde(default)] + pub align: RubyAlign, + /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). + #[serde(default = "fifty")] + pub size_percent: f64, + /// Let a reading wider than its base hang over the neighbouring characters instead of being + /// squeezed to the base's width. + #[serde(default = "yes")] + pub overhang: bool, +} + +impl RubySpan { + /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). + pub fn new(start: usize, end: usize, text: impl Into) -> Self { + Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } + } + /// `size_percent` made finite and usable: 1–100% (untrusted input). + pub fn safe_size_percent(&self) -> f64 { + if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } + } +} diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs new file mode 100644 index 000000000..763a82c69 --- /dev/null +++ b/crates/doc/src/text/tabs_lists.rs @@ -0,0 +1,249 @@ +use super::char_style::{CharPosition, CharStyle}; +use super::*; + +/// Tab stop alignment (Tabs panel). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TabAlign { + #[default] + Left, + Center, + Right, + /// Aligns on the first `align_on` character (a decimal point by default). + Decimal, +} + +/// A tab stop, measured from the left edge of the text (area type: the frame's left edge). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + pub position: f64, + #[serde(default)] + pub align: TabAlign, + /// Leader characters repeated across the tab's gap (e.g. ". "). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub leader: String, + /// Decimal tabs align on this character. + #[serde(default = "default_align_on")] + pub align_on: char, +} + +fn default_align_on() -> char { + '.' +} + +/// Distance between default tab stops when no explicit stop applies (½ inch). +pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; + +/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Bullet { + /// A filled dot (•). + #[default] + Dot, + /// A dash (–). + Dash, + /// A hollow circle (◦). + Circle, + /// A filled square (▪). + Square, +} + +impl Bullet { + /// The character the layout draws for the bullet. + pub fn character(self) -> char { + match self { + Bullet::Dot => '\u{2022}', + Bullet::Dash => '\u{2013}', + Bullet::Circle => '\u{25E6}', + Bullet::Square => '\u{25AA}', + } + } +} + +/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered +/// List presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumberStyle { + /// 1, 2, 3… + #[default] + Arabic, + /// A, B, C… Z, AA, BB… + UpperLetters, + /// a, b, c… z, aa, bb… + LowerLetters, + /// I, II, III, IV… + UpperRoman, + /// i, ii, iii, iv… + LowerRoman, + /// 01, 02… 99, 100… + TwoDigits, + /// 001, 002… 999, 1000… + ThreeDigits, +} + +/// Past this number, roman numerals and letters are written in Arabic digits. +const MAX_LETTERS_OR_ROMAN: u32 = 3999; + +impl NumberStyle { + /// Every style, in the order the Numbered List presets list them. + pub const ALL: [NumberStyle; 7] = [ + NumberStyle::Arabic, + NumberStyle::UpperLetters, + NumberStyle::LowerLetters, + NumberStyle::UpperRoman, + NumberStyle::LowerRoman, + NumberStyle::TwoDigits, + NumberStyle::ThreeDigits, + ]; + + /// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then + /// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD, + /// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and + /// keep every digit of a longer number. + pub fn format(self, n: u32) -> String { + let letters_or_roman = + matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman); + if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) { + return n.to_string(); + } + match self { + NumberStyle::Arabic => n.to_string(), + NumberStyle::TwoDigits => format!("{n:02}"), + NumberStyle::ThreeDigits => format!("{n:03}"), + NumberStyle::UpperLetters => letters(n), + NumberStyle::LowerLetters => letters(n).to_lowercase(), + NumberStyle::UpperRoman => roman(n), + NumberStyle::LowerRoman => roman(n).to_lowercase(), + } + } +} + +/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on. +fn letters(n: u32) -> String { + let n = n - 1; + let letter = char::from(b'A' + (n % 26) as u8); + std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect() +} + +/// `n` (1 to 3999) in capital roman numerals. +fn roman(mut n: u32) -> String { + const DIGITS: [(u32, &str); 13] = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + let mut s = String::new(); + for (value, digits) in DIGITS { + while n >= value { + s.push_str(digits); + n -= value; + } + } + s +} + +/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the +/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's +/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual +/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after +/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a +/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves +/// out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaList { + /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for + /// when the list is made bulleted again. + pub bullet: Bullet, + /// A numbered list's number style. A bulleted list has none, and the file saves it only for a + /// numbered list. + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option, +} + +impl ParaList { + /// A bulleted list with `bullet`. + pub fn bulleted(bullet: Bullet) -> Self { + Self { bullet, number: None } + } + + /// A numbered list with `style`. + pub fn numbered(style: NumberStyle) -> Self { + Self { bullet: Bullet::default(), number: Some(style) } + } + + /// The characters of the marker the layout draws before the paragraph: the bullet, or for a + /// numbered list number `n` in its style followed by a period. + pub fn marker(&self, n: u32) -> String { + match self.number { + Some(style) => format!("{}.", style.format(n)), + None => self.bullet.character().to_string(), + } + } + + /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and + /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. + /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has + /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the + /// count as it is; a numbered paragraph adds one to the count and is numbered with it. + pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { + let Some(list) = list else { + *count = 0; + return None; + }; + if empty { + return None; + } + if list.number.is_some() { + *count = count.saturating_add(1); + } + Some(list.marker(*count)) + } + + /// The style the marker is set in: `first` (the style of the paragraph's first character) + /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, + /// tate-chu-yoko, underline and strikethrough. + pub fn marker_style(first: &CharStyle) -> CharStyle { + CharStyle { + baseline_shift: 0.0, + rotation: 0.0, + position: CharPosition::Normal, + tracking: 0.0, + kerning: None, + tate_chu_yoko: false, + underline: false, + strikethrough: false, + ..first.clone() + } + } + + /// The list that the Type tool starts when `typed` and a space are typed at the start of a + /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, + /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts + /// none. + pub fn typed(typed: &str) -> Option { + match typed { + "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), + "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), + "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), + _ => None, + } + } +} + +/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the +/// marker hangs in the first line's 18 pt before the text. +pub const LIST_HANGING_INDENT: f64 = 18.0; diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs new file mode 100644 index 000000000..54142efd7 --- /dev/null +++ b/crates/doc/src/text/tests.rs @@ -0,0 +1,335 @@ +use super::*; + +/// Character Direction is saved only when set, and styles written before it existed read as +/// Default. +#[test] +fn character_direction_is_saved_only_when_set() { + let st = CharStyle::default(); + assert_eq!(st.char_direction, None); + assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); + for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { + let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); +} + +/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. +#[test] +fn ruby_is_saved_and_missing_ruby_reads_as_none() { + let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); + t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains("かんじ")); + let back: TextObject = serde_json::from_str(&json).unwrap(); + assert_eq!(back.ruby, t.ruby); + assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); + // Old files have no `ruby` key: none. + let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); + old.as_object_mut().unwrap().remove("ruby"); + assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); + // A damaged file's range past the text and its empty reading are clamped away. + let mut damaged = t.clone(); + damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; + damaged.normalize_ruby(); + assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); +} + +/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and +/// are saved only when set. +#[test] +fn digits_map_between_the_numeral_systems() { + let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; + for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { + assert_eq!(map(Digits::Default, s), s); + assert_eq!(map(Digits::Arabic, s), "0123456789"); + assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩"); + assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹"); + } + // Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets. + assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔"); + let st = CharStyle::default(); + assert!(serde_json::to_value(&st).unwrap().get("digits").is_none()); + let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi"); + // Styles written before the attribute existed (no `digits` key) read as Default. + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().digits, Digits::Default); + assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); +} + +/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. +#[test] +fn paragraph_effective_leading_follows_auto_leading() { + let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() }; + let mut para = ParaStyle::default(); + assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%"); + para.justification.auto_leading = 200.0; + assert_eq!(para.effective_leading(&st), 20.0); + para.justification.auto_leading = 0.0; + assert_eq!(para.effective_leading(&st), 0.0); + let set = CharStyle { leading: Some(14.0), ..st.clone() }; + assert_eq!(para.effective_leading(&set), 14.0); + para.justification.auto_leading = f64::NAN; + assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number"); +} + +/// A file can't feed layout justification values `text.setFormat` would refuse: each is +/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and +/// Maximum out of order go back to their defaults. +#[test] +fn justification_options_read_from_a_file_stay_in_range() { + let d = JustificationOptions::default(); + let read: ParaStyle = serde_json::from_value(serde_json::json!({ + "justification": { + "word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0], + "glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12 + } + })) + .unwrap(); + let j = read.justification; + assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]); + assert_eq!(j.letter_spacing, d.letter_spacing, "out of order"); + assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]); + assert_eq!(j.auto_leading, 0.0); + let mut j = JustificationOptions { + word_spacing: [f64::NAN, 100.0, f64::INFINITY], + letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY], + auto_leading: f64::NAN, + ..d.clone() + }; + j.keep_in_range(); + assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading)); + let mut same = d.clone(); + same.keep_in_range(); + assert_eq!(same, d); +} + +/// Numbered lists: each number style writes its numbers and rolls over (letters after Z, +/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds +/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and +/// starts again after a paragraph in no list. A list from before number styles reads as +/// bulleted, and a bulleted list saves no number style. +#[test] +fn number_styles_write_their_numbers_and_roll_over() { + let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::>(); + assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]); + assert_eq!( + numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]), + ["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"] + ); + assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]); + assert_eq!( + numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]), + ["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"] + ); + assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]); + assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]); + assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]); + assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); + assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); + let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); + let mut count = 0; + let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] + .iter() + .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) + .collect(); + assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); + assert_eq!(count, 1); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); + assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); + assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); + assert_eq!( + serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(), + serde_json::json!({"bullet": "dot", "number": "lowerRoman"}) + ); +} + +/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph +/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the +/// filled dot. Other text, the same characters later in a paragraph or without the space, and +/// a paragraph already in a list start nothing. +#[test] +fn typed_list_starts_are_found_at_a_paragraphs_start() { + let (arabic, letters, dot) = + (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); + let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed); + assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some)); + for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] { + assert_eq!(ParaList::typed(other), None, "{other:?}"); + } + let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default()); + assert_eq!(t.typed_list_at(9), Some((6, arabic))); + assert_eq!(t.typed_list_at(19), Some((16, letters))); + assert_eq!(t.typed_list_at(27), Some((25, dot))); + // Not at the paragraph's start, not right after the space, or past the end. + for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] { + assert_eq!(t.typed_list_at(at), None, "{at}"); + } + let mut styles = t.paragraph_styles(); + styles[1].list = Some(ParaList::bulleted(Bullet::Square)); + t.set_paragraph_styles(styles); + assert_eq!(t.typed_list_at(9), None, "a list paragraph stays as it is"); + assert_eq!(t.typed_list_at(19), Some((16, letters))); +} + +#[test] +fn character_controls_reject_collapsed_and_nonfinite_transforms() { + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { + t.xf = Affine::new(matrix); + assert!(t.effective_char_style(&t.first_style()).is_none()); + } + t.xf = Affine::rotate(0.83); + assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); + t.xf = Affine::scale(1e308); + assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); +} + +#[test] +fn point_text_basics() { + let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); + assert_eq!(t.plain_text(), "Hello"); + let b = t.bounds().unwrap(); + assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); + assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); +} + +fn justified(j: Justify) -> ParaStyle { + ParaStyle { justify: j, ..ParaStyle::default() } +} + +#[test] +fn paragraph_styles_split_merge_and_normalize() { + let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); + assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); + assert_eq!(t.paragraphs_in(0, 0), 0..1); + assert_eq!(t.paragraphs_in(4, 4), 1..2); + assert_eq!(t.paragraphs_in(2, 9), 0..3); + assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); + t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); + let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); + // Return inside "two": the new paragraph continues its style. + t.splice_paras(5, 5, "\n"); + t.runs[0].text.insert(5, '\n'); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); + // Deleting the break between "one" and "t": the first paragraph's style wins. + t.splice_paras(3, 4, ""); + t.runs[0].text.remove(3); + assert_eq!(t.plain_text(), "onet\nwo\nthree"); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); + // An edit that skips the splice is repaired by normalizing (the last style continues). + t.runs[0].text.push_str("\nfour"); + t.normalize_paras(); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); + // `para` is paragraph 0's; all alike collapses to `para` alone. + t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); + assert_eq!(t.para.justify, Justify::Right); + t.edit_paras(None, |p| p.justify = Justify::Right); + assert!(t.paras.is_empty() && t.para.justify == Justify::Right); + // A short list repeats its last entry, a long one is cut. + t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); + t.set_paragraph_styles(vec![justified(Justify::Center); 9]); + assert!(t.paras.is_empty() && t.para.justify == Justify::Center); + // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. + t.edit_paras(Some(3..4), |p| p.space_before = 4.0); + let json = serde_json::to_value(&t).unwrap(); + assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); + let mut old = json.clone(); + old.as_object_mut().unwrap().remove("paras"); + let back: TextObject = serde_json::from_value(old).unwrap(); + assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); + let back: TextObject = serde_json::from_value(json).unwrap(); + assert_eq!(back.para_at(3).space_before, 4.0); +} + +/// 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()); + t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; + t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); + t +} + +#[test] +fn area_resizes_its_frame_and_keeps_its_type() { + let mut t = area_type(); + assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); + // Bottom-right handle to (200, 140), about the top-left corner. + let o = Affine::translate((40.0, 40.0)); + assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); + assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); + let b = t.bounds().unwrap(); + assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); + assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); + // Point type has no area; a degenerate transform or a non-finite frame changes nothing. + let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); + assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); + let before = t.clone(); + assert!(!t.transform_area(Affine::scale(f64::INFINITY))); + t.xf = Affine::scale(0.0); + assert!(!t.transform_area(Affine::scale(2.0))); + t.xf = before.xf; + assert_eq!(t, before); +} + +#[test] +fn area_anchors_move_in_document_space() { + let mut t = area_type(); + // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. + assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); + let TextKind::Area { frame } = &t.kind else { panic!() }; + assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); + assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); + // A missing anchor changes nothing. + let before = t.clone(); + assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); + assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); + assert_eq!(t, before); +} + +/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore +/// `justify_auto`); this build reads Auto back. +#[test] +fn auto_alignment_saves_readable_by_older_builds() { + #[derive(Deserialize, Debug, PartialEq)] + enum OldJustify { + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, + } + #[derive(Deserialize)] + struct OldPara { + justify: OldJustify, + } + #[derive(Deserialize)] + struct OldText { + para: OldPara, + } + for (direction, physical) in + [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] + { + let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); + t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; + let json = serde_json::to_string(&t).unwrap(); + let old: OldText = serde_json::from_str(&json).unwrap(); + assert_eq!(old.para.justify, physical, "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), t); + } + // Other alignments save as before, without the flag. + let p = ParaStyle { justify: Justify::Right, ..Default::default() }; + let json = serde_json::to_string(&p).unwrap(); + assert!(!json.contains("justify_auto"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), p); + // Files from before the flag keep their alignment. + assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); +} diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs new file mode 100644 index 000000000..1b09c4e7b --- /dev/null +++ b/crates/doc/src/units.rs @@ -0,0 +1,293 @@ +use super::*; + +/// Measurement units (display only; the model is always in points). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + Points, + Picas, + Inches, + Millimeters, + Centimeters, + Pixels, + FeetInches, + Meters, + Yards, + Feet, +} + +impl Unit { + pub const ALL: [Unit; 10] = [ + Unit::Points, + Unit::Picas, + Unit::Inches, + Unit::Millimeters, + Unit::Centimeters, + Unit::Pixels, + Unit::FeetInches, + Unit::Meters, + Unit::Yards, + Unit::Feet, + ]; + /// Points per unit. + pub fn points(self) -> f64 { + match self { + Unit::Points | Unit::Pixels => 1.0, + Unit::Picas => 12.0, + Unit::Inches => 72.0, + Unit::Millimeters => 72.0 / 25.4, + Unit::Centimeters => 72.0 / 2.54, + Unit::Meters => 72.0 / 0.0254, + Unit::Yards => 72.0 * 36.0, + Unit::Feet | Unit::FeetInches => 72.0 * 12.0, + } + } + pub fn suffix(self) -> &'static str { + match self { + Unit::Points => "pt", + Unit::Picas => "p", + Unit::Inches => "in", + Unit::Millimeters => "mm", + Unit::Centimeters => "cm", + Unit::Pixels => "px", + Unit::FeetInches | Unit::Feet => "ft", + Unit::Meters => "m", + Unit::Yards => "yd", + } + } + pub fn label(self) -> &'static str { + match self { + Unit::Points => "Points", + Unit::Picas => "Picas", + Unit::Inches => "Inches", + Unit::Millimeters => "Millimeters", + Unit::Centimeters => "Centimeters", + Unit::Pixels => "Pixels", + Unit::FeetInches => "Feet & Inches", + Unit::Meters => "Meters", + Unit::Yards => "Yards", + Unit::Feet => "Feet", + } + } + /// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of + /// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it. + pub fn ruler_step(self, zoom: f64) -> f64 { + const DECIMAL: [f64; 19] = + [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; + // An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler + // carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a + // sixteenth; zooming in past that leaves the labels where they are and only the ticks + // between them grow denser, down to a hundred and twenty-eighth + // ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which + // the left ruler has room for when it spells one down the ruler a character at a time. + // From an inch up the ladder is the shared one. + const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0]; + let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL }; + steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0) + } + /// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric + /// units, points, pixels and picas are read in tenths. + fn ruler_halves(self) -> bool { + matches!(self, Unit::Inches) + } + /// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of + /// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about + /// as dense as the tenths elsewhere; a tenth of it otherwise. + pub fn ruler_minor(self, step: f64) -> f64 { + if self.ruler_halves() { step / 8.0 } else { step / 10.0 } + } + /// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]). + pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 { + let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points(); + (v / tick).round() * tick + } + pub fn from_pt(self, v: f64) -> f64 { + v / self.points() + } + pub fn to_pt(self, v: f64) -> f64 { + v * self.points() + } + /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). + pub fn format(self, pt: f64) -> String { + format!("{} {}", self.number(pt), self.suffix()) + } + /// [`Unit::format`] without the suffix (`12.5`), for narrow fields. + 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 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. + /// + /// 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 + /// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away + /// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A + /// percentage on its own (`50%`) reads as nothing. + pub fn parse(self, s: &str) -> Option { + parse_measure(s, self).filter(|v| v.is_finite()) + } + /// The value naming this unit in the Units preferences (`points`, `millimeters`, + /// `feetInches`). + pub fn key(self) -> &'static str { + match self { + Unit::Points => "points", + Unit::Picas => "picas", + Unit::Inches => "inches", + Unit::Millimeters => "millimeters", + Unit::Centimeters => "centimeters", + Unit::Pixels => "pixels", + Unit::FeetInches => "feetInches", + Unit::Meters => "meters", + Unit::Yards => "yards", + Unit::Feet => "feet", + } + } + /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and + /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). + pub fn named(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()))) + } + /// 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()) + } +} + +fn parse_measure(s: &str, default: Unit) -> Option { + let s = s.trim(); + // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). + // `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last + // of its operators (left to right). + for ops in [&['+', '-'][..], &['*', '/'][..]] { + // Past the first character, so a leading sign isn't an operator. + let skip = s.chars().next().map_or(0, char::len_utf8); + // A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`). + let is_sign = |i: usize| { + s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-')) + && s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c)) + }; + let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i)); + if let Some(i) = found.map(|(i, _)| i) { + let (l, r) = (&s[..i], &s[i + 1..]); + if l.trim().is_empty() { + continue; + } + let Some(op) = s[i..].chars().next() else { continue }; + return match op { + '+' | '-' => { + let a = parse_measure(l, default)?; + // A percentage added or taken away is a share of everything before it: + // `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt. + let b = match percent(r) { + Some(k) => a * k, + None => parse_measure(r, default)?, + }; + Some(if op == '+' { a + b } else { a - b }) + } + _ => { + // A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm); + // a unit after it is the expression's. + let (k, unit) = match percent(r) { + Some(k) => (k, None), + None => number_unit(r)?, + }; + let a = parse_measure(l, unit.unwrap_or(default))?; + if op == '*' { + Some(a * k) + } else if k == 0.0 { + None + } else { + Some(a / k) + } + } + }; + } + } + // Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`). + let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::().is_ok(); + if let Some((p, pt)) = s.split_once('p') + && !p.is_empty() + && plain(p) + && (pt.is_empty() || plain(pt)) + && !s.ends_with("pt") + && !s.ends_with("px") + { + // The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points). + let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 }; + let picas = p.trim().parse::().ok()?.abs(); + return Some(sign * (picas * 12.0 + pt.trim().parse::().unwrap_or(0.0))); + } + let (v, unit) = number_unit(s)?; + Some(unit.unwrap_or(default).to_pt(v)) +} + +/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). +fn number_unit(s: &str) -> Option<(f64, Option)> { + let s = s.trim(); + // A leading `+` is a sign (`+5`), like `-`. + let sign = usize::from(s.starts_with('+')); + let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign); + let v: f64 = s.get(..num_end)?.trim().parse().ok()?; + let unit = match s.get(num_end..)?.trim() { + "" => None, + "pt" => Some(Unit::Points), + "px" => Some(Unit::Pixels), + "in" | "\"" => Some(Unit::Inches), + "mm" => Some(Unit::Millimeters), + "cm" => Some(Unit::Centimeters), + "m" => Some(Unit::Meters), + "ft" | "'" => Some(Unit::Feet), + "yd" => Some(Unit::Yards), + "p" | "pc" => Some(Unit::Picas), + _ => return None, + }; + Some((v, unit)) +} + +/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`). +fn percent(s: &str) -> Option { + match number_unit(s.trim().strip_suffix('%')?)? { + (p, None) => Some(p / 100.0), + _ => None, + } +} + +/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as +/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing. +pub fn parse_number(s: &str) -> Option { + if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) { + return None; + } + // A leading `+` is a sign (`+5`), as a plain number read it. + let s = s.trim(); + Unit::Points.parse(s.strip_prefix('+').unwrap_or(s)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ColorMode { + #[default] + Rgb, + Cmyk, +} + +impl ColorMode { + /// The colour model new colours take in a document of this mode. + pub fn model(self) -> vectorcraft_color::cms::Model { + match self { + ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, + ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, + } + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 496946e30..4e07df30c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -597,485 +597,4 @@ pub fn bake_document(doc: &Document) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use vectorcraft_color::{Color, Paint}; - use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; - use vectorcraft_geom::{Point, Rect, shapes}; - - /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. - fn skip_without_craft_fonts() -> bool { - let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); - if skip { - eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); - } - skip - } - - fn fx(id: &str, params: serde_json::Value) -> Effect { - Effect { id: id.into(), params, visible: true } - } - - fn doc_with(n: Node) -> Document { - let mut d = Document::new(400.0, 400.0); - let l = d.layers[0].id; - d.insert(Some(l), 0, n).unwrap(); - d - } - - #[test] - fn plain_documents_are_not_copied() { - let mut d = Document::new(100.0, 100.0); - let id = d.alloc_id(); - let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())); - assert!(bake_document(&d).is_none()); - } - - #[test] - fn object_effects_become_geometry_and_raster_effects_stay() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0}))); - n.appearance.effects.push(fx("stylize.dropShadow", json!({}))); - let d = bake_document(&doc_with(n)).unwrap(); - let m = d.node(NodeId(50)).unwrap(); - let b = m.geometric_bounds().unwrap(); - assert!((b.width() - 120.0).abs() < 0.5, "{b:?}"); - assert_eq!(m.appearance.effects.len(), 1); - assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); - } - - #[test] - fn item_effects_split_into_one_path_per_item() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); - } - let d = bake_document(&doc_with(n)).unwrap(); - let m = d.node(NodeId(50)).unwrap(); - let ch = m.children().unwrap(); - assert_eq!(ch.len(), 2); - assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); - assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5); - assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50)); - } - - #[test] - fn a_baked_stroke_keeps_its_raster_effects() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); - s.effects.push(fx("stylize.dropShadow", json!({}))); - } - let m = bake_appearance(&n).unwrap(); - let ch = m.children().unwrap(); - let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; - assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - } - - #[test] - fn a_baked_stroke_leaves_dither_behind() { - // Strokes ignore dither, so the filled outline goes without it and looks as the stroke did. - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - let mut g = vectorcraft_color::GradientPaint::new(Default::default()); - g.gradient.dither = true; - s.paint = Paint::Gradient(Box::new(g)); - } - let m = bake_appearance(&n).unwrap(); - let ch = m.children().unwrap(); - let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; - let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; - assert!(!g.gradient.dither); - } - - #[test] - fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - s.width = 10.0; - s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); - s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; - s.opacity = 0.5; - } - let m = bake_appearance(&n).unwrap(); - let ch = m.children().unwrap(); - let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; - assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); - assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes"); - assert_eq!(ch[1].opacity, 0.5); - } - - /// 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() }; - Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))) - } - - /// The origins of the type objects in `d`, in paint order. - fn type_origins(d: &Document) -> Vec<(f64, f64)> { - let mut out = vec![]; - d.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - let c = t.xf.as_coeffs(); - out.push((c[4], c[5])); - } - }); - out - } - - /// A group of the type and a 10 pt square with `effects`. - fn type_group(effects: Vec) -> Node { - let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]); - g.appearance.effects = effects; - g - } - - #[test] - fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() { - if skip_without_craft_fonts() { - return; - } - for (params, origins, square) in [ - (json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)), - (json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)), - (json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)), - ] { - let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]); - let d = bake_document(&doc_with(g.clone())).unwrap(); - assert_eq!(type_origins(&d), origins, "{params}"); - let baked = d.node(NodeId(52)).unwrap(); - assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - // The square moves with the type, as one path holding its copies. - let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap(); - assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}"); - let mut ids = std::collections::HashSet::new(); - baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id)); - // The renderer and Expand Appearance still draw it as outlines. - let mut text = 0; - evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); - assert_eq!(text, 0, "{params}"); - } - // Stacked effects that only move: every copy of a copy. - let d = bake_document(&doc_with(type_group(vec![ - fx("distort.transform", json!({"moveH": 100, "copies": 1})), - fx("distort.transform", json!({"moveV": 100, "copies": 1})), - ]))) - .unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]); - } - - #[test] - fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() { - if skip_without_craft_fonts() { - return; - } - let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); - let mut hidden = type_node(55); - hidden.visible = false; - g.children_mut().unwrap().push(Arc::new(hidden)); - let d = bake_document(&doc_with(g)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - assert!(!d.node(NodeId(55)).unwrap().visible); - } - - #[test] - fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { - if skip_without_craft_fonts() { - return; - } - let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); - let mut shadowed = type_node(55); - shadowed.opacity = 0.5; - shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); - // Type that clips a group is outlined as the plain outlines it clips by. - let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true }); - g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]); - let d = bake_document(&doc_with(g)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - let o = d.node(NodeId(55)).unwrap(); - assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}"); - assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - // The outlines are copied with the type. - let b = o.geometric_bounds().unwrap(); - assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}"); - let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap(); - assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}"); - } - - #[test] - fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { - if skip_without_craft_fonts() { - return; - } - let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); - // The clipping path is a group with a fill of its own, holding type with an effect of its own. - let mut moved = type_node(60); - moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300}))); - let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]); - path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))); - let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true }); - g.children_mut().unwrap().push(Arc::new(clip)); - let d = bake_document(&doc_with(g)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. - let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); - } - - #[test] - fn type_with_its_own_transform_effect_copies_stays_type() { - if skip_without_craft_fonts() { - return; - } - let mut n = type_node(50); - n.opacity = 0.5; - n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))]; - let d = bake_document(&doc_with(n)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]); - // One group keeps the object's transparency and its other effects; the copies are plain. - let g = d.node(NodeId(50)).unwrap(); - assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5); - assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - for c in g.children().unwrap() { - assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}"); - } - // Hidden type stays hidden. - let mut n = type_node(50); - n.visible = false; - n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))]; - let d = bake_document(&doc_with(n)).unwrap(); - assert_eq!(type_origins(&d).len(), 2); - assert!(!d.node(NodeId(50)).unwrap().visible); - } - - #[test] - fn type_under_other_geometry_effects_is_still_outlined() { - for effects in [ - vec![fx("distort.transform", json!({"moveH": 30, "random": true}))], - vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))], - vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))], - vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))], - vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))], - // Copies that would reach past the effect's limit, which stops them early. - vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))], - // Copies that overlap, or land on each other. - vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))], - vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))], - ] { - let mut own = type_node(50); - own.appearance.effects = effects.clone(); - for n in [type_group(effects.clone()), own] { - let d = bake_document(&doc_with(n)).unwrap(); - assert!(type_origins(&d).is_empty(), "{effects:?}"); - } - } - } - - #[test] - fn type_whose_copies_paint_over_each_other_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; - assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2); - // A 20 pt stroke on the characters reaches the copy below. - let mut stroked = type_node(50); - if let NodeKind::Text(t) = &mut stroked.kind { - t.runs[0].style.stroke = Paint::solid(Color::BLACK); - t.runs[0].style.stroke_width = 20.0; - } - let mut g = type_group(moves); - g.children_mut().unwrap()[0] = Arc::new(stroked); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - } - - #[test] - fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; - let mut translucent = type_node(50); - translucent.opacity = 0.5; - let mut underlined = type_node(50); - if let NodeKind::Text(t) = &mut underlined.kind { - t.runs[0].style.underline = true; - } - let mut shadowed = type_node(50); - shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); - let mut gradient = type_node(50); - if let NodeKind::Text(t) = &mut gradient.kind { - t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); - } - let mut reshaped = type_node(50); - let mut fill = FillLayer::new(Paint::solid(Color::BLACK)); - fill.effects.push(fx("path.offsetPath", json!({"offset": 2}))); - reshaped.appearance.items.push(AppearanceItem::Fill(fill)); - // A stroke of the type object's own (beyond its characters'). - let mut stroked = type_node(50); - stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0))); - // Character strokes scale with type scaled by its transform, where the outlines keep theirs. - let mut scaled = type_node(50); - if let NodeKind::Text(t) = &mut scaled.kind { - t.runs[0].style.stroke = Paint::solid(Color::BLACK); - t.runs[0].style.stroke_width = 1.0; - t.xf *= Affine::scale(2.0); - } - let mut turned = scaled.clone(); - if let NodeKind::Text(t) = &mut turned.kind { - t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5); - } - let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))]; - for (t, kept) in [(turned, 2), (scaled, 0)] { - let mut g = type_group(far.clone()); - g.children_mut().unwrap()[0] = Arc::new(t); - assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept); - } - for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] { - let mut g = type_group(moves.clone()); - g.children_mut().unwrap()[0] = Arc::new(t); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - } - } - - #[test] - fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() { - let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))]; - let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); - let mut g = type_group(moves.clone()); - g.appearance.items.push(fill.clone()); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - // A group with a fill of its own inside the moved group. - let mut inner = type_group(vec![]); - inner.id = NodeId(53); - inner.appearance.items.push(fill); - let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); - outer.appearance.effects = moves; - assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); - } - - #[test] - fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() { - // The inner copy lands back on the original. - let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]); - inner.id = NodeId(53); - let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); - outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1}))); - assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); - } - - #[test] - fn type_whose_copies_hold_too_much_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let spaced = |spaces: usize| { - let mut n = type_node(50); - if let NodeKind::Text(t) = &mut n.kind { - t.runs[0].text = format!("a{}", " ".repeat(spaces)); - } - Arc::new(n) - }; - // 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000 - // bytes together, they are outlined. - let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))]; - for (spaces, kept) in [(1000, 1001), (20_000, 0)] { - let mut g = type_group(copies.clone()); - g.children_mut().unwrap()[0] = spaced(spaces); - assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces"); - } - // 1001 × 21 copies, past 10,000. - let mut g = type_group(vec![ - fx("distort.transform", json!({"moveH": 100, "copies": 1000})), - fx("distort.transform", json!({"moveV": 100, "copies": 20})), - ]); - g.children_mut().unwrap()[0] = spaced(0); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - } - - #[test] - fn copies_are_apart_when_their_boxes_only_touch() { - let b = Rect::new(0.0, 0.0, 10.0, 5.0); - assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b)); - assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b)); - assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b)); - assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b)); - // Offsets too far for the cells' range count as overlapping. - assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0))); - } - - #[test] - fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]); - inner.id = NodeId(53); - let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); - outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1}))); - let d = bake_document(&doc_with(outer)).unwrap(); - assert!(type_origins(&d).is_empty()); - // The glyphs take part in Add: the result reaches past the 10 pt square. - let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.width() > 30.0, "{b:?}"); - } - - #[test] - fn type_is_copied_only_when_the_effect_makes_every_copy() { - // A 10 pt line with `n` anchors. - let line = |n: usize| { - let pts: Vec = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect(); - PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]) - }; - let right = json!({"moveH": 10, "copies": 1000}); - let down = json!({"moveV": 10, "copies": 1}); - // 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors. - assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001)); - assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none()); - // A second effect copies every copy. - assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002)); - assert!(transform_offsets(&[right, down], &line(1000)).is_none()); - // Copies stop before one reaches 1,000,000,000 pt from the origin. - let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); - assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100)); - assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none()); - // An effect that also scales. - assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); - } - /// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. - fn gradient_copies(id: u64) -> Node { - use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); - let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); - n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); - n - } - - /// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. - fn gradient_pieces(n: &Node) -> Vec { - let mut out = vec![]; - let mut walk = vec![n]; - while let Some(n) = walk.pop() { - match n.children() { - Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), - _ => { - assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); - out.extend(n.geometric_bounds().map(|b| b.width())); - } - } - } - out - } - - #[test] - fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { - let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); - // Three squares of their own, not one path spanning all three. - assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); - } -} +mod tests; diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs new file mode 100644 index 000000000..b150f2004 --- /dev/null +++ b/crates/effects/src/bake/tests.rs @@ -0,0 +1,480 @@ +use super::*; +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; + +/// Type tests need glyph outlines, which only the optional craft-fonts build input provides. +fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip +} + +fn fx(id: &str, params: serde_json::Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn doc_with(n: Node) -> Document { + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +#[test] +fn plain_documents_are_not_copied() { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())); + assert!(bake_document(&d).is_none()); +} + +#[test] +fn object_effects_become_geometry_and_raster_effects_stay() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0}))); + n.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let b = m.geometric_bounds().unwrap(); + assert!((b.width() - 120.0).abs() < 0.5, "{b:?}"); + assert_eq!(m.appearance.effects.len(), 1); + assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); +} + +#[test] +fn item_effects_split_into_one_path_per_item() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let ch = m.children().unwrap(); + assert_eq!(ch.len(), 2); + assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); + assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5); + assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50)); +} + +#[test] +fn a_baked_stroke_keeps_its_raster_effects() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + s.effects.push(fx("stylize.dropShadow", json!({}))); + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; + assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); +} + +#[test] +fn a_baked_stroke_leaves_dither_behind() { + // Strokes ignore dither, so the filled outline goes without it and looks as the stroke did. + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + g.gradient.dither = true; + s.paint = Paint::Gradient(Box::new(g)); + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; + let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; + assert!(!g.gradient.dither); +} + +#[test] +fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.width = 10.0; + s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; + s.opacity = 0.5; + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes"); + assert_eq!(ch[1].opacity, 0.5); +} + +/// 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() }; + Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))) +} + +/// The origins of the type objects in `d`, in paint order. +fn type_origins(d: &Document) -> Vec<(f64, f64)> { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let c = t.xf.as_coeffs(); + out.push((c[4], c[5])); + } + }); + out +} + +/// A group of the type and a 10 pt square with `effects`. +fn type_group(effects: Vec) -> Node { + let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]); + g.appearance.effects = effects; + g +} + +#[test] +fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() { + if skip_without_craft_fonts() { + return; + } + for (params, origins, square) in [ + (json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)), + (json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)), + (json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)), + ] { + let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]); + let d = bake_document(&doc_with(g.clone())).unwrap(); + assert_eq!(type_origins(&d), origins, "{params}"); + let baked = d.node(NodeId(52)).unwrap(); + assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + // The square moves with the type, as one path holding its copies. + let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap(); + assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}"); + let mut ids = std::collections::HashSet::new(); + baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id)); + // The renderer and Expand Appearance still draw it as outlines. + let mut text = 0; + evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(text, 0, "{params}"); + } + // Stacked effects that only move: every copy of a copy. + let d = bake_document(&doc_with(type_group(vec![ + fx("distort.transform", json!({"moveH": 100, "copies": 1})), + fx("distort.transform", json!({"moveV": 100, "copies": 1})), + ]))) + .unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]); +} + +#[test] +fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() { + if skip_without_craft_fonts() { + return; + } + let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); + let mut hidden = type_node(55); + hidden.visible = false; + g.children_mut().unwrap().push(Arc::new(hidden)); + let d = bake_document(&doc_with(g)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); + assert!(!d.node(NodeId(55)).unwrap().visible); +} + +#[test] +fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { + if skip_without_craft_fonts() { + return; + } + let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); + let mut shadowed = type_node(55); + shadowed.opacity = 0.5; + shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + // Type that clips a group is outlined as the plain outlines it clips by. + let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true }); + g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]); + let d = bake_document(&doc_with(g)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); + let o = d.node(NodeId(55)).unwrap(); + assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}"); + assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + // The outlines are copied with the type. + let b = o.geometric_bounds().unwrap(); + assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}"); + let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap(); + assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}"); +} + +#[test] +fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { + if skip_without_craft_fonts() { + return; + } + let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); + // The clipping path is a group with a fill of its own, holding type with an effect of its own. + let mut moved = type_node(60); + moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300}))); + let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]); + path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))); + let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true }); + g.children_mut().unwrap().push(Arc::new(clip)); + let d = bake_document(&doc_with(g)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); + // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. + let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); + assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); +} + +#[test] +fn type_with_its_own_transform_effect_copies_stays_type() { + if skip_without_craft_fonts() { + return; + } + let mut n = type_node(50); + n.opacity = 0.5; + n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))]; + let d = bake_document(&doc_with(n)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]); + // One group keeps the object's transparency and its other effects; the copies are plain. + let g = d.node(NodeId(50)).unwrap(); + assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5); + assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + for c in g.children().unwrap() { + assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}"); + } + // Hidden type stays hidden. + let mut n = type_node(50); + n.visible = false; + n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))]; + let d = bake_document(&doc_with(n)).unwrap(); + assert_eq!(type_origins(&d).len(), 2); + assert!(!d.node(NodeId(50)).unwrap().visible); +} + +#[test] +fn type_under_other_geometry_effects_is_still_outlined() { + for effects in [ + vec![fx("distort.transform", json!({"moveH": 30, "random": true}))], + vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))], + vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))], + vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))], + vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))], + // Copies that would reach past the effect's limit, which stops them early. + vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))], + // Copies that overlap, or land on each other. + vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))], + vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))], + ] { + let mut own = type_node(50); + own.appearance.effects = effects.clone(); + for n in [type_group(effects.clone()), own] { + let d = bake_document(&doc_with(n)).unwrap(); + assert!(type_origins(&d).is_empty(), "{effects:?}"); + } + } +} + +#[test] +fn type_whose_copies_paint_over_each_other_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; + assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2); + // A 20 pt stroke on the characters reaches the copy below. + let mut stroked = type_node(50); + if let NodeKind::Text(t) = &mut stroked.kind { + t.runs[0].style.stroke = Paint::solid(Color::BLACK); + t.runs[0].style.stroke_width = 20.0; + } + let mut g = type_group(moves); + g.children_mut().unwrap()[0] = Arc::new(stroked); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); +} + +#[test] +fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; + let mut translucent = type_node(50); + translucent.opacity = 0.5; + let mut underlined = type_node(50); + if let NodeKind::Text(t) = &mut underlined.kind { + t.runs[0].style.underline = true; + } + let mut shadowed = type_node(50); + shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + let mut gradient = type_node(50); + if let NodeKind::Text(t) = &mut gradient.kind { + t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + } + let mut reshaped = type_node(50); + let mut fill = FillLayer::new(Paint::solid(Color::BLACK)); + fill.effects.push(fx("path.offsetPath", json!({"offset": 2}))); + reshaped.appearance.items.push(AppearanceItem::Fill(fill)); + // A stroke of the type object's own (beyond its characters'). + let mut stroked = type_node(50); + stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0))); + // Character strokes scale with type scaled by its transform, where the outlines keep theirs. + let mut scaled = type_node(50); + if let NodeKind::Text(t) = &mut scaled.kind { + t.runs[0].style.stroke = Paint::solid(Color::BLACK); + t.runs[0].style.stroke_width = 1.0; + t.xf *= Affine::scale(2.0); + } + let mut turned = scaled.clone(); + if let NodeKind::Text(t) = &mut turned.kind { + t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5); + } + let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))]; + for (t, kept) in [(turned, 2), (scaled, 0)] { + let mut g = type_group(far.clone()); + g.children_mut().unwrap()[0] = Arc::new(t); + assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept); + } + for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] { + let mut g = type_group(moves.clone()); + g.children_mut().unwrap()[0] = Arc::new(t); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); + } +} + +#[test] +fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() { + let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))]; + let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); + let mut g = type_group(moves.clone()); + g.appearance.items.push(fill.clone()); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); + // A group with a fill of its own inside the moved group. + let mut inner = type_group(vec![]); + inner.id = NodeId(53); + inner.appearance.items.push(fill); + let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); + outer.appearance.effects = moves; + assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); +} + +#[test] +fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() { + // The inner copy lands back on the original. + let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]); + inner.id = NodeId(53); + let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1}))); + assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); +} + +#[test] +fn type_whose_copies_hold_too_much_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let spaced = |spaces: usize| { + let mut n = type_node(50); + if let NodeKind::Text(t) = &mut n.kind { + t.runs[0].text = format!("a{}", " ".repeat(spaces)); + } + Arc::new(n) + }; + // 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000 + // bytes together, they are outlined. + let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))]; + for (spaces, kept) in [(1000, 1001), (20_000, 0)] { + let mut g = type_group(copies.clone()); + g.children_mut().unwrap()[0] = spaced(spaces); + assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces"); + } + // 1001 × 21 copies, past 10,000. + let mut g = type_group(vec![ + fx("distort.transform", json!({"moveH": 100, "copies": 1000})), + fx("distort.transform", json!({"moveV": 100, "copies": 20})), + ]); + g.children_mut().unwrap()[0] = spaced(0); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); +} + +#[test] +fn copies_are_apart_when_their_boxes_only_touch() { + let b = Rect::new(0.0, 0.0, 10.0, 5.0); + assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b)); + assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b)); + assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b)); + assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b)); + // Offsets too far for the cells' range count as overlapping. + assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0))); +} + +#[test] +fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]); + inner.id = NodeId(53); + let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1}))); + let d = bake_document(&doc_with(outer)).unwrap(); + assert!(type_origins(&d).is_empty()); + // The glyphs take part in Add: the result reaches past the 10 pt square. + let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap(); + assert!(b.width() > 30.0, "{b:?}"); +} + +#[test] +fn type_is_copied_only_when_the_effect_makes_every_copy() { + // A 10 pt line with `n` anchors. + let line = |n: usize| { + let pts: Vec = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect(); + PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]) + }; + let right = json!({"moveH": 10, "copies": 1000}); + let down = json!({"moveV": 10, "copies": 1}); + // 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors. + assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001)); + assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none()); + // A second effect copies every copy. + assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002)); + assert!(transform_offsets(&[right, down], &line(1000)).is_none()); + // Copies stop before one reaches 1,000,000,000 pt from the origin. + let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100)); + assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none()); + // An effect that also scales. + assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); +} +/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. +fn gradient_copies(id: u64) -> Node { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); + n +} + +/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. +fn gradient_pieces(n: &Node) -> Vec { + let mut out = vec![]; + let mut walk = vec![n]; + while let Some(n) = walk.pop() { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), + _ => { + assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); + out.extend(n.geometric_bounds().map(|b| b.width())); + } + } + } + out +} + +#[test] +fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { + let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); + // Three squares of their own, not one path spanning all three. + assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); +} diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs new file mode 100644 index 000000000..14fd90c13 --- /dev/null +++ b/crates/effects/src/catalog.rs @@ -0,0 +1,829 @@ +use super::*; + +/// Catalogue entry for one effect. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectInfo { + /// Stable id, e.g. `distort.roughen`. + pub id: &'static str, + /// Menu label (with the ellipsis when the effect has a dialog). + pub label: &'static str, + /// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`. + pub menu: &'static [&'static str], + /// Human/agent-readable parameter documentation. + pub params: &'static str, + /// Dialog defaults. + pub defaults: Value, + /// Raster (painted) rather than geometry effect. + pub raster: bool, + /// The parameters that are distances in points (Scale Strokes & Effects scales them). + pub lengths: Lengths, +} + +/// Which of an effect's parameters are distances in points. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Lengths { + /// Always distances. + pub always: &'static [&'static str], + /// Distances while the effect's `relative` parameter is false (percentages of the object's + /// size otherwise). + pub absolute: &'static [&'static str], +} + +impl Lengths { + /// The distance parameters while the effect's `relative` parameter is `relative`. + pub fn keys(&self, relative: bool) -> impl Iterator + '_ { + self.always.iter().chain(if relative { &[][..] } else { self.absolute }).copied() + } +} + +/// Is parameter `key` of effect `id` a distance in points while its `relative` parameter is +/// `relative`? +pub fn is_length(id: &str, key: &str, relative: bool) -> bool { + catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) +} + +/// The distance parameters of effect `id` (none for unknown effects). +fn lengths_of(id: &str) -> Lengths { + let always = |always| Lengths { always, absolute: &[] }; + match id { + "convertToShape.rectangle" | "convertToShape.ellipse" => always(&["extraW", "extraH", "width", "height"]), + "convertToShape.roundedRectangle" => always(&["extraW", "extraH", "width", "height", "radius"]), + "distort.roughen" | "distort.zigZag" => Lengths { always: &[], absolute: &["size"] }, + "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, + "distort.transform" => always(&["moveH", "moveV"]), + "path.offsetPath" => always(&["offset"]), + "threeD.revolve" => always(&["offset"]), + "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), + "threeD.inflate" => always(&["depth"]), + "path.outlineStroke" => always(&["width"]), + "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), + "stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]), + "stylize.dropShadow" => always(&["x", "y", "blur"]), + "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), + "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), + "brushStrokes.accentedEdges" => always(&["edgeWidth"]), + "artistic.paletteKnife" => always(&["strokeSize"]), + "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), + "brushStrokes.spatter" => always(&["sprayRadius"]), + "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), + "brushStrokes.sumiE" => always(&["strokeWidth"]), + "pixelate.colorHalftone" => always(&["maxRadius"]), + "pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]), + "texture.craquelure" => always(&["crackSpacing"]), + "texture.mosaicTiles" => always(&["tileSize"]), + "texture.patchwork" => always(&["squareSize"]), + "texture.stainedGlass" => always(&["cellSize"]), + "sketch.graphicPen" => always(&["strokeLength"]), + "sketch.halftonePattern" => always(&["size"]), + "sketch.notePaper" => always(&["relief"]), + "sketch.tornEdges" => always(&["smoothness"]), + "sketch.waterPaper" => always(&["fiberLength"]), + TRAP => always(&["thickness", "precision"]), + RASTERIZE => always(&["addAround"]), + _ => Lengths::default(), + } +} + +const WARP_DOC: &str = + "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; + +const DT: &[&str] = &["Effect", "Distort & Transform"]; + +const PATH: &[&str] = &["Effect", "Path"]; + +const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; + +const STYLIZE: &[&str] = &["Effect", "Stylize"]; + +const WARP: &[&str] = &["Effect", "Warp"]; + +const BLUR: &[&str] = &["Effect", "Blur"]; + +const ARTISTIC: &[&str] = &["Effect", "Artistic"]; + +const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; + +const DISTORT: &[&str] = &["Effect", "Distort"]; + +const SHARPEN: &[&str] = &["Effect", "Sharpen"]; + +const SKETCH: &[&str] = &["Effect", "Sketch"]; + +const PIXELATE: &[&str] = &["Effect", "Pixelate"]; + +const TEXTURE: &[&str] = &["Effect", "Texture"]; + +const VIDEO: &[&str] = &["Effect", "Video"]; + +const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; + +const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; + +const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; + +/// The warp styles in Illustrator's Style menu order: (id suffix, label). +pub const WARP_STYLES: [(&str, &str); 15] = [ + ("arc", "Arc…"), + ("arcLower", "Arc Lower…"), + ("arcUpper", "Arc Upper…"), + ("arch", "Arch…"), + ("bulge", "Bulge…"), + ("shellLower", "Shell Lower…"), + ("shellUpper", "Shell Upper…"), + ("flag", "Flag…"), + ("wave", "Wave…"), + ("fish", "Fish…"), + ("rise", "Rise…"), + ("fisheye", "Fisheye…"), + ("inflate", "Inflate…"), + ("squeeze", "Squeeze…"), + ("twist", "Twist…"), +]; + +/// Every supported effect with its dialog defaults. +pub fn effect_catalog() -> Vec { + let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; + let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; + let mut v = vec![ + g( + REVOLVE, + "Revolve…", + &["Effect", "3D and Materials"], + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), + ), + g( + EXTRUDE, + "Extrude & Bevel…", + &["Effect", "3D and Materials"], + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + ), + g( + INFLATE, + "Inflate…", + &["Effect", "3D and Materials"], + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + ), + g( + ROTATE, + "Rotate…", + &["Effect", "3D and Materials"], + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + ), + g( + "convertToShape.rectangle", + "Rectangle…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), + ), + g( + "convertToShape.roundedRectangle", + "Rounded Rectangle…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}), + ), + g( + "convertToShape.ellipse", + "Ellipse…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), + ), + g( + "distort.freeDistort", + "Free Distort…", + DT, + "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", + json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), + ), + g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), + g( + "distort.roughen", + "Roughen…", + DT, + "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), + ), + g( + "distort.transform", + "Transform…", + DT, + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), + ), + g( + "distort.tweak", + "Tweak…", + DT, + "{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}", + json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}), + ), + g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})), + g( + "distort.zigZag", + "Zig Zag…", + DT, + "{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}", + json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}), + ), + g( + "path.offsetPath", + "Offset Path…", + PATH, + "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", + json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), + ), + g( + "path.outlineObject", + "Outline Object", + PATH, + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", + json!({}), + ), + g( + "path.outlineStroke", + "Outline Stroke", + PATH, + "{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads", + json!({}), + ), + g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), + g( + "stylize.scribble", + "Scribble…", + STYLIZE, + "{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", + json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}), + ), + r( + "stylize.dropShadow", + "Drop Shadow…", + STYLIZE, + "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", + json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), + ), + r( + "stylize.innerGlow", + "Inner Glow…", + STYLIZE, + "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}", + json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}), + ), + r( + "stylize.outerGlow", + "Outer Glow…", + STYLIZE, + "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}", + json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}), + ), + r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), + r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), + r( + "artistic.coloredPencil", + "Colored Pencil…", + ARTISTIC, + "{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}), + ), + r( + "artistic.cutout", + "Cutout…", + ARTISTIC, + "{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}), + ), + r( + "artistic.dryBrush", + "Dry Brush…", + ARTISTIC, + "{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), + ), + r( + "artistic.filmGrain", + "Film Grain…", + ARTISTIC, + "{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", + json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}), + ), + r( + "artistic.fresco", + "Fresco…", + ARTISTIC, + "{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), + ), + r( + "artistic.neonGlow", + "Neon Glow…", + ARTISTIC, + "{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}), + ), + r( + "artistic.paintDaubs", + "Paint Daubs…", + ARTISTIC, + "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), + ), + r( + "artistic.paletteKnife", + "Palette Knife…", + ARTISTIC, + "{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}), + ), + r( + "artistic.plasticWrap", + "Plastic Wrap…", + ARTISTIC, + "{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", + json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}), + ), + r( + "artistic.posterEdges", + "Poster Edges…", + ARTISTIC, + "{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}), + ), + r( + "artistic.roughPastels", + "Rough Pastels…", + ARTISTIC, + "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", + json!({ + "strokeLength": 6.0, + "strokeDetail": 10.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), + r( + "artistic.smudgeStick", + "Smudge Stick…", + ARTISTIC, + "{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}), + ), + r( + "artistic.sponge", + "Sponge…", + ARTISTIC, + "{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}), + ), + r( + "artistic.underpainting", + "Underpainting…", + ARTISTIC, + "{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.", + json!({ + "brushSize": 6.0, + "textureCoverage": 16.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), + r( + "artistic.watercolor", + "Watercolor…", + ARTISTIC, + "{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}), + ), + r( + "blur.radial", + "Radial Blur…", + BLUR, + "{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre", + json!({"amount": 10.0, "method": "spin", "quality": "good"}), + ), + r( + "blur.smart", + "Smart Blur…", + BLUR, + "{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage", + json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}), + ), + r( + "brushStrokes.accentedEdges", + "Accented Edges…", + BRUSH_STROKES, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges", + json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.angledStrokes", + "Angled Strokes…", + BRUSH_STROKES, + "{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'", + json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}), + ), + r( + "brushStrokes.crosshatch", + "Crosshatch…", + BRUSH_STROKES, + "{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges", + json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}), + ), + r( + "brushStrokes.darkStrokes", + "Dark Strokes…", + BRUSH_STROKES, + "{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes", + json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}), + ), + r( + "brushStrokes.inkOutlines", + "Ink Outlines…", + BRUSH_STROKES, + "{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows", + json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}), + ), + r( + "brushStrokes.spatter", + "Spatter…", + BRUSH_STROKES, + "{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush", + json!({"sprayRadius": 10.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.sprayedStrokes", + "Sprayed Strokes…", + BRUSH_STROKES, + "{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours", + json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}), + ), + r( + "brushStrokes.sumiE", + "Sumi-e…", + BRUSH_STROKES, + "{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape", + json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}), + ), + r( + "distort.diffuseGlow", + "Diffuse Glow…", + DISTORT, + "{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain", + json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}), + ), + r( + "distort.glass", + "Glass…", + DISTORT, + "{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", + json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}), + ), + r( + "distort.oceanRipple", + "Ocean Ripple…", + DISTORT, + "{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water", + json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}), + ), + r( + "sharpen.unsharpMask", + "Unsharp Mask…", + SHARPEN, + "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", + json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), + ), + r( + "sketch.basRelief", + "Bas Relief…", + SKETCH, + "{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}), + ), + r( + "sketch.chalkCharcoal", + "Chalk & Charcoal…", + SKETCH, + "{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.", + json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}), + ), + r( + "sketch.charcoal", + "Charcoal…", + SKETCH, + "{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}), + ), + r( + "sketch.chrome", + "Chrome…", + SKETCH, + "{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"detail": 4.0, "smoothness": 4.0}), + ), + r( + "sketch.conteCrayon", + "Conté Crayon…", + SKETCH, + "{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", + json!({ + "foregroundLevel": 12.0, + "backgroundLevel": 10.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), + r( + "sketch.graphicPen", + "Graphic Pen…", + SKETCH, + "{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.", + json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}), + ), + r( + "sketch.halftonePattern", + "Halftone Pattern…", + SKETCH, + "{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", + json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}), + ), + r( + "sketch.notePaper", + "Note Paper…", + SKETCH, + "{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", + json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}), + ), + r( + "sketch.photocopy", + "Photocopy…", + SKETCH, + "{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"detail": 7.0, "darkness": 25.0}), + ), + r( + "sketch.plaster", + "Plaster…", + SKETCH, + "{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}), + ), + r( + "sketch.reticulation", + "Reticulation…", + SKETCH, + "{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}), + ), + r( + "sketch.stamp", + "Stamp…", + SKETCH, + "{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"lightDarkBalance": 25.0, "smoothness": 5.0}), + ), + r( + "sketch.tornEdges", + "Torn Edges…", + SKETCH, + "{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}), + ), + r( + "sketch.waterPaper", + "Water Paper…", + SKETCH, + "{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", + json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}), + ), + r( + "stylize.glowingEdges", + "Glowing Edges…", + STYLIZE, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black", + json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}), + ), + r( + "pixelate.colorHalftone", + "Color Halftone…", + PIXELATE, + "{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell", + json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}), + ), + r( + "pixelate.crystallize", + "Crystallize…", + PIXELATE, + "{cellSize: pt 3..300 (10)} redraws the object as polygon crystals of solid colour around random points about cellSize apart", + json!({"cellSize": 10.0}), + ), + r( + "pixelate.mezzotint", + "Mezzotint…", + PIXELATE, + "{type: \"fineDots\"|\"mediumDots\"|\"grainyDots\"|\"coarseDots\"|\"shortLines\"|\"mediumLines\"|\"longLines\"|\"shortStrokes\"|\"mediumStrokes\"|\"longStrokes\" (\"fineDots\")} turns each colour channel fully on or off against a random pattern of dots, lines or strokes: fully saturated colours", + json!({"type": "fineDots"}), + ), + r( + "pixelate.pointillize", + "Pointillize…", + PIXELATE, + "{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas", + json!({"cellSize": 5.0}), + ), + r( + "texture.craquelure", + "Craquelure…", + TEXTURE, + "{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones", + json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}), + ), + r( + "texture.grain", + "Grain…", + TEXTURE, + "{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white", + json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}), + ), + r( + "texture.mosaicTiles", + "Mosaic Tiles…", + TEXTURE, + "{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them", + json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}), + ), + r( + "texture.patchwork", + "Patchwork…", + TEXTURE, + "{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left", + json!({"squareSize": 4.0, "relief": 8.0}), + ), + r( + "texture.stainedGlass", + "Stained Glass…", + TEXTURE, + "{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart", + json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}), + ), + r( + "texture.texturizer", + "Texturizer…", + TEXTURE, + "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", + json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}), + ), + r( + "video.deinterlace", + "De-Interlace…", + VIDEO, + "{eliminate: \"odd\"|\"even\" (\"odd\"; which field lines are taken out), create: \"duplication\"|\"interpolation\" (\"duplication\"; a line taken out becomes a copy of the line above, or the average of the lines above and below)} the field lines are the rows of the document's raster grid (Document Raster Effects Settings › Resolution), numbered from 1 at the top of the page", + json!({"eliminate": "odd", "create": "duplication"}), + ), + r( + "video.ntscColors", + "NTSC Colors", + VIDEO, + "{} makes colours a television signal can't carry (luma plus chroma past 110 % of white, or luma minus chroma below −20 %) less saturated, keeping their brightness", + json!({}), + ), + ]; + v.extend([ + g( + "adjust.brightnessContrast", + "Brightness/Contrast…", + ADJUST, + "{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)", + json!({"brightness": 0.0, "contrast": 0.0}), + ), + g( + "adjust.curves", + "Curves…", + ADJUST, + "{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"points": "0,0 128,128 255,255", "channel": "rgb"}), + ), + g( + "adjust.hueSaturation", + "Hue/Saturation…", + ADJUST, + "{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does", + json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}), + ), + g( + "adjust.levels", + "Levels…", + ADJUST, + "{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}), + ), + g( + "adjust.shiftToColor", + "Shift to Color…", + ADJUST, + "{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does", + json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}), + ), + g( + "adjust.temperatureTint", + "Temperature/Tint…", + ADJUST, + "{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does", + json!({"temperature": 0.0, "tint": 0.0}), + ), + ]); + for (suffix, label) in WARP_STYLES { + let id: &'static str = match suffix { + "arc" => "warp.arc", + "arcLower" => "warp.arcLower", + "arcUpper" => "warp.arcUpper", + "arch" => "warp.arch", + "bulge" => "warp.bulge", + "shellLower" => "warp.shellLower", + "shellUpper" => "warp.shellUpper", + "flag" => "warp.flag", + "wave" => "warp.wave", + "fish" => "warp.fish", + "rise" => "warp.rise", + "fisheye" => "warp.fisheye", + "inflate" => "warp.inflate", + "squeeze" => "warp.squeeze", + _ => "warp.twist", + }; + v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + } + for (id, label, _) in PATHFINDER_EFFECTS { + if id == "pathfinder.softMix" { + v.push(g( + id, + label, + PATHFINDER, + "{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)", + json!({"mixingRate": 50.0}), + )); + } else { + v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); + } + } + v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); + v.push(g( + CROP_MARKS, + "Crop Marks", + &["Effect"], + "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", + json!({}), + )); + v.push(r( + RASTERIZE, + "Rasterize…", + &["Effect"], + "{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes", + json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}), + )); + v +} + +/// The catalogue built once, indexed by id (lookups run per effect per rendered frame). +pub(crate) fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInfo> { + static INDEX: std::sync::OnceLock> = std::sync::OnceLock::new(); + INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) +} + +/// Catalogue entry for `id` (built-in, or an installed plug-in effect). +pub fn effect_info(id: &str) -> Option { + catalog_index().get(id).cloned().or_else(|| plugin_info(id)) +} + +/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.`). +pub(crate) fn plugin_info(id: &str) -> Option { + use vectorcraft_plugins::registry::intern; + let p = vectorcraft_plugins::effect::installed(id)?; + let m = p.manifest(); + let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; + Some(EffectInfo { + id: intern(id), + label: intern(&format!("{}{dots}", m.name)), + menu: PLUGINS, + params: intern(&m.params_doc()), + defaults: Value::Object(m.defaults()), + raster: false, + lengths: Lengths::default(), + }) +} + +/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id. +pub fn plugin_effects() -> Vec { + vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect() +} diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs new file mode 100644 index 000000000..7d25d1955 --- /dev/null +++ b/crates/effects/src/geometry.rs @@ -0,0 +1,219 @@ +use super::params::{is_geometry, merged_params}; +use super::*; + +/// Context the geometry effects may need from the object's appearance. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GeomContext<'a> { + /// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment, + /// profile and arrowheads): the stroke the effect sits on, else the object's top painted + /// stroke. `None` outlines a plain 1 pt stroke. + pub stroke: Option<&'a StrokeLayer>, + /// The object's fill rule (inside and outside alignment). + pub rule: FillRule, + /// The seed of the Transform effect's Random: the object's id, so each object varies its own + /// way and keeps its result on every redraw. + pub seed: u64, + /// The object's appearance, which live-effect plug-ins are given read only (their fills and + /// strokes). + pub appearance: Option<&'a Appearance>, + /// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`: + /// the object's effects. + pub item: Option, +} + +impl<'a> GeomContext<'a> { + /// The context of `n`'s object-level effects. + pub fn of(n: &'a Node) -> Self { + let rule = match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + }; + let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); + Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None } + } + + /// The context of the effects on `item`, one of the same object's appearance items: a + /// stroke's own effects outline that stroke, and a plug-in is told which item it is on. + pub fn item(self, item: &'a AppearanceItem) -> Self { + let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item))); + match item { + AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self }, + AppearanceItem::Fill(_) => Self { item: at, ..self }, + } + } +} + +/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type, +/// symbol or image art together; a path that already has more keeps its own count as the limit. +/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps +/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of +/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is +/// left out. +pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000; + +/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at +/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large +/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of +/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past +/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few +/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does. +pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000; + +/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the +/// renderer is estimated to draw it with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Budget { + pub anchors: usize, + pub lines: usize, +} + +impl Budget { + /// The budget of a chain of geometry effects. + pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES }; + + /// What `path` costs. + pub(crate) fn of(path: &PathData) -> Self { + Self { anchors: path.anchor_count(), lines: util::line_estimate(path) } + } + + /// This budget, raised to `cost` where that is more. + pub(crate) fn at_least(self, cost: Self) -> Self { + Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) } + } + + /// Does `cost` fit this budget? + pub(crate) fn fits(self, cost: Self) -> bool { + cost.anchors <= self.anchors && cost.lines <= self.lines + } + + /// What is left of this budget once `cost` is spent. + pub(crate) fn minus(self, cost: Self) -> Self { + Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) } + } + + /// This cost and `cost` together. + pub(crate) fn plus(self, cost: Self) -> Self { + Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) } + } +} + +/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the +/// reference box of the first effect (usually the object's geometric bounds); each later effect +/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect +/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts, +/// when they are more). +pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData { + apply_geometry_with(effects, path, bounds, &GeomContext::default()) +} + +/// [`apply_geometry`] with an explicit [`GeomContext`]. +pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData { + apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS) +} + +/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that +/// is more. +pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { + // `budget` raised to what `path` costs, measured once, and only for a path that has geometry + // effects. + let mut limit = None; + let mut cur = path.clone(); + let mut b = bounds; + let mut first = true; + for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) { + if !first { + b = cur.bounds().unwrap_or(b); + } + first = false; + if cur.is_empty() { + break; + } + let p = merged_params(&e.id, &e.params); + let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); + if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + cur = next; + } + } + cur +} + +/// Convenience wrapper on kurbo paths. +pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath { + apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() +} + +/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its +/// result would not fit `budget` (the effects that multiply anchors build within its anchors). +pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option { + let out = apply_unchecked(id, p, path, b, ctx, budget.anchors); + let cost = Budget::of(&out); + if budget.fits(cost) { + return Some(out); + } + if cost.lines <= budget.lines || !builds_within(id) { + return None; + } + // Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer + // anchors as there were lines too many, and a tenth to spare, as segments vary in their lines. + let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize; + let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors)); + budget.fits(Budget::of(&out)).then_some(out) +} + +/// Does geometry effect `id` build within the anchors it is given? +fn builds_within(id: &str) -> bool { + matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.") +} + +/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. +fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { + use util::*; + if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { + let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; + return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); + } + match id { + "distort.freeDistort" => distort::free_distort(path, b, p, max), + "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), + "distort.roughen" => distort::roughen(path, b, p, max), + "distort.transform" => distort::transform_within(path, b, p, ctx.seed, max), + "distort.tweak" => distort::tweak(path, b, p), + "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max), + "distort.zigZag" => distort::zig_zag(path, b, p, max), + "path.offsetPath" => { + let off = num(p, "offset", 10.0).clamp(-1e5, 1e5); + if off.abs() < 1e-9 { + return path.clone(); + } + vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) + } + // Outline Object returns the geometry unchanged. Type reaches every geometry effect as its + // glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines. + "path.outlineObject" => path.clone(), + "path.outlineStroke" => { + let fallback; + let base = match ctx.stroke { + Some(s) => s, + None => { + fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0); + &fallback + } + }; + let st = match p.get("width").and_then(Value::as_f64) { + Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }), + None => std::borrow::Cow::Borrowed(base), + }; + if st.width.is_nan() || st.width <= 0.0 { + return path.clone(); + } + stroke::outline_region(path, ctx.rule, &st) + } + "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), + _ => 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 5463857ed..ad5927850 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -117,7 +117,7 @@ pub const PATHFINDER_MAX_OVERLAPS: usize = 16_384; struct Counts(Vec); // `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version). -#[allow(clippy::manual_isolate_lowest_one)] +#[allow(unknown_lints, clippy::manual_isolate_lowest_one)] impl Counts { fn add(&mut self, slot: usize, d: isize) { let mut i = slot + 1; @@ -480,359 +480,4 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_color::{BlendMode, Color, Paint}; - use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; - use vectorcraft_geom::{Point, Rect, shapes}; - - /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. - fn skip_without_craft_fonts() -> bool { - let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); - if skip { - eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); - } - skip - } - - fn sq(id: u64, x: f64, color: Color) -> Arc { - Arc::new(Node::path( - NodeId(id), - shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), - Appearance::basic(Paint::solid(color), Paint::None, 0.0), - )) - } - - fn group_with(effect: &str) -> Node { - let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))]); - g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - g - } - - fn area(n: &Node) -> f64 { - po::area(n.path_data().unwrap(), FillRule::NonZero).abs() - } - - fn overlap_cmyk(g: &Node) -> [f32; 4] { - let faces = pathfinder_children(g, None).unwrap(); - assert_eq!(faces.len(), 3); - let overlap = faces - .iter() - .find(|n| { - let b = n.geometric_bounds().unwrap(); - b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9 - }) - .expect("overlap face"); - assert!(overlap.appearance.stroke_paint().is_none()); - overlap.appearance.fill_paint().color().unwrap().to_cmyk() - } - - fn two_overlapping_cmyk_squares() -> Node { - let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0); - let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0); - Node::group( - NodeId(10), - vec![ - Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)), - Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)), - ], - ) - } - - /// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter - /// colour inside the darker one along the edge where they meet. - #[test] - fn trap_effect_adds_an_overprinting_strip_above_the_members() { - let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); - let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0); - let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); - let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); - g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); - let out = pathfinder_children(&g, None).unwrap(); - assert_eq!(out.len(), 3, "both squares and one trap"); - assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); - let b = out[2].geometric_bounds().unwrap(); - assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}"); - let fill = out[2].appearance.fill().unwrap(); - assert!(fill.overprint); - assert_eq!(fill.paint.color(), Some(yellow)); - } - - #[test] - fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { - let mut g = two_overlapping_cmyk_squares(); - g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true }); - let [c, m, y, k] = overlap_cmyk(&g); - assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}"); - } - - #[test] - fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { - let mut g = two_overlapping_cmyk_squares(); - g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true }); - let [c, m, y, k] = overlap_cmyk(&g); - assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}"); - } - - #[test] - fn shape_modes_follow_the_pathfinder_panel() { - let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); - assert_eq!(add.len(), 1); - assert!((area(&add[0]) - 150.0).abs() < 1e-3); - // Add keeps the front object's paint, Subtract the back one's. - assert_eq!(add[0].id, NodeId(2)); - let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); - assert_eq!(sub[0].id, NodeId(1)); - assert!((area(&sub[0]) - 50.0).abs() < 1e-3); - let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); - assert!((area(&int[0]) - 50.0).abs() < 1e-3); - let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); - assert_eq!(div.len(), 3); - } - - /// 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 { - let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() }; - Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style)))) - } - - /// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it. - fn square_and(front: Option, effect: &str) -> Node { - let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red); - let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect()); - g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - g - } - - /// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. - fn results(g: &Node) -> Vec> { - pathfinder_children(g, crate::text_outliner()).unwrap() - } - - #[test] - fn type_changes_the_result_of_every_pathfinder_effect() { - if skip_without_craft_fonts() { - return; - } - let glyphs = crate::outline_text(&type_h()).unwrap(); - let b = glyphs.geometric_bounds().unwrap(); - assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); - // `measure` gives the number of results and their total area. - let measure = |front: Option, effect: &str| { - let r = results(&square_and(front, effect)); - (r.len(), r.iter().map(|n| area(n)).sum::()) - }; - let unchanged: Vec<_> = PATHFINDER_EFFECTS - .iter() - .map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id))) - .filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0) - .collect(); - assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}"); - // The shape modes take the glyph's whole area: the part inside the square and the part outside. - let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum(); - let with_type = |id: &str| measure(Some(type_h()), id).1; - assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}"); - assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); - // Outline art nested one group deeper takes part as well. - let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); - let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); - assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); - } - - #[test] - fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { - if skip_without_craft_fonts() { - return; - } - let mut t = type_h(); - (t.opacity, t.blend) = (0.5, BlendMode::Multiply); - let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); - let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal); - let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply); - let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect(); - assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}"); - // In the shape modes a group with the type in front is one shape, styled like the type. - let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); - let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green); - let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]); - let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect(); - assert_eq!(added, [glyph]); - } - - #[test] - fn a_random_transform_on_a_group_moves_its_members_together() { - let mut g = group_with("distort.transform"); - g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true}); - let art = evaluate_container(None, &g).unwrap(); - let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap(); - let (a, b) = (corner(0), corner(1)); - assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}"); - assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}"); - } - - /// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`, - /// in a group with a square and Offset Path 0, which leaves every path as it is. - fn type_in_group(style: impl FnOnce(&mut Node)) -> Node { - let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; - let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts); - let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text))); - style(&mut t); - let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); - g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); - let art = evaluate_container(None, &g).unwrap(); - (*art.children().unwrap()[1]).clone() - } - - #[test] - fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { - if skip_without_craft_fonts() { - return; - } - use vectorcraft_color::BlendMode; - let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); - assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); - assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply)); - // Its opacity mask, its own shadow, its knockout shape option and its visibility stay. - let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); - let mask = vectorcraft_doc::OpacityMask::new(mask_art, true); - assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask)); - let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }; - assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]); - assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape); - assert!(!type_in_group(|t| t.visible = false).visible); - // Its own Transform moves its outlines 30 pt before the group's effect reshapes them. - let x0 = |n: Node| n.geometric_bounds().unwrap().x0; - let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }; - let moved = x0(type_in_group(|t| t.appearance.effects.push(transform))); - assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}"); - } - - #[test] - fn hidden_effect_or_plain_group_is_untouched() { - let mut g = group_with("pathfinder.add"); - g.appearance.effects[0].visible = false; - assert!(pathfinder_children(&g, None).is_none()); - assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); - } - - #[test] - fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { - if skip_without_craft_fonts() { - return; - } - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - // The type's characters fit the gradient to its layout; the square has it placed across itself. - let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() }; - let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style); - let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text)))); - let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; - let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); - let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0))); - let start = |n: &Node| match n.appearance.fill_paint() { - Paint::Gradient(g) => g.geom.unwrap().start.x, - p => panic!("a gradient: {p:?}"), - }; - let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]); - let mut g = Node::group(NodeId(10), vec![square, t]); - g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); - let art = evaluate_container(None, &g).unwrap(); - let members = art.children().unwrap(); - let after = start(&members[1].children().unwrap()[0]); - assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); - assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); - } - - #[test] - fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; - let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; - for (k, paint) in paints.into_iter().enumerate() { - let square = - Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); - let mut g = Node::group(NodeId(10), vec![square]); - let params = serde_json::json!({"moveH": 30, "copies": 2}); - g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); - let art = evaluate_container(None, &g).unwrap(); - let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); - assert_eq!(copies.len(), 3); - for (i, c) in copies.iter().enumerate() { - // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. - assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); - match c.appearance.fill_paint() { - Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), - Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), - p => panic!("a gradient: {p:?}"), - } - } - } - } - - /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit - /// pitch, each crossing every bar of the other direction: n² crossings. - fn crossing_bars(n: usize, effect: &str) -> Node { - let len = 4.0 * n as f64; - let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - let mut children = vec![]; - for i in 0..n { - let at = 4.0 * i as f64; - children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone()))); - } - for i in 0..n { - let at = 4.0 * i as f64; - let id = NodeId(100 + (n + i) as u64); - children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone()))); - } - let mut g = Node::group(NodeId(10), children); - g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - g - } - - #[test] - fn pathfinder_past_its_budget_draws_the_source_art() { - // n² crossings past the cap: no evaluation (#3591), the source art as it is. - for n in [130, 800] { - let g = crossing_bars(n, "pathfinder.divide"); - let out = pathfinder_children(&g, None).unwrap(); - assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); - let ids: Vec = out.iter().map(|c| c.id).collect(); - let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); - assert_eq!(ids, src, "{n} x {n} bars"); - assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data())); - } - } - - #[test] - fn pathfinder_within_its_budget_still_divides() { - // Each bar is cut into one piece per crossing plus the n pieces between and past them, so - // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly - // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. - for n in [8, 128] { - let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); - assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); - } - } - - #[test] - fn pathfinder_on_many_stacked_bars_still_runs() { - // 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999 - // overlapping pairs are within the budget, so Add still merges them into one path. - let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - let children = (0..3000) - .map(|i| { - let at = 1.5 * i as f64; - Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone())) - }) - .collect(); - let mut g = Node::group(NodeId(10), children); - g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); - assert_eq!(out.len(), 1); - } -} +mod tests; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs new file mode 100644 index 000000000..c3bf4bfe3 --- /dev/null +++ b/crates/effects/src/group/tests.rs @@ -0,0 +1,349 @@ +use super::*; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; + +/// Type tests need glyph outlines, which only the optional craft-fonts build input provides. +fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip +} + +fn sq(id: u64, x: f64, color: Color) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(Paint::solid(color), Paint::None, 0.0))) +} + +fn group_with(effect: &str) -> Node { + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))]); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g +} + +fn area(n: &Node) -> f64 { + po::area(n.path_data().unwrap(), FillRule::NonZero).abs() +} + +fn overlap_cmyk(g: &Node) -> [f32; 4] { + let faces = pathfinder_children(g, None).unwrap(); + assert_eq!(faces.len(), 3); + let overlap = faces + .iter() + .find(|n| { + let b = n.geometric_bounds().unwrap(); + b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9 + }) + .expect("overlap face"); + assert!(overlap.appearance.stroke_paint().is_none()); + overlap.appearance.fill_paint().color().unwrap().to_cmyk() +} + +fn two_overlapping_cmyk_squares() -> Node { + let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0); + let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0); + Node::group( + NodeId(10), + vec![ + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)), + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)), + ], + ) +} + +/// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter +/// colour inside the darker one along the edge where they meet. +#[test] +fn trap_effect_adds_an_overprinting_strip_above_the_members() { + let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); + let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); + let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); + g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 3, "both squares and one trap"); + assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); + let b = out[2].geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}"); + let fill = out[2].appearance.fill().unwrap(); + assert!(fill.overprint); + assert_eq!(fill.paint.color(), Some(yellow)); +} + +#[test] +fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { + let mut g = two_overlapping_cmyk_squares(); + g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true }); + let [c, m, y, k] = overlap_cmyk(&g); + assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}"); +} + +#[test] +fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { + let mut g = two_overlapping_cmyk_squares(); + g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true }); + let [c, m, y, k] = overlap_cmyk(&g); + assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}"); +} + +#[test] +fn shape_modes_follow_the_pathfinder_panel() { + let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); + assert_eq!(add.len(), 1); + assert!((area(&add[0]) - 150.0).abs() < 1e-3); + // Add keeps the front object's paint, Subtract the back one's. + assert_eq!(add[0].id, NodeId(2)); + let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); + assert_eq!(sub[0].id, NodeId(1)); + assert!((area(&sub[0]) - 50.0).abs() < 1e-3); + let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); + assert!((area(&int[0]) - 50.0).abs() < 1e-3); + let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); + assert_eq!(div.len(), 3); +} + +/// 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 { + let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() }; + Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style)))) +} + +/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it. +fn square_and(front: Option, effect: &str) -> Node { + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red); + let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect()); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g +} + +/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. +fn results(g: &Node) -> Vec> { + pathfinder_children(g, crate::text_outliner()).unwrap() +} + +#[test] +fn type_changes_the_result_of_every_pathfinder_effect() { + if skip_without_craft_fonts() { + return; + } + let glyphs = crate::outline_text(&type_h()).unwrap(); + let b = glyphs.geometric_bounds().unwrap(); + assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); + // `measure` gives the number of results and their total area. + let measure = |front: Option, effect: &str| { + let r = results(&square_and(front, effect)); + (r.len(), r.iter().map(|n| area(n)).sum::()) + }; + let unchanged: Vec<_> = PATHFINDER_EFFECTS + .iter() + .map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id))) + .filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0) + .collect(); + assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}"); + // The shape modes take the glyph's whole area: the part inside the square and the part outside. + let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum(); + let with_type = |id: &str| measure(Some(type_h()), id).1; + assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}"); + assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); + // Outline art nested one group deeper takes part as well. + let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); + let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); + assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); +} + +#[test] +fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { + if skip_without_craft_fonts() { + return; + } + let mut t = type_h(); + (t.opacity, t.blend) = (0.5, BlendMode::Multiply); + let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); + let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal); + let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply); + let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect(); + assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}"); + // In the shape modes a group with the type in front is one shape, styled like the type. + let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); + let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green); + let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]); + let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect(); + assert_eq!(added, [glyph]); +} + +#[test] +fn a_random_transform_on_a_group_moves_its_members_together() { + let mut g = group_with("distort.transform"); + g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true}); + let art = evaluate_container(None, &g).unwrap(); + let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap(); + let (a, b) = (corner(0), corner(1)); + assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}"); + assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}"); +} + +/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`, +/// in a group with a square and Offset Path 0, which leaves every path as it is. +fn type_in_group(style: impl FnOnce(&mut Node)) -> Node { + let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts); + let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text))); + style(&mut t); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); + g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + (*art.children().unwrap()[1]).clone() +} + +#[test] +fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { + if skip_without_craft_fonts() { + return; + } + use vectorcraft_color::BlendMode; + let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); + assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); + assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply)); + // Its opacity mask, its own shadow, its knockout shape option and its visibility stay. + let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + let mask = vectorcraft_doc::OpacityMask::new(mask_art, true); + assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask)); + let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }; + assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]); + assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape); + assert!(!type_in_group(|t| t.visible = false).visible); + // Its own Transform moves its outlines 30 pt before the group's effect reshapes them. + let x0 = |n: Node| n.geometric_bounds().unwrap().x0; + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }; + let moved = x0(type_in_group(|t| t.appearance.effects.push(transform))); + assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}"); +} + +#[test] +fn hidden_effect_or_plain_group_is_untouched() { + let mut g = group_with("pathfinder.add"); + g.appearance.effects[0].visible = false; + assert!(pathfinder_children(&g, None).is_none()); + assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); +} + +#[test] +fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { + if skip_without_craft_fonts() { + return; + } + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + // The type's characters fit the gradient to its layout; the square has it placed across itself. + let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style); + let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text)))); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0))); + let start = |n: &Node| match n.appearance.fill_paint() { + Paint::Gradient(g) => g.geom.unwrap().start.x, + p => panic!("a gradient: {p:?}"), + }; + let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]); + let mut g = Node::group(NodeId(10), vec![square, t]); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let members = art.children().unwrap(); + let after = start(&members[1].children().unwrap()[0]); + assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); + assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); +} + +#[test] +fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; + for (k, paint) in paints.into_iter().enumerate() { + let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for (i, c) in copies.iter().enumerate() { + // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. + assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); + match c.appearance.fill_paint() { + Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), + Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), + p => panic!("a gradient: {p:?}"), + } + } + } +} + +/// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit +/// pitch, each crossing every bar of the other direction: n² crossings. +fn crossing_bars(n: usize, effect: &str) -> Node { + let len = 4.0 * n as f64; + let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut children = vec![]; + for i in 0..n { + let at = 4.0 * i as f64; + children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone()))); + } + for i in 0..n { + let at = 4.0 * i as f64; + let id = NodeId(100 + (n + i) as u64); + children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone()))); + } + let mut g = Node::group(NodeId(10), children); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g +} + +#[test] +fn pathfinder_past_its_budget_draws_the_source_art() { + // n² crossings past the cap: no evaluation (#3591), the source art as it is. + for n in [130, 800] { + let g = crossing_bars(n, "pathfinder.divide"); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); + let ids: Vec = out.iter().map(|c| c.id).collect(); + let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(ids, src, "{n} x {n} bars"); + assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data())); + } +} + +#[test] +fn pathfinder_within_its_budget_still_divides() { + // Each bar is cut into one piece per crossing plus the n pieces between and past them, so + // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly + // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. + for n in [8, 128] { + let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); + assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); + } +} + +#[test] +fn pathfinder_on_many_stacked_bars_still_runs() { + // 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999 + // overlapping pairs are within the budget, so Add still merges them into one path. + let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let children = (0..3000) + .map(|i| { + let at = 1.5 * i as f64; + Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone())) + }) + .collect(); + let mut g = Node::group(NodeId(10), children); + g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 1); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ce1b1d58d..e3055da0b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -57,10 +57,6 @@ mod warp; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_scale; -#[cfg(test)] -mod tests_transformfx; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; @@ -90,1179 +86,19 @@ pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, validate_three_d, view_options, }; +pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; pub use warp::{WarpStyle, warp_point}; -/// Catalogue entry for one effect. -#[derive(Clone, Debug, PartialEq)] -pub struct EffectInfo { - /// Stable id, e.g. `distort.roughen`. - pub id: &'static str, - /// Menu label (with the ellipsis when the effect has a dialog). - pub label: &'static str, - /// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`. - pub menu: &'static [&'static str], - /// Human/agent-readable parameter documentation. - pub params: &'static str, - /// Dialog defaults. - pub defaults: Value, - /// Raster (painted) rather than geometry effect. - pub raster: bool, - /// The parameters that are distances in points (Scale Strokes & Effects scales them). - pub lengths: Lengths, -} +mod catalog; +mod geometry; +mod params; -/// Which of an effect's parameters are distances in points. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct Lengths { - /// Always distances. - pub always: &'static [&'static str], - /// Distances while the effect's `relative` parameter is false (percentages of the object's - /// size otherwise). - pub absolute: &'static [&'static str], -} - -impl Lengths { - /// The distance parameters while the effect's `relative` parameter is `relative`. - pub fn keys(&self, relative: bool) -> impl Iterator + '_ { - self.always.iter().chain(if relative { &[][..] } else { self.absolute }).copied() - } -} - -/// Is parameter `key` of effect `id` a distance in points while its `relative` parameter is -/// `relative`? -pub fn is_length(id: &str, key: &str, relative: bool) -> bool { - catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) -} - -/// The distance parameters of effect `id` (none for unknown effects). -fn lengths_of(id: &str) -> Lengths { - let always = |always| Lengths { always, absolute: &[] }; - match id { - "convertToShape.rectangle" | "convertToShape.ellipse" => always(&["extraW", "extraH", "width", "height"]), - "convertToShape.roundedRectangle" => always(&["extraW", "extraH", "width", "height", "radius"]), - "distort.roughen" | "distort.zigZag" => Lengths { always: &[], absolute: &["size"] }, - "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, - "distort.transform" => always(&["moveH", "moveV"]), - "path.offsetPath" => always(&["offset"]), - "threeD.revolve" => always(&["offset"]), - "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), - "threeD.inflate" => always(&["depth"]), - "path.outlineStroke" => always(&["width"]), - "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), - "stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]), - "stylize.dropShadow" => always(&["x", "y", "blur"]), - "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), - "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), - "brushStrokes.accentedEdges" => always(&["edgeWidth"]), - "artistic.paletteKnife" => always(&["strokeSize"]), - "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), - "brushStrokes.spatter" => always(&["sprayRadius"]), - "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), - "brushStrokes.sumiE" => always(&["strokeWidth"]), - "pixelate.colorHalftone" => always(&["maxRadius"]), - "pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]), - "texture.craquelure" => always(&["crackSpacing"]), - "texture.mosaicTiles" => always(&["tileSize"]), - "texture.patchwork" => always(&["squareSize"]), - "texture.stainedGlass" => always(&["cellSize"]), - "sketch.graphicPen" => always(&["strokeLength"]), - "sketch.halftonePattern" => always(&["size"]), - "sketch.notePaper" => always(&["relief"]), - "sketch.tornEdges" => always(&["smoothness"]), - "sketch.waterPaper" => always(&["fiberLength"]), - TRAP => always(&["thickness", "precision"]), - RASTERIZE => always(&["addAround"]), - _ => Lengths::default(), - } -} - -const WARP_DOC: &str = - "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; -const DT: &[&str] = &["Effect", "Distort & Transform"]; -const PATH: &[&str] = &["Effect", "Path"]; -const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; -const STYLIZE: &[&str] = &["Effect", "Stylize"]; -const WARP: &[&str] = &["Effect", "Warp"]; -const BLUR: &[&str] = &["Effect", "Blur"]; -const ARTISTIC: &[&str] = &["Effect", "Artistic"]; -const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; -const DISTORT: &[&str] = &["Effect", "Distort"]; -const SHARPEN: &[&str] = &["Effect", "Sharpen"]; -const SKETCH: &[&str] = &["Effect", "Sketch"]; -const PIXELATE: &[&str] = &["Effect", "Pixelate"]; -const TEXTURE: &[&str] = &["Effect", "Texture"]; -const VIDEO: &[&str] = &["Effect", "Video"]; -const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; -const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; -const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; - -/// The warp styles in Illustrator's Style menu order: (id suffix, label). -pub const WARP_STYLES: [(&str, &str); 15] = [ - ("arc", "Arc…"), - ("arcLower", "Arc Lower…"), - ("arcUpper", "Arc Upper…"), - ("arch", "Arch…"), - ("bulge", "Bulge…"), - ("shellLower", "Shell Lower…"), - ("shellUpper", "Shell Upper…"), - ("flag", "Flag…"), - ("wave", "Wave…"), - ("fish", "Fish…"), - ("rise", "Rise…"), - ("fisheye", "Fisheye…"), - ("inflate", "Inflate…"), - ("squeeze", "Squeeze…"), - ("twist", "Twist…"), -]; - -/// Every supported effect with its dialog defaults. -pub fn effect_catalog() -> Vec { - let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; - let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; - let mut v = vec![ - g( - REVOLVE, - "Revolve…", - &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), - ), - g( - EXTRUDE, - "Extrude & Bevel…", - &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), - ), - g( - INFLATE, - "Inflate…", - &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), - ), - g( - ROTATE, - "Rotate…", - &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), - ), - g( - "convertToShape.rectangle", - "Rectangle…", - SHAPE, - "{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}", - json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), - ), - g( - "convertToShape.roundedRectangle", - "Rounded Rectangle…", - SHAPE, - "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}", - json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}), - ), - g( - "convertToShape.ellipse", - "Ellipse…", - SHAPE, - "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}", - json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), - ), - g( - "distort.freeDistort", - "Free Distort…", - DT, - "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", - json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), - ), - g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), - g( - "distort.roughen", - "Roughen…", - DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", - json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), - ), - g( - "distort.transform", - "Transform…", - DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", - json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), - ), - g( - "distort.tweak", - "Tweak…", - DT, - "{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}", - json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}), - ), - g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})), - g( - "distort.zigZag", - "Zig Zag…", - DT, - "{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}", - json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}), - ), - g( - "path.offsetPath", - "Offset Path…", - PATH, - "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", - json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), - ), - g( - "path.outlineObject", - "Outline Object", - PATH, - "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", - json!({}), - ), - g( - "path.outlineStroke", - "Outline Stroke", - PATH, - "{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads", - json!({}), - ), - g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), - g( - "stylize.scribble", - "Scribble…", - STYLIZE, - "{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", - json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}), - ), - r( - "stylize.dropShadow", - "Drop Shadow…", - STYLIZE, - "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", - json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), - ), - r( - "stylize.innerGlow", - "Inner Glow…", - STYLIZE, - "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}", - json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}), - ), - r( - "stylize.outerGlow", - "Outer Glow…", - STYLIZE, - "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}", - json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}), - ), - r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), - r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), - r( - "artistic.coloredPencil", - "Colored Pencil…", - ARTISTIC, - "{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}), - ), - r( - "artistic.cutout", - "Cutout…", - ARTISTIC, - "{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}), - ), - r( - "artistic.dryBrush", - "Dry Brush…", - ARTISTIC, - "{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), - ), - r( - "artistic.filmGrain", - "Film Grain…", - ARTISTIC, - "{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", - json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}), - ), - r( - "artistic.fresco", - "Fresco…", - ARTISTIC, - "{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), - ), - r( - "artistic.neonGlow", - "Neon Glow…", - ARTISTIC, - "{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}), - ), - r( - "artistic.paintDaubs", - "Paint Daubs…", - ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), - ), - r( - "artistic.paletteKnife", - "Palette Knife…", - ARTISTIC, - "{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}), - ), - r( - "artistic.plasticWrap", - "Plastic Wrap…", - ARTISTIC, - "{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", - json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}), - ), - r( - "artistic.posterEdges", - "Poster Edges…", - ARTISTIC, - "{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}), - ), - r( - "artistic.roughPastels", - "Rough Pastels…", - ARTISTIC, - "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", - json!({ - "strokeLength": 6.0, - "strokeDetail": 10.0, - "texture": "canvas", - "scaling": 100.0, - "relief": 4.0, - "lightDirection": "top", - "invert": false - }), - ), - r( - "artistic.smudgeStick", - "Smudge Stick…", - ARTISTIC, - "{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}), - ), - r( - "artistic.sponge", - "Sponge…", - ARTISTIC, - "{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}), - ), - r( - "artistic.underpainting", - "Underpainting…", - ARTISTIC, - "{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.", - json!({ - "brushSize": 6.0, - "textureCoverage": 16.0, - "texture": "canvas", - "scaling": 100.0, - "relief": 4.0, - "lightDirection": "top", - "invert": false - }), - ), - r( - "artistic.watercolor", - "Watercolor…", - ARTISTIC, - "{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}), - ), - r( - "blur.radial", - "Radial Blur…", - BLUR, - "{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre", - json!({"amount": 10.0, "method": "spin", "quality": "good"}), - ), - r( - "blur.smart", - "Smart Blur…", - BLUR, - "{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage", - json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}), - ), - r( - "brushStrokes.accentedEdges", - "Accented Edges…", - BRUSH_STROKES, - "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges", - json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}), - ), - r( - "brushStrokes.angledStrokes", - "Angled Strokes…", - BRUSH_STROKES, - "{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'", - json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}), - ), - r( - "brushStrokes.crosshatch", - "Crosshatch…", - BRUSH_STROKES, - "{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges", - json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}), - ), - r( - "brushStrokes.darkStrokes", - "Dark Strokes…", - BRUSH_STROKES, - "{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes", - json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}), - ), - r( - "brushStrokes.inkOutlines", - "Ink Outlines…", - BRUSH_STROKES, - "{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows", - json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}), - ), - r( - "brushStrokes.spatter", - "Spatter…", - BRUSH_STROKES, - "{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush", - json!({"sprayRadius": 10.0, "smoothness": 5.0}), - ), - r( - "brushStrokes.sprayedStrokes", - "Sprayed Strokes…", - BRUSH_STROKES, - "{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours", - json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}), - ), - r( - "brushStrokes.sumiE", - "Sumi-e…", - BRUSH_STROKES, - "{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape", - json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}), - ), - r( - "distort.diffuseGlow", - "Diffuse Glow…", - DISTORT, - "{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain", - json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}), - ), - r( - "distort.glass", - "Glass…", - DISTORT, - "{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", - json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}), - ), - r( - "distort.oceanRipple", - "Ocean Ripple…", - DISTORT, - "{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water", - json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}), - ), - r( - "sharpen.unsharpMask", - "Unsharp Mask…", - SHARPEN, - "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", - json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), - ), - r( - "sketch.basRelief", - "Bas Relief…", - SKETCH, - "{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}), - ), - r( - "sketch.chalkCharcoal", - "Chalk & Charcoal…", - SKETCH, - "{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.", - json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}), - ), - r( - "sketch.charcoal", - "Charcoal…", - SKETCH, - "{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}), - ), - r( - "sketch.chrome", - "Chrome…", - SKETCH, - "{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"detail": 4.0, "smoothness": 4.0}), - ), - r( - "sketch.conteCrayon", - "Conté Crayon…", - SKETCH, - "{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", - json!({ - "foregroundLevel": 12.0, - "backgroundLevel": 10.0, - "texture": "canvas", - "scaling": 100.0, - "relief": 4.0, - "lightDirection": "top", - "invert": false - }), - ), - r( - "sketch.graphicPen", - "Graphic Pen…", - SKETCH, - "{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.", - json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}), - ), - r( - "sketch.halftonePattern", - "Halftone Pattern…", - SKETCH, - "{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", - json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}), - ), - r( - "sketch.notePaper", - "Note Paper…", - SKETCH, - "{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", - json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}), - ), - r( - "sketch.photocopy", - "Photocopy…", - SKETCH, - "{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"detail": 7.0, "darkness": 25.0}), - ), - r( - "sketch.plaster", - "Plaster…", - SKETCH, - "{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}), - ), - r( - "sketch.reticulation", - "Reticulation…", - SKETCH, - "{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}), - ), - r( - "sketch.stamp", - "Stamp…", - SKETCH, - "{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"lightDarkBalance": 25.0, "smoothness": 5.0}), - ), - r( - "sketch.tornEdges", - "Torn Edges…", - SKETCH, - "{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}), - ), - r( - "sketch.waterPaper", - "Water Paper…", - SKETCH, - "{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", - json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}), - ), - r( - "stylize.glowingEdges", - "Glowing Edges…", - STYLIZE, - "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black", - json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}), - ), - r( - "pixelate.colorHalftone", - "Color Halftone…", - PIXELATE, - "{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell", - json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}), - ), - r( - "pixelate.crystallize", - "Crystallize…", - PIXELATE, - "{cellSize: pt 3..300 (10)} redraws the object as polygon crystals of solid colour around random points about cellSize apart", - json!({"cellSize": 10.0}), - ), - r( - "pixelate.mezzotint", - "Mezzotint…", - PIXELATE, - "{type: \"fineDots\"|\"mediumDots\"|\"grainyDots\"|\"coarseDots\"|\"shortLines\"|\"mediumLines\"|\"longLines\"|\"shortStrokes\"|\"mediumStrokes\"|\"longStrokes\" (\"fineDots\")} turns each colour channel fully on or off against a random pattern of dots, lines or strokes: fully saturated colours", - json!({"type": "fineDots"}), - ), - r( - "pixelate.pointillize", - "Pointillize…", - PIXELATE, - "{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas", - json!({"cellSize": 5.0}), - ), - r( - "texture.craquelure", - "Craquelure…", - TEXTURE, - "{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones", - json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}), - ), - r( - "texture.grain", - "Grain…", - TEXTURE, - "{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white", - json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}), - ), - r( - "texture.mosaicTiles", - "Mosaic Tiles…", - TEXTURE, - "{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them", - json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}), - ), - r( - "texture.patchwork", - "Patchwork…", - TEXTURE, - "{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left", - json!({"squareSize": 4.0, "relief": 8.0}), - ), - r( - "texture.stainedGlass", - "Stained Glass…", - TEXTURE, - "{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart", - json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}), - ), - r( - "texture.texturizer", - "Texturizer…", - TEXTURE, - "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", - json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}), - ), - r( - "video.deinterlace", - "De-Interlace…", - VIDEO, - "{eliminate: \"odd\"|\"even\" (\"odd\"; which field lines are taken out), create: \"duplication\"|\"interpolation\" (\"duplication\"; a line taken out becomes a copy of the line above, or the average of the lines above and below)} the field lines are the rows of the document's raster grid (Document Raster Effects Settings › Resolution), numbered from 1 at the top of the page", - json!({"eliminate": "odd", "create": "duplication"}), - ), - r( - "video.ntscColors", - "NTSC Colors", - VIDEO, - "{} makes colours a television signal can't carry (luma plus chroma past 110 % of white, or luma minus chroma below −20 %) less saturated, keeping their brightness", - json!({}), - ), - ]; - v.extend([ - g( - "adjust.brightnessContrast", - "Brightness/Contrast…", - ADJUST, - "{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)", - json!({"brightness": 0.0, "contrast": 0.0}), - ), - g( - "adjust.curves", - "Curves…", - ADJUST, - "{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", - json!({"points": "0,0 128,128 255,255", "channel": "rgb"}), - ), - g( - "adjust.hueSaturation", - "Hue/Saturation…", - ADJUST, - "{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does", - json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}), - ), - g( - "adjust.levels", - "Levels…", - ADJUST, - "{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", - json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}), - ), - g( - "adjust.shiftToColor", - "Shift to Color…", - ADJUST, - "{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does", - json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}), - ), - g( - "adjust.temperatureTint", - "Temperature/Tint…", - ADJUST, - "{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does", - json!({"temperature": 0.0, "tint": 0.0}), - ), - ]); - for (suffix, label) in WARP_STYLES { - let id: &'static str = match suffix { - "arc" => "warp.arc", - "arcLower" => "warp.arcLower", - "arcUpper" => "warp.arcUpper", - "arch" => "warp.arch", - "bulge" => "warp.bulge", - "shellLower" => "warp.shellLower", - "shellUpper" => "warp.shellUpper", - "flag" => "warp.flag", - "wave" => "warp.wave", - "fish" => "warp.fish", - "rise" => "warp.rise", - "fisheye" => "warp.fisheye", - "inflate" => "warp.inflate", - "squeeze" => "warp.squeeze", - _ => "warp.twist", - }; - v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); - } - for (id, label, _) in PATHFINDER_EFFECTS { - if id == "pathfinder.softMix" { - v.push(g( - id, - label, - PATHFINDER, - "{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)", - json!({"mixingRate": 50.0}), - )); - } else { - v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); - } - } - v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); - v.push(g( - CROP_MARKS, - "Crop Marks", - &["Effect"], - "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", - json!({}), - )); - v.push(r( - RASTERIZE, - "Rasterize…", - &["Effect"], - "{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes", - json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}), - )); - v -} - -/// The catalogue built once, indexed by id (lookups run per effect per rendered frame). -fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInfo> { - static INDEX: std::sync::OnceLock> = std::sync::OnceLock::new(); - INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) -} - -/// Catalogue entry for `id` (built-in, or an installed plug-in effect). -pub fn effect_info(id: &str) -> Option { - catalog_index().get(id).cloned().or_else(|| plugin_info(id)) -} - -/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.`). -fn plugin_info(id: &str) -> Option { - use vectorcraft_plugins::registry::intern; - let p = vectorcraft_plugins::effect::installed(id)?; - let m = p.manifest(); - let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; - Some(EffectInfo { - id: intern(id), - label: intern(&format!("{}{dots}", m.name)), - menu: PLUGINS, - params: intern(&m.params_doc()), - defaults: Value::Object(m.defaults()), - raster: false, - lengths: Lengths::default(), - }) -} - -/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id. -pub fn plugin_effects() -> Vec { - vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect() -} - -/// Dialog defaults for `id` (`None` for unknown effects). -pub fn default_params(id: &str) -> Option { - match catalog_index().get(id) { - Some(e) => Some(e.defaults.clone()), - None => plugin_info(id).map(|e| e.defaults), - } -} - -/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see -/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and -/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`. -pub fn merged_params(id: &str, params: &Value) -> Value { - let mut out = match catalog_index().get(id).map(|e| &e.defaults) { - Some(Value::Object(m)) => m.clone(), - Some(_) => Map::new(), - None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(), - }; - // Upgraded before the defaults are under it: a default angle is no angle given. - if let Value::Object(p) = upgrade_params(id, params) { - out.extend(p); - } - Value::Object(out) -} - -/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 % -/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light -/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height -/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its -/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of -/// then) become the current ones that show exactly the same, unless those are given too, which win. -/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). -pub fn upgrade_params(id: &str, params: &Value) -> Value { - match params { - Value::Object(m) => { - let mut m = m.clone(); - upgrade_perspective(id, &mut m); - upgrade_light(id, &mut m); - upgrade_scribble(id, &mut m); - Value::Object(m) - } - other => other.clone(), - } -} - -fn upgrade_light(id: &str, m: &mut Map) { - if !is_three_d(id) { - return; - } - let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation")); - if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") { - return; - } - let degrees = |v: Option, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default); - let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0)); - m.insert("lightRotation".into(), json!(rotation)); - m.insert("lightHeight".into(), json!(height)); - m.insert("lightBehind".into(), json!(behind)); -} - -fn upgrade_perspective(id: &str, m: &mut Map) { - if !is_three_d(id) { - return; - } - let Some(strength) = m.remove("perspective") else { return }; - if !m.contains_key("perspectiveAngle") { - let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0); - m.insert("perspectiveAngle".into(), json!(angle)); - } -} - -fn upgrade_scribble(id: &str, m: &mut Map) { - if id != "stylize.scribble" { - return; - } - if let Some(variation) = m.remove("variation") { - for key in ["overlapVariation", "spacingVariation"] { - m.entry(key).or_insert_with(|| variation.clone()); - } - } - if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) { - let wrapped = angle.rem_euclid(360.0); - m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped })); - } -} - -/// A new effect record with default parameters (overridden by `params`). -pub fn new_effect(id: &str, params: &Value) -> Option { - if !catalog_index().contains_key(id) { - vectorcraft_plugins::effect::installed(id)?; - } - Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) -} - -/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see -/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the -/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent. -pub fn scale_effect(e: &mut Effect, s: f64) { - let Some(info) = catalog_index().get(e.id.as_str()) else { return }; - let merged = merged_params(&e.id, &e.params); - let relative = util::flag(&merged, "relative", false); - for k in info.lengths.keys(relative) { - let v = util::num(&merged, k, f64::NAN); - if v.is_finite() { - if !e.params.is_object() { - e.params = Value::Object(Map::new()); - } - e.params[k] = json!(v * s); - } - } -} - -/// Is `id` a raster (painted) effect? -pub fn is_raster(id: &str) -> bool { - matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE) - || 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 -/// 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 - && !is_three_d(id) - && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) -} - -/// Any visible geometry effect in the list? -pub fn has_geometry(effects: &[Effect]) -> bool { - effects.iter().any(|e| e.visible && is_geometry(&e.id)) -} - -/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws -/// what the plug-in installed under its id draws ([`plugin_revision`]). -pub fn has_plugin_effect(n: &Node) -> bool { - let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some(); - n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) -} - -/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. -pub fn plugin_revision() -> u64 { - vectorcraft_plugins::registry::revision() -} - -/// Context the geometry effects may need from the object's appearance. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct GeomContext<'a> { - /// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment, - /// profile and arrowheads): the stroke the effect sits on, else the object's top painted - /// stroke. `None` outlines a plain 1 pt stroke. - pub stroke: Option<&'a StrokeLayer>, - /// The object's fill rule (inside and outside alignment). - pub rule: FillRule, - /// The seed of the Transform effect's Random: the object's id, so each object varies its own - /// way and keeps its result on every redraw. - pub seed: u64, - /// The object's appearance, which live-effect plug-ins are given read only (their fills and - /// strokes). - pub appearance: Option<&'a Appearance>, - /// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`: - /// the object's effects. - pub item: Option, -} - -impl<'a> GeomContext<'a> { - /// The context of `n`'s object-level effects. - pub fn of(n: &'a Node) -> Self { - let rule = match &n.kind { - NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, - _ => FillRule::NonZero, - }; - let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); - Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None } - } - - /// The context of the effects on `item`, one of the same object's appearance items: a - /// stroke's own effects outline that stroke, and a plug-in is told which item it is on. - pub fn item(self, item: &'a AppearanceItem) -> Self { - let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item))); - match item { - AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self }, - AppearanceItem::Fill(_) => Self { item: at, ..self }, - } - } -} - -/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type, -/// symbol or image art together; a path that already has more keeps its own count as the limit. -/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps -/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of -/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is -/// left out. -pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000; - -/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at -/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large -/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of -/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past -/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few -/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does. -pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000; - -/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the -/// renderer is estimated to draw it with. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct Budget { - pub anchors: usize, - pub lines: usize, -} - -impl Budget { - /// The budget of a chain of geometry effects. - pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES }; - - /// What `path` costs. - pub(crate) fn of(path: &PathData) -> Self { - Self { anchors: path.anchor_count(), lines: util::line_estimate(path) } - } - - /// This budget, raised to `cost` where that is more. - pub(crate) fn at_least(self, cost: Self) -> Self { - Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) } - } - - /// Does `cost` fit this budget? - pub(crate) fn fits(self, cost: Self) -> bool { - cost.anchors <= self.anchors && cost.lines <= self.lines - } - - /// What is left of this budget once `cost` is spent. - pub(crate) fn minus(self, cost: Self) -> Self { - Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) } - } - - /// This cost and `cost` together. - pub(crate) fn plus(self, cost: Self) -> Self { - Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) } - } -} - -/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the -/// reference box of the first effect (usually the object's geometric bounds); each later effect -/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect -/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts, -/// when they are more). -pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData { - apply_geometry_with(effects, path, bounds, &GeomContext::default()) -} - -/// [`apply_geometry`] with an explicit [`GeomContext`]. -pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData { - apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS) -} - -/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that -/// is more. -pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { - // `budget` raised to what `path` costs, measured once, and only for a path that has geometry - // effects. - let mut limit = None; - let mut cur = path.clone(); - let mut b = bounds; - let mut first = true; - for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) { - if !first { - b = cur.bounds().unwrap_or(b); - } - first = false; - if cur.is_empty() { - break; - } - let p = merged_params(&e.id, &e.params); - let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); - if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { - cur = next; - } - } - cur -} - -/// Convenience wrapper on kurbo paths. -pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath { - apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() -} - -/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its -/// result would not fit `budget` (the effects that multiply anchors build within its anchors). -pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option { - let out = apply_unchecked(id, p, path, b, ctx, budget.anchors); - let cost = Budget::of(&out); - if budget.fits(cost) { - return Some(out); - } - if cost.lines <= budget.lines || !builds_within(id) { - return None; - } - // Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer - // anchors as there were lines too many, and a tenth to spare, as segments vary in their lines. - let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize; - let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors)); - budget.fits(Budget::of(&out)).then_some(out) -} - -/// Does geometry effect `id` build within the anchors it is given? -fn builds_within(id: &str) -> bool { - matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.") -} - -/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. -fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { - use util::*; - if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { - let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; - return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); - } - match id { - "distort.freeDistort" => distort::free_distort(path, b, p, max), - "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), - "distort.roughen" => distort::roughen(path, b, p, max), - "distort.transform" => distort::transform_within(path, b, p, ctx.seed, max), - "distort.tweak" => distort::tweak(path, b, p), - "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max), - "distort.zigZag" => distort::zig_zag(path, b, p, max), - "path.offsetPath" => { - let off = num(p, "offset", 10.0).clamp(-1e5, 1e5); - if off.abs() < 1e-9 { - return path.clone(); - } - vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) - } - // Outline Object returns the geometry unchanged. Type reaches every geometry effect as its - // glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines. - "path.outlineObject" => path.clone(), - "path.outlineStroke" => { - let fallback; - let base = match ctx.stroke { - Some(s) => s, - None => { - fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0); - &fallback - } - }; - let st = match p.get("width").and_then(Value::as_f64) { - Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }), - None => std::borrow::Cow::Borrowed(base), - }; - if st.width.is_nan() || st.width <= 0.0 { - return path.clone(); - } - stroke::outline_region(path, ctx.rule, &st) - } - "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), - _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { - Some(style) => warp::warp(path, b, style, p, max), - None => path.clone(), - }, - } -} +pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, plugin_effects}; +pub(crate) use geometry::{Budget, apply_one}; +pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; +#[cfg(test)] +pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within}; +pub use params::{ + default_params, has_geometry, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_params, +}; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs new file mode 100644 index 000000000..dc54e8b51 --- /dev/null +++ b/crates/effects/src/params.rs @@ -0,0 +1,148 @@ +use super::catalog::{catalog_index, plugin_info}; +use super::*; + +/// Dialog defaults for `id` (`None` for unknown effects). +pub fn default_params(id: &str) -> Option { + match catalog_index().get(id) { + Some(e) => Some(e.defaults.clone()), + None => plugin_info(id).map(|e| e.defaults), + } +} + +/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see +/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and +/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`. +pub fn merged_params(id: &str, params: &Value) -> Value { + let mut out = match catalog_index().get(id).map(|e| &e.defaults) { + Some(Value::Object(m)) => m.clone(), + Some(_) => Map::new(), + None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(), + }; + // Upgraded before the defaults are under it: a default angle is no angle given. + if let Value::Object(p) = upgrade_params(id, params) { + out.extend(p); + } + Value::Object(out) +} + +/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 % +/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light +/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height +/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its +/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of +/// then) become the current ones that show exactly the same, unless those are given too, which win. +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). +pub fn upgrade_params(id: &str, params: &Value) -> Value { + match params { + Value::Object(m) => { + let mut m = m.clone(); + upgrade_perspective(id, &mut m); + upgrade_light(id, &mut m); + upgrade_scribble(id, &mut m); + Value::Object(m) + } + other => other.clone(), + } +} + +fn upgrade_light(id: &str, m: &mut Map) { + if !is_three_d(id) { + return; + } + let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation")); + if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") { + return; + } + let degrees = |v: Option, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default); + let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0)); + m.insert("lightRotation".into(), json!(rotation)); + m.insert("lightHeight".into(), json!(height)); + m.insert("lightBehind".into(), json!(behind)); +} + +fn upgrade_perspective(id: &str, m: &mut Map) { + if !is_three_d(id) { + return; + } + let Some(strength) = m.remove("perspective") else { return }; + if !m.contains_key("perspectiveAngle") { + let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0); + m.insert("perspectiveAngle".into(), json!(angle)); + } +} + +fn upgrade_scribble(id: &str, m: &mut Map) { + if id != "stylize.scribble" { + return; + } + if let Some(variation) = m.remove("variation") { + for key in ["overlapVariation", "spacingVariation"] { + m.entry(key).or_insert_with(|| variation.clone()); + } + } + if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) { + let wrapped = angle.rem_euclid(360.0); + m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped })); + } +} + +/// A new effect record with default parameters (overridden by `params`). +pub fn new_effect(id: &str, params: &Value) -> Option { + if !catalog_index().contains_key(id) { + vectorcraft_plugins::effect::installed(id)?; + } + Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) +} + +/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see +/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the +/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent. +pub fn scale_effect(e: &mut Effect, s: f64) { + let Some(info) = catalog_index().get(e.id.as_str()) else { return }; + let merged = merged_params(&e.id, &e.params); + let relative = util::flag(&merged, "relative", false); + for k in info.lengths.keys(relative) { + let v = util::num(&merged, k, f64::NAN); + if v.is_finite() { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params[k] = json!(v * s); + } + } +} + +/// Is `id` a raster (painted) effect? +pub fn is_raster(id: &str) -> bool { + matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE) + || 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 +/// 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 + && !is_three_d(id) + && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) +} + +/// Any visible geometry effect in the list? +pub fn has_geometry(effects: &[Effect]) -> bool { + effects.iter().any(|e| e.visible && is_geometry(&e.id)) +} + +/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws +/// what the plug-in installed under its id draws ([`plugin_revision`]). +pub fn has_plugin_effect(n: &Node) -> bool { + let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some(); + n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) +} + +/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. +pub fn plugin_revision() -> u64 { + vectorcraft_plugins::registry::revision() +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel.rs similarity index 100% rename from crates/effects/src/pixel/mod.rs rename to crates/effects/src/pixel.rs diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index cd09153c6..a68b7f579 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -358,5 +358,4 @@ pub fn solid_art(n: &Node) -> Option { } #[cfg(test)] -#[path = "tests_solids.rs"] mod tests; diff --git a/crates/effects/src/tests_solids.rs b/crates/effects/src/solids/tests.rs similarity index 100% rename from crates/effects/src/tests_solids.rs rename to crates/effects/src/solids/tests.rs diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index bdb5f8623..98bf2dd67 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -1,6 +1,6 @@ //! Convert to Shape, Round Corners and Scribble. -use serde_json::Value; +use serde_json::{Value, json}; use vectorcraft_geom::kurbo; use vectorcraft_geom::shapes::{self, KAPPA}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners}; @@ -144,6 +144,54 @@ fn crosses_outline(p: Point, q: Point, polys: &[Vec]) -> bool { }) } +/// One named Settings entry in Effect › Stylize › Scribble (public feature names; parameter sets +/// are chosen for VectorCraft). +pub struct ScribbleSetting { + pub id: &'static str, + pub label: &'static str, + pub params: Value, +} + +/// The Scribble dialog's Settings presets, in menu order. +pub fn scribble_settings() -> &'static [ScribbleSetting] { + static PRESETS: std::sync::OnceLock> = std::sync::OnceLock::new(); + PRESETS.get_or_init(|| { + [ + ("default", "Default", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5})), + ("childlike", "Childlike", json!({"angle": 45.0, "overlap": 2.0, "overlapVariation": 2.0, "strokeWidth": 2.0, "curviness": 15.0, "curvinessVariation": 30.0, "spacing": 4.0, "spacingVariation": 1.5})), + ("dense", "Dense", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 3.0, "curviness": 0.0, "curvinessVariation": 0.0, "spacing": 2.0, "spacingVariation": 0.0})), + ("loose", "Loose", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 1.0, "strokeWidth": 2.0, "curviness": 20.0, "curvinessVariation": 40.0, "spacing": 12.0, "spacingVariation": 3.0})), + ("moire", "Moire", json!({"angle": 0.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 1.0, "curviness": 80.0, "curvinessVariation": 10.0, "spacing": 3.0, "spacingVariation": 0.5})), + ("sharp", "Sharp", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 2.0, "curviness": 0.0, "curvinessVariation": 0.0, "spacing": 3.0, "spacingVariation": 0.0})), + ("sketch", "Sketch", json!({"angle": 30.0, "overlap": -1.0, "overlapVariation": 0.5, "strokeWidth": 2.0, "curviness": 10.0, "curvinessVariation": 15.0, "spacing": 6.0, "spacingVariation": 1.0})), + ("snarl", "Snarl", json!({"angle": 15.0, "overlap": 3.0, "overlapVariation": 3.0, "strokeWidth": 4.0, "curviness": 40.0, "curvinessVariation": 50.0, "spacing": 8.0, "spacingVariation": 4.0})), + ("swash", "Swash", json!({"angle": 60.0, "overlap": 5.0, "overlapVariation": 2.0, "strokeWidth": 5.0, "curviness": 30.0, "curvinessVariation": 20.0, "spacing": 10.0, "spacingVariation": 2.0})), + ("tight", "Tight", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 2.0, "spacingVariation": 0.2})), + ("zigzag", "Zigzag", json!({"angle": 90.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 3.0, "curviness": 0.0, "curvinessVariation": 0.0, "spacing": 8.0, "spacingVariation": 0.0})), + ] + .into_iter() + .map(|(id, label, params)| ScribbleSetting { id, label, params }) + .collect() + }) +} + +/// Which Settings preset matches `params` (seed ignored), if any. +pub fn scribble_setting_id(params: &Value) -> Option<&'static str> { + let obj = params.as_object()?; + scribble_settings().iter().find(|p| p.params.as_object().is_some_and(|want| scribble_params_match(obj, want))).map(|p| p.id) +} + +fn scribble_params_match(have: &serde_json::Map, want: &serde_json::Map) -> bool { + want.iter().all(|(k, v)| have.get(k).is_some_and(|a| scribble_param_eq(a, v))) +} + +fn scribble_param_eq(a: &Value, b: &Value) -> bool { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + } +} + /// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region /// of the shape, outlined to `strokeWidth` so the fill paints it. `overlapVariation` moves each /// line's ends along it by up to that many points, `spacingVariation` moves them across it, and diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ad66a2a97..3996794ac 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,9 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod scale; +mod transformfx; + fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } } @@ -922,6 +925,17 @@ fn scribble_reads_its_earlier_single_variation() { assert!(default_params("stylize.scribble").unwrap().get("variation").is_none()); } +#[test] +fn scribble_settings_presets_are_distinct_and_recognized() { + let presets = crate::scribble_settings(); + assert_eq!(presets.len(), 11); + assert_eq!(presets[0].id, "default"); + assert_eq!(crate::scribble_setting_id(&presets[0].params), Some("default")); + assert_eq!(crate::scribble_setting_id(&presets[9].params), Some("tight")); + let custom = json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 4.0, "spacingVariation": 0.5}); + assert_eq!(crate::scribble_setting_id(&custom), None); +} + #[test] fn scribble_angle_is_stored_above_minus_180_and_up_to_180() { for (given, stored) in [(270.0, -90.0), (-180.0, 180.0), (180.0, 180.0), (540.0, 180.0), (-270.0, 90.0), (-200.0, 160.0)] { diff --git a/crates/effects/src/tests_scale.rs b/crates/effects/src/tests/scale.rs similarity index 99% rename from crates/effects/src/tests_scale.rs rename to crates/effects/src/tests/scale.rs index 6e2c71689..061695e73 100644 --- a/crates/effects/src/tests_scale.rs +++ b/crates/effects/src/tests/scale.rs @@ -3,7 +3,7 @@ use serde_json::json; use vectorcraft_doc::Effect; -use super::*; +use crate::*; #[test] fn every_distance_parameter_is_a_parameter() { diff --git a/crates/effects/src/tests_transformfx.rs b/crates/effects/src/tests/transformfx.rs similarity index 100% rename from crates/effects/src/tests_transformfx.rs rename to crates/effects/src/tests/transformfx.rs diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 000000000..62438b2be --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,47 @@ +//! `cargo run --release -p vectorcraft-engine --example export_bench -- [FORMAT] [FILE…]` — wall +//! time of `document.export` (default SVG) for each file (default: the documents in `examples/`). +//! `ITERS` sets the runs per file (default 7); prints the median and the fastest run. +// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::time::Instant; + +use serde_json::json; +use vectorcraft_engine::Session; + +fn main() { + let mut args = std::env::args().skip(1); + let format = args.next().unwrap_or_else(|| "svg".into()); + let mut files: Vec = args.map(PathBuf::from).collect(); + if files.is_empty() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples"); + files = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "vectorcraft")).collect(); + files.sort(); + } + let iters: usize = std::env::var("ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1); + let out_dir = std::env::temp_dir().join(format!("vectorcraft-export-bench-{}", std::process::id())); + std::fs::create_dir_all(&out_dir).unwrap(); + let mut total = 0.0; + for file in &files { + let mut s = Session::new(); + s.execute("document.open", &json!({ "path": file.to_string_lossy() })).unwrap(); + let name = file.file_stem().unwrap().to_string_lossy().into_owned(); + let out = out_dir.join(format!("{name}.{format}")); + let p = json!({ "path": out.to_string_lossy(), "format": format }); + let mut ms: Vec = (0..iters) + .map(|_| { + let t = Instant::now(); + s.execute("document.export", &p).unwrap(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + ms.sort_by(f64::total_cmp); + let median = ms[ms.len() / 2]; + total += median; + let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + println!("{name}.{format}: median {median:.1} ms, min {:.1} ms ({bytes} bytes)", ms[0]); + } + println!("total of medians: {total:.1} ms"); + std::fs::remove_dir_all(&out_dir).ok(); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 99% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs index 9c5d38158..411f2004a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd.rs @@ -3,6 +3,7 @@ pub(crate) mod appearance; mod assets; mod attributes; +pub mod brushlib; mod brushsym; mod buildcmds; pub mod clipboard; @@ -271,6 +272,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(flatten::specs()); v.extend(stylelib::specs()); v.extend(symbollib::specs()); + v.extend(brushlib::specs()); v.extend(library::specs()); v.extend(expand::specs()); v.extend(attributes::specs()); @@ -564,6 +566,9 @@ pub use opacitymask::TransparencyInfo; /// Eyedropper Options (`Prefs::eyedropper`). pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs}; +/// Pathfinder Options (`Prefs::pathfinder`). +pub use pathops::{PATHFINDER_PRECISION_RANGE, PathfinderOptions}; + /// The Attributes panel's state ([`Session::attributes_info`]). pub use overprint::AttributesInfo; diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6fdced122..3e1173d8b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -21,6 +21,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::appearance::SharedStack; use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; @@ -74,7 +75,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} edit one fill/stroke of each target object's own appearance stack", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack", has_doc, set_item ), @@ -83,7 +84,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?} remove those fills/strokes from each target object's own stack", + "{index | indices: [..] (paint-order item indices), 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)} remove those fills/strokes from each target object's own stack", has_doc, remove_item ), @@ -101,7 +102,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), ids?|id?, target?} copy fills/strokes with their effects", + "{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", has_doc, duplicate_item ), @@ -110,7 +111,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?} 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\", 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 ), @@ -203,8 +204,11 @@ pub(crate) enum ItemTarget { /// The topmost fill or stroke (for effects: the object-level effects). Top, /// Item `index`. `explicit`: named by the `item` param, so it must exist and be of the edited - /// kind; otherwise it is the panel's active item, which applies only where it fits. - Item { index: usize, explicit: bool }, + /// kind; otherwise it is the panel's active item, which applies only where it fits. `aligned`: + /// `index` is a row of the first target's stack, and each other target edits the item equal to + /// it there ([`ItemTarget::per_target`]): the active item always is (it is a row of the first + /// selected object's stack), an explicit one with `aligned: true`. + Item { index: usize, explicit: bool, aligned: bool }, } /// Parse the `item` param of `cmd` (see the module docs). @@ -218,11 +222,11 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re Some(Value::Null) => Ok(ItemTarget::Top), Some(v) => v .as_u64() - .map(|i| ItemTarget::Item { index: index(i), explicit: true }) + .map(|i| ItemTarget::Item { index: index(i), explicit: true, aligned: aligned(p) }) .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), // The active item is a row of the first selected object's own stack. None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), - None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })), + None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })), } } @@ -240,17 +244,30 @@ impl ItemTarget { /// of its own kind, so a fill row leaves stroke edits on the topmost stroke of the painted /// leaves (as without an active item) and vice versa. Explicit items are kept (and checked). pub(crate) fn of_kind(self, s: &Session, fill: bool) -> Self { - let ItemTarget::Item { index, explicit: false } = self else { return self }; + let ItemTarget::Item { index, explicit: false, .. } = self else { return self }; let fits = s.active().and_then(|st| st.doc.node(*st.selection.subjects().first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill)); 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 + /// [`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> { + 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()] }; + 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() + } + /// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one. pub(crate) fn resolve(self, ap: &Appearance, fill: bool, cmd: &str) -> Result> { match self { ItemTarget::Top => Ok(None), - ItemTarget::Item { index, explicit } => match ap.item_of_kind(Some(index), fill) { + ItemTarget::Item { index, explicit, .. } => match ap.item_of_kind(Some(index), fill) { None if explicit => Err(bad( cmd, match ap.items.get(index) { @@ -268,12 +285,45 @@ impl ItemTarget { match self { ItemTarget::Top => Ok(None), ItemTarget::Item { index, .. } if index < ap.items.len() => Ok(Some(index)), - ItemTarget::Item { index, explicit: true } => Err(bad(cmd, format!("no appearance item {index}"))), + ItemTarget::Item { index, explicit: true, .. } => Err(bad(cmd, format!("no appearance item {index}"))), ItemTarget::Item { explicit: false, .. } => Ok(None), } } } +/// Does `p` say its item and effect indices are rows of the first target's stack (`aligned`), +/// which each other target edits where it has them (see [`ItemTarget::per_target`])? +pub(crate) fn aligned(p: &Value) -> bool { + p.get("aligned").and_then(Value::as_bool).unwrap_or(false) +} + +/// What the appearances of `ids` share, when there are several of them and they all exist. +pub(crate) fn shared_stack(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Option { + if ids.len() < 2 { + return None; + } + let aps: Vec<&Appearance> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect::>()?; + Some(SharedStack::of(&aps)) +} + +/// Do the appearances of `ids` differ (several objects whose stacks aren't all equal)? +pub(crate) fn appearances_differ(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + let mut aps = ids.iter().filter_map(|id| d.node(*id)).map(|n| &n.appearance); + let Some(first) = aps.next() else { return false }; + aps.any(|a| a != first) +} + +/// Indices `idx` of the first target's items (aligned with `aligned`) in each of `ids`: the first +/// target's own, and in each other one the items equal to them there (see [`SharedStack`]), each +/// index on its own: one the targets don't share is left out of the others (never taken as the +/// same index there). Not aligned, or one target: the same indices in each. +pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { + match shared_stack(d, ids).filter(|_| aligned) { + Some(sh) => (0..ids.len()).map(|k| if k == 0 { idx.to_vec() } else { idx.iter().filter_map(|i| sh.item_in(*i, k)).collect() }).collect(), + None => vec![idx.to_vec(); ids.len()], + } +} + /// Whether the `target` param asks for the contents of groups and layers (see the module docs). fn targets_contents(p: &Value) -> Result { match p.get("target") { @@ -352,9 +402,11 @@ pub(crate) fn edit_items_then( return Ok(()); } let before = s.doc()?.doc.clone(); + let per = item.per_target(&before, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); - for id in ids { + for (id, item) in ids.iter().zip(&per) { + let Some(item) = item else { continue }; let Some(n) = d.node_mut(*id) else { continue }; let index = item.resolve(&n.appearance, fill, cmd)?; f(n, index)?; @@ -562,8 +614,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { // When every object comes out as it was (for example, when none has item `index`), the document // and its undo history stay as they are. let before = s.doc()?.doc.clone(); + let per = items_per_target(&before, &ids, &[idx], aligned(p)); super::overprint::edit_counted(s, "Appearance", |d| { - for id in &ids { + for (id, at) in ids.iter().zip(&per) { + let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; if p.get("weight").is_none() && let Some(pa) = &paint @@ -625,9 +679,12 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { // When no object has an item at any of the indices, the document and its undo history stay as // they are. let before = s.doc()?.doc.clone(); + let per = items_per_target(&before, &ids, &idx, aligned(p)); super::overprint::edit_counted(s, "Remove Item", |d| { - for id in &ids { + for (id, idx) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { + let mut idx = idx.clone(); + idx.sort_unstable(); // From the top down, so the lower indices stay valid. for &i in idx.iter().rev() { n.appearance.remove_item(i); @@ -650,11 +707,18 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let ids = require_targets(s, p, C)?; // Where the first object's copy landed (the active item lives in the first object's stack). let mut first_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")); + } + let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p)); s.edit("Duplicate Item", |d, _| { let mut any = false; - for id in &ids { + for (id, idx) in ids.iter().zip(&per) { let Some(n) = d.node_mut(*id) else { continue }; let ap = &mut n.appearance; + 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() { @@ -697,6 +761,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result { 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")); + } 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 diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs new file mode 100644 index 000000000..1c7de9978 --- /dev/null +++ b/crates/engine/src/cmd/brushlib.rs @@ -0,0 +1,502 @@ +//! Brush libraries (Window → Brush Libraries, the Brushes panel's library button): read-only sets +//! of brushes the library panel shows. A library is a document holding the brushes with the +//! resources their art uses (patterns, symbols, swatches, graphic styles, images); saved, it is a +//! native `.vectorcraft` file, which File › Open also opens. The built-in libraries are generated +//! here ([`BUILTIN`]); User Defined ones are the `.vectorcraft` files in the user library folder +//! ([`Libraries`]), and Other Library… loads the brushes of any document VectorCraft opens. +//! `brush.addFromLibrary` copies brushes into the document; `brush.saveLibrary` writes the +//! document's. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock}; + +use serde_json::{Value, json}; +use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes}; + +use super::brushsym::{apply_brush, brush_targets, roots, stroke_paint}; +use super::clipboard::Clipboard; +use super::clipboard::resources::SwatchChoices; +use super::patterncmds::add_pattern; +use super::swatch::str_list; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; +use super::*; + +/// The extension of saved brush libraries: native documents. +pub const BRUSHES_EXT: &str = vectorcraft_format::EXTENSION; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "brush.libraries", + "Brush Libraries", + [], + None, + "{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vectorcraft files in the user library folder, rescanned now)|\"loaded\" (brush.loadLibrary), count, path?}], userFolder} every brush library the library panel opens, in menu order", + always, + list + ), + cmd!( + query "brush.library", + "Brush Library", + [], + None, + "{library: id or name} → {id, name, category, brushes: [{name, type}] (as brush.list)}", + always, + get + ), + cmd!( + "brush.addFromLibrary", + "Add to Brushes", + ["Window", "Brush Libraries"], + None, + "{library: id or name, name? | names?: [brush names in the library] (default: all of it), apply?: false (also apply the first one to paths, as brush.apply does), ids?|id? (the paths apply takes; default the selected ones), swatchConflict? (as edit.paste)} copy library brushes into the document's Brushes as one undo step (with the apply; named Apply Brush when no brush is added and it only applies one), with the resources their art uses (patterns, symbols, swatches, graphic styles, images). A brush the document already has (same name and definition) isn't added again; a name another brush has gets a number (\"Ribbon 2\"). The first one becomes the current brush → {library, names: [each brush's name in the document], added: [names added], existing: [names already there], applied: [ids of the paths it was applied to]}", + has_doc, + add + ), + cmd!( + "brush.saveLibrary", + "Save Brush Library…", + ["Window", "Brushes"], + None, + "{path?, names?: [brush names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's brushes, with the resources their art uses, as a brush library: a .vectorcraft document holding only them → {path, count, library?: id when saved to the user folder}; without path or user → {dataBase64: the file's bytes, count}", + has_doc, + save + ), + cmd!( + "brush.loadLibrary", + "Other Library…", + ["Window", "Brush Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load the brushes of any document VectorCraft opens (see document.formats; one without brushes of its own has the default ones), a saved brush library included, for the library panel (Window → Brush Libraries lists it until the app quits; a file of the user library folder, or a file with the same bytes as one there, opens as that User Defined library) → {library: id, name, count}", + always, + load + ), + ] +} + +/// A brush library: its name, its brushes (in panel order) and a document holding them with the +/// resources their art uses; the document has no art of its own. +#[derive(Clone, Debug)] +pub struct BrushLibrary { + pub name: String, + pub brushes: Vec, + pub doc: Document, +} + +impl Default for BrushLibrary { + fn default() -> Self { + Self { name: String::new(), brushes: vec![], doc: Document::new(100.0, 100.0) } + } +} + +impl BrushLibrary { + pub fn len(&self) -> usize { + self.brushes.len() + } + pub fn is_empty(&self) -> bool { + self.brushes.is_empty() + } + pub fn brush(&self, name: &str) -> Option<&Brush> { + self.brushes.iter().find(|b| b.name == name) + } + + /// Document `src`'s brushes `names` (empty: all of them, in panel order) as a library named + /// `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)) { + return Err(format!("no brush `{n}`")); + } + let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name)); + let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect()); + let mut doc = clip.to_document(); + for l in &mut doc.layers { + if let Some(ch) = Arc::make_mut(l).children_mut() { + ch.clear(); + } + } + // 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(); + brush::store(&mut doc, &brushes); + doc.title = name.clone(); + Ok(Self { name, brushes, doc }) + } +} + +impl LibraryFile for BrushLibrary { + const EXTS: &'static [&'static str] = &[BRUSHES_EXT]; + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?; + BrushLibrary::from_document(&doc, &[], stem.to_string()) + } + fn name(&self) -> &str { + &self.name + } +} + +/// User Defined and loaded brush libraries. +pub type Libraries = super::swatchlib::Libraries; + +/// A path stroked with brush `name` (what the resource gathering follows). +fn stroke(name: &str) -> Node { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = ap.stroke_mut() { + st.brush = Some(name.into()); + } + let line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false); + Node::path(NodeId(0), PathData::single(line), ap) +} + +// ---------- built-in libraries ---------- + +/// A library generated in code. +pub struct BuiltinBrushLibrary { + pub id: &'static str, + pub name: &'static str, + make: fn() -> BrushLibrary, +} + +/// The built-in brush libraries, in menu order: original brushes generated in code, one library +/// per kind of brush. +pub const BUILTIN: &[BuiltinBrushLibrary] = &[ + BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, + BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, + BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, +]; + +/// Built-in library `id`, made once. +fn builtin_library(id: &str) -> Option> { + static MADE: LazyLock>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect()); + BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned()) +} + +/// Library `name` of `brushes`, `doc` holding the resources their art uses. +fn built(name: &str, mut doc: Document, brushes: Vec) -> BrushLibrary { + brush::store(&mut doc, &brushes); + doc.title = name.into(); + BrushLibrary { name: name.into(), brushes, doc } +} + +fn filled(p: PathData, paint: Paint) -> Node { + Node::path(NodeId(0), p, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() }) +} + +fn black(p: PathData) -> Node { + filled(p, Paint::solid(Color::BLACK)) +} + +fn group(children: Vec) -> Node { + Node::group(NodeId(0), children.into_iter().map(Arc::new).collect()) +} + +fn polygon(pts: &[(f64, f64)]) -> PathData { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// A band along y = `f(x)` for x in 0..`len`, `thick` pt thick, in `n` steps. +fn band(len: f64, thick: f64, n: usize, f: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| len * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, f(x) - thick / 2.0)).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, f(x) + thick / 2.0))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// An even-odd ring of radii `outer` and `inner` around `c`. +fn ring(c: Point, outer: f64, inner: f64) -> Node { + let disc = |r: f64| shapes::ellipse(Rect::new(c.x - r, c.y - r, c.x + r, c.y + r)); + let children = [disc(outer), disc(inner)].into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() }.into(); + n +} + +fn art(name: &str, art: Node, colorization: Colorization) -> Brush { + Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, colorization, ..Default::default() }) } +} + +/// Art Ribbons: art brushes, 100 pt long, drawn here; Striped Ribbon is filled with the library's +/// own pattern, Pinstripes. +fn art_ribbons() -> BrushLibrary { + let mut doc = Document::new(100.0, 100.0); + let stripe = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 6.0, 1.5)), { + let mut a = Appearance::default(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::from_hex("#2f80c9").unwrap_or(Color::BLACK))))); + a + }); + add_pattern(&mut doc, PatternDef { tile: Rect::new(0.0, 0.0, 6.0, 3.0), ..PatternDef::new("Pinstripes", vec![Arc::new(stripe)]) }); + let notched = polygon(&[(0.0, -3.0), (100.0, -3.0), (95.0, 0.0), (100.0, 3.0), (0.0, 3.0), (5.0, 0.0)]); + let double = group(vec![black(shapes::rectangle(Rect::new(0.0, -2.5, 100.0, -1.5))), black(shapes::rectangle(Rect::new(0.0, 1.5, 100.0, 2.5)))]); + let wave = band(100.0, 1.6, 64, |x| 2.5 * (x / 100.0 * 4.0 * std::f64::consts::TAU).sin()); + // Round and thick at the start, tapering to a point. + let teardrop = { + let n = 48; + let half = |x: f64| 3.5 * (1.0 - x / 100.0).powf(0.8) * (x / 4.0).min(1.0).sqrt(); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let striped = { + let mut n = + filled(shapes::rectangle(Rect::new(0.0, -4.0, 100.0, 4.0)), Paint::Pattern { pattern: "Pinstripes".into(), xf: Default::default() }); + n.appearance.set_stroke(Paint::solid(Color::from_hex("#1d4f7c").unwrap_or(Color::BLACK))); + n + }; + let brushes = vec![ + art("Notched Ribbon", black(notched), Colorization::Tints), + art("Double Line", double, Colorization::Tints), + art("Wave", black(wave), Colorization::Tints), + art("Teardrop", black(teardrop), Colorization::Tints), + art("Striped Ribbon", striped, Colorization::None), + ]; + built("Art Ribbons", doc, brushes) +} + +fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { + Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } +} + +/// Calligraphic Nibs: pen nibs of several shapes; Pressure Pen's size follows the pen pressure. +fn calligraphic_nibs() -> BrushLibrary { + let pressure = Brush { + name: "Pressure Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 30.0, + roundness: 60.0, + size: 5.0, + variation: [0.0, 0.0, 4.0], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let brushes = vec![ + nib("Fine Nib", 45.0, 25.0, 2.0), + nib("Broad Nib", 45.0, 15.0, 9.0), + nib("Chisel", 0.0, 8.0, 6.0), + nib("Round Pen", 0.0, 100.0, 4.0), + pressure, + ]; + built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes) +} + +fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brush { + let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() }); + Brush { name: name.into(), kind } +} + +/// An invisible rectangle that fixes a tile's width. +fn spacer(r: Rect) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::default()) +} + +/// Pattern Borders: pattern brushes of tiles drawn here; Beads has corner tiles. +fn pattern_borders() -> BrushLibrary { + let zigzag = black(polygon(&[(0.0, 1.4), (5.0, -2.6), (10.0, 1.4), (10.0, 2.6), (5.0, -1.4), (0.0, 2.6)])); + let beads = group(vec![black(shapes::rectangle(Rect::new(0.0, -0.3, 8.0, 0.3))), black(shapes::ellipse(Rect::new(1.5, -2.5, 6.5, 2.5)))]); + let dash_dot = group(vec![ + spacer(Rect::new(0.0, -1.0, 12.0, 1.0)), + black(shapes::rounded_rectangle(Rect::new(0.5, -0.75, 6.5, 0.75), 0.75)), + black(shapes::ellipse(Rect::new(8.5, -1.0, 10.5, 1.0))), + ]); + let scallops = black(band(10.0, 1.0, 20, |x| -3.0 * (x / 10.0 * std::f64::consts::PI).sin())); + let brushes = vec![ + border("Zigzag", zigzag, None, PatternFit::Stretch), + border("Beads", beads, Some(ring(Point::ZERO, 3.5, 1.5)), PatternFit::Stretch), + border("Dash Dot", dash_dot, None, PatternFit::AddSpace), + border("Scallops", scallops, None, PatternFit::Stretch), + ]; + built("Pattern Borders", Document::new(100.0, 100.0), brushes) +} + +fn scatter(name: &str, art: Node, s: Scatter) -> Brush { + Brush { name: name.into(), kind: BrushKind::Scatter(Scatter { art, ..s }) } +} + +/// Scatter Shapes: scatter brushes of shapes drawn here; Sparkles scatters the library's own +/// symbol, Sparkle. +fn scatter_shapes() -> BrushLibrary { + let mut doc = Document::new(100.0, 100.0); + // A four-pointed gold sparkle in the ±10 pt box symbol instances assume. + let points: Vec = (0..8) + .map(|i| { + let r = if i % 2 == 0 { 10.0 } else { 2.5 }; + let a = std::f64::consts::FRAC_PI_4 * i as f64; + Point::new(r * a.cos(), r * a.sin()) + }) + .collect(); + let gold = Color::from_hex("#e0a526").unwrap_or(Color::BLACK); + let sparkle = Node::path(doc.alloc_id(), PathData::single(SubPath::polyline(&points, true)), { + let mut a = Appearance::default(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold)))); + a + }); + doc.symbols.push(Symbol { 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() }; + let brushes = vec![ + scatter( + "Stars", + black(shapes::star(Point::ZERO, 4.0, 1.7, 5, 0.0)), + Scatter { + size: random(70.0, 130.0), + spacing: random(150.0, 220.0), + scatter: random(-80.0, 80.0), + rotation: random(-180.0, 180.0), + ..base.clone() + }, + ), + scatter("Rings", ring(Point::ZERO, 2.5, 1.5), Scatter { spacing: (140.0, 140.0), ..base.clone() }), + scatter( + "Triangles", + black(shapes::polygon(Point::ZERO, 3.0, 3, 0.0)), + Scatter { spacing: (120.0, 120.0), rotation: (90.0, 90.0), rotation_relative_to_path: true, ..base.clone() }, + ), + scatter( + "Sparkles", + instance, + Scatter { + size: random(60.0, 140.0), + spacing: random(110.0, 180.0), + scatter: random(-60.0, 60.0), + rotation: random(-45.0, 45.0), + colorization: Colorization::None, + ..base + }, + ), + ]; + built("Scatter Shapes", doc, brushes) +} + +// ---------- commands ---------- + +fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { + LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" } +} + +/// Every brush library, in menu order: built-in, User Defined, loaded. +pub fn libraries(s: &Session) -> Vec { + BUILTIN.iter().map(builtin_info).chain(s.brush_libraries.infos().cloned()).collect() +} + +/// Library `key` (an id, or a name in any case) with its info. +pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { + if let Some(found) = s.brush_libraries.get(key) { + return Some(found); + } + if let Some(b) = BUILTIN.iter().find(|b| b.id == key) { + return Some((builtin_info(b), builtin_library(key)?)); + } + let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id; + library(s, &id) +} + +fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { + let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see brush.libraries)"))?; + library(s, key).ok_or_else(|| bad(cmd, format!("no brush library `{key}` (see brush.libraries)"))) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.brush_libraries.rescan(); + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)}) + }) + .collect(); + Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "brush.library")?; + let brushes: Vec = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); + Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.addFromLibrary"; + let (info, lib) = library_param(s, p, C)?; + let mut names = str_list(p, "names"); + names.extend(str_param(p, "name").map(str::to_string)); + if let Some(n) = names.iter().find(|n| lib.brush(n).is_none()) { + return Err(bad(C, format!("no brush `{n}` in `{}`", lib.name))); + } + let mut picked: Vec = if names.is_empty() { lib.brushes.iter().map(|b| b.name.clone()).collect() } else { names }; + // Each once, in the order asked for. + let mut seen = HashSet::new(); + picked.retain(|n| seen.insert(n.clone())); + let apply = bool_or(p, "apply", false); + if apply && picked.is_empty() { + return Err(bad(C, format!("`{}` has no brushes to apply", lib.name))); + } + let choices = SwatchChoices::parse(C, p)?; + let clip = Clipboard::gather(&lib.doc, picked.iter().map(|n| stroke(n)).collect()); + // The typing `s.edit` would settle first is settled before the probe below, so the document + // the edit installs is the one it starts from. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let st = s.doc()?; + let before: HashSet = brush::library(&st.doc).into_iter().map(|b| b.name).collect(); + // A library brush's name in the document: its own, or the one the import gave it. + let target = |renamed: &[Value], n: &str| -> String { + let to = renamed.iter().find(|r| r["kind"] == "brush" && r["from"] == n).and_then(|r| r["to"].as_str()); + to.unwrap_or(n).to_string() + }; + // Where the brushes land, worked out on a copy that the edit then installs: nothing to add and + // nothing to apply is no edit, and a click that only applies brushes the document has is + // named for that. + let mut probe = (*st.doc).clone(); + let imported = clip.import_into(&mut probe, &choices, false); + let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); + let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] }; + let paint = stroke_paint(s); + if imported.added > 0 || !paths.is_empty() { + let label = if imported.added > 0 { "Add to Brushes" } else { "Apply Brush" }; + s.edit(label, |d, _| { + *d = probe; + if let Some(first) = doc_names.first() { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(first)); + apply_brush(d, &paths, first, &paint); + } + Ok(()) + })?; + } else if let Some(first) = doc_names.first() { + s.execute("brush.setCurrent", &json!({ "name": first }))?; + } + let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n)); + let applied: Vec = paths.iter().map(|id| id.0).collect(); + Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied})) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.saveLibrary"; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = BrushLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?; + if lib.is_empty() { + return Err(bad(C, "the document has no brushes to save")); + } + let mut out = json!({ "count": lib.len() }); + let bytes = vectorcraft_format::save_file(&lib.doc); + s.brush_libraries.write(p, &lib.name, BRUSHES_EXT, FileData::Binary(bytes), &mut out, C)?; + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.loadLibrary"; + let (info, lib) = s.brush_libraries.load(p, C, |bytes, file| { + let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?; + if loaded.preview_only { + return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) + })?; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 0b465c533..b677b3003 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -21,6 +21,20 @@ use super::swatch::{anchor, names_param}; use super::*; use crate::EngineError; +mod brushes; +mod symbols; + +pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; +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 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, +}; + const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; pub(crate) const CURRENT_SYMBOL: &str = "currentSymbol"; /// Half the instance box `Node::geometric_bounds` uses for symbol instances. @@ -117,7 +131,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%, 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. 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 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. 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 ), @@ -249,7 +263,7 @@ fn ids_json(ids: &[NodeId]) -> Value { Value::Array(ids.iter().map(|i| Value::from(i.0)).collect()) } -fn roots(s: &Session, p: &Value) -> Result> { +pub(crate) fn roots(s: &Session, p: &Value) -> Result> { match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { Some(mut v) => { // Explicit ids: only existing, non-layer objects, none inside another of them. @@ -266,86 +280,6 @@ fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`")) } -// ---------- brushes ---------- - -/// Brushable nodes (paths and compound paths) under `ids`. -fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { - let mut out = vec![]; - for id in ids { - let Some(n) = d.node(*id) else { continue }; - n.walk(&mut |c| { - let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); - let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); - if own && !in_compound && !out.contains(&c.id) { - out.push(c.id); - } - }); - } - out -} - -fn brush_list(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); - Ok(json!({ "brushes": list, "current": brush::current(d) })) -} - -fn brush_get(s: &mut Session, p: &Value) -> Result { - let name = name_param(p, "brush.get")?; - let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; - Ok(serde_json::to_value(b).unwrap_or_default()) -} - -/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). -fn brush_selection_art(s: &mut Session, p: &Value) -> Result { - let art = selection_art(s, p)?; - Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) -} - -fn brush_apply(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.apply"; - let name = name_param(p, C)?.to_string(); - if brush::find(&s.doc()?.doc, &name).is_none() { - return Err(bad(C, format!("no brush named `{name}`"))); - } - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let stroke = if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() }; - s.edit("Apply Brush", |d, _| { - d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { - n.appearance.set_stroke(stroke.clone()); - } - if let Some(st) = n.appearance.stroke_mut() { - if st.width <= 0.0 { - st.width = 1.0; - } - st.brush = Some(name.clone()); - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn brush_remove(s: &mut Session, p: &Value) -> Result { - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - s.edit("Remove Brush Stroke", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - for it in &mut n.appearance.items { - if let AppearanceItem::Stroke(st) = it { - st.brush = None; - } - } - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - /// Set a document preference outside the undo history. fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { let st = s.doc_mut()?; @@ -357,19 +291,6 @@ fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { Ok(()) } -fn brush_set_current(s: &mut Session, p: &Value) -> Result { - match str_param(p, "name") { - Some(n) => { - if brush::find(&s.doc()?.doc, n).is_none() { - return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); - } - set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; - } - None => set_unknown(s, brush::CURRENT_KEY, None)?, - } - ok() -} - /// The selection as one art node (a group for several objects), ids kept. pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; @@ -381,1033 +302,3 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { _ => Some(Node::group(NodeId(0), nodes.into_iter().map(Arc::new).collect())), }) } - -/// The definition fields that `params` change field by field when the brush has them as objects: -/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only -/// `start` keeps `end`. -const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; - -fn merge(base: &mut Value, patch: &Value) { - if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { - for (k, v) in pt { - match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { - (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { - for (f, fv) in fields { - inner.insert(f.clone(), fv.clone()); - } - } - _ => { - b.insert(k.clone(), v.clone()); - } - } - } - } -} - -/// Sets brush `to` on the strokes in `items` that paint with brush `from`. -fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { - for it in items { - if let AppearanceItem::Stroke(st) = it - && st.brush.as_deref() == Some(from) - { - st.brush = to.map(str::to_string); - } - } -} - -/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. -fn paints_with(n: &Node, name: &str) -> bool { - let mut hit = false; - vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); - }); - hit -} - -/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that -/// hold such a stroke, and those that hold them, are copied. -fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { - set_stroke_brush(&mut n.appearance.items, from, to); - if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) - && paints_with(&mask.art, from) - { - set_brush_in(Arc::make_mut(&mut mask.art), from, to); - } - for c in n.children_mut().into_iter().flatten() { - if paints_with(c, from) { - set_brush_in(Arc::make_mut(c), from, to); - } - } -} - -/// Returns the art a brush is made of ([`Brush::art`]) for editing. -fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { - use vectorcraft_brush::BrushKind; - match &mut b.kind { - BrushKind::Scatter(s) => vec![&mut s.art], - BrushKind::Art(a) => vec![&mut a.art], - BrushKind::Pattern(p) => { - std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() - } - BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], - } -} - -/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art -/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern -/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. -fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { - let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); - let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); - for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { - if paints_with(n, from) { - set_brush_in(Arc::make_mut(n), from, to); - } - } - for g in &mut d.graphic_styles { - set_stroke_brush(&mut g.appearance.items, from, to); - } - for n in lib.iter_mut().flat_map(brush_art_mut) { - set_brush_in(n, from, to); - } -} - -fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { - s.edit(label, |d, _| { - for (from, to) in renames { - rename_brush_strokes(d, &mut lib, from, to.as_deref()); - if brush::current(d).as_deref() == Some(from.as_str()) { - match to { - Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), - None => d.unknown.remove(brush::CURRENT_KEY), - }; - } - } - brush::store(d, &lib); - Ok(()) - }) -} - -fn brush_new(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.new"; - let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; - let art_key = match ty { - "calligraphic" | "bristle" => None, - "scatter" | "art" => Some("art"), - "pattern" => Some("side"), - o => return Err(bad(C, format!("unknown brush type `{o}`"))), - }; - let mut lib = brush::library(&s.doc()?.doc); - let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); - let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); - let name = brush::unique_name(&lib, &base_name); - if ty == "bristle" { - bristle_name_fits(C, &name)?; - } - let mut v = json!({"name": name, "type": ty}); - if let Some(params) = p.get("params") { - merge(&mut v, params); - v["name"] = json!(name); - v["type"] = json!(ty); - } - if let Some(k) = art_key - && v.get(k).is_none() - { - let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; - if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { - return Err(bad(C, "the selected art has no width")); - } - v[k] = serde_json::to_value(&art).unwrap_or_default(); - } - lib.push(parse_brush(C, v)?); - save_library(s, "New Brush", lib, &[])?; - Ok(json!({ "name": name })) -} - -/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. -fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { - let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); - if count > max { - return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); - } - Ok(()) -} - -/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). -fn parse_brush(cmd: &str, v: Value) -> Result { - Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) -} - -fn brush_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.delete"; - let names = names_param(p, C)?; - let mut lib = brush::library(&s.doc()?.doc); - if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) { - return Err(bad(C, format!("no brush named `{name}`"))); - } - lib.retain(|b| !names.contains(&b.name)); - let renames: Vec<(String, Option)> = names.into_iter().map(|n| (n, None)).collect(); - save_library(s, "Delete Brush", lib, &renames)?; - ok() -} - -/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their -/// type in library order: before the first brush of that type at or after `to` that isn't moving, -/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` -/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. -fn brush_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.move"; - const WITHIN: &str = "brushes move only within their type"; - let names = names_param(p, C)?; - check_numbers(p, &["to"], C)?; - let to = match p.get("to").filter(|v| !v.is_null()) { - Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), - None => None, - }; - let old = brush::library(&s.doc()?.doc); - let mut lib = old.clone(); - // `names_param` refuses an empty list, so the loop sets `kind`. - let mut kind = ""; - for name in &names { - let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - if !kind.is_empty() && kind != b.kind.type_id() { - return Err(bad(C, WITHIN)); - } - kind = b.kind.type_id(); - } - if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { - return Err(bad(C, WITHIN)); - } - let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); - let before = anchor(&same, to, &names, |b| &b.name); - let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); - let mut items = vec![]; - for name in &names { - if let Some(i) = lib.iter().position(|b| &b.name == name) { - items.push(lib.remove(i)); - } - } - let at = match before { - Some(a) => lib.iter().position(|b| b.name == a), - None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), - }; - let at = at.unwrap_or(first).min(lib.len()); - lib.splice(at..at, items); - if lib != old { - save_library(s, "Move Brush", lib, &[])?; - } - Ok(json!({ "moved": names.len() })) -} - -/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given -/// without `names`, names the copy. -fn brush_duplicate(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.duplicate"; - let names = names_param(p, C)?; - let before = brush::library(&s.doc()?.doc); - let mut lib = before.clone(); - let mut out = vec![]; - for name in &names { - let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); - let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); - b.name = brush::unique_name(&lib, &new_name); - out.push(b.name.clone()); - lib.push(b); - } - save_library(s, "Duplicate Brush", lib, &[])?; - Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) -} - -/// Adds the names of the brushes the strokes of `items` paint with to `out`. -fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { - for it in items { - if let AppearanceItem::Stroke(st) = it - && let Some(b) = &st.brush - { - out.push(b.clone()); - } - } -} - -/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to -/// `out`. -fn art_brushes(n: &Node, out: &mut Vec) { - vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); -} - -/// 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. -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)); - 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() { - if !used.contains(&name) { - lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); - used.insert(name); - } - } - let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); - Ok(json!({ "names": names })) -} - -fn brush_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.options"; - let name = name_param(p, C)?.to_string(); - let mut lib = brush::library(&s.doc()?.doc); - 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") { - merge(&mut v, params); - } - 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 { - if lib.iter().any(|b| &b.name == nn) { - return Err(bad(C, format!("a brush named `{nn}` exists"))); - } - // A `newName` that only trims the white space around the saved name is taken at any length. - if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { - bristle_name_fits(C, nn)?; - } - v["name"] = json!(nn); - } else { - v["name"] = json!(name); - } - v["type"] = json!(lib[i].kind.type_id()); - lib[i] = parse_brush(C, v)?; - let out = lib[i].name.clone(); - let rename = new_name.map(|nn| (name, Some(nn))); - save_library(s, "Brush Options", lib, rename.as_slice())?; - Ok(json!({ "name": out })) -} - -fn expand_brush(s: &mut Session, p: &Value) -> Result { - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); - if ids.is_empty() { - return Err(EngineError::Other("Expand: no brush strokes selected".into())); - } - let out = s.edit("Expand", |d, sel| { - let mut out = vec![]; - for id in &ids { - let Some(n) = d.node(*id).cloned() else { continue }; - let Some(g) = brush::expand(d, &n) else { continue }; - let g = d.reid(&g); - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let gid = g.id; - d.remove(*id)?; - d.insert(par, idx, g)?; - out.push(gid); - } - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&out) })) -} - -fn brush_freehand(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); - let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = (spec.run)(s, p)?; - let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; - s.edit("Paintbrush", |d, _| { - if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { - st.brush = Some(name.clone()); - } - Ok(()) - })?; - Ok(r) -} - -// ---------- symbols ---------- - -pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { - d.unknown - .get(SIZES_KEY) - .and_then(|m| m.get(name)) - .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) - .unwrap_or((2.0 * HALF, 2.0 * HALF)) -} - -pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { - let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); - if let Some(o) = m.as_object_mut() { - match size { - Some((w, h)) => o.insert(name.into(), json!([w, h])), - None => o.remove(name), - }; - } -} - -/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. -pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { - let c = at - reg.offset_from_center(w, h); - Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) -} - -fn registration_param(p: &Value, cmd: &str) -> Result> { - match p.get("registration") { - None => Ok(None), - Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), - } -} - -fn export_type_param(p: &Value, cmd: &str) -> Result> { - match str_param(p, "exportType") { - None => Ok(None), - Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), - } -} - -/// `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). -fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { - let on = match p.get("nineSlice") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), - }; - let guides = - match p.get("nineSliceGuides") { - None | Some(Value::Null) => None, - Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { - bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") - })?), - }; - Ok(match (on, guides) { - (Some(false), _) => Some(None), - (None, None) => None, - (_, Some(g)) => Some(Some(g)), - (Some(true), None) => Some(Some(current.unwrap_or_default())), - }) -} - -fn nine_slice_json(d: &Document, name: &str) -> Value { - d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) -} - -fn rename_natural_size(d: &mut Document, from: &str, to: &str) { - if from == to { - return; - } - let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); - set_natural_size(d, from, None); - if let Some(v) = size - && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) - { - set_natural_size(d, to, Some((w, h))); - } -} - -/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). -pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { - let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; - let (w, h) = (b.width().max(1.0), b.height().max(1.0)); - let c = b.center(); - let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); - art.transform(n, false); - Ok((art, (w, h), c)) -} - -fn instances_of(d: &Document, name: Option<&str>) -> Vec { - let mut v = vec![]; - d.walk(|n| { - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind - && name.is_none_or(|x| x == symbol) - { - v.push(n.id); - } - }); - v -} - -fn symbol_names(d: &Document) -> Vec { - d.symbols.iter().map(|s| s.name.clone()).collect() -} - -fn current_symbol(d: &Document) -> Option { - d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) -} - -fn symbol_list(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let list: Vec = d - .symbols - .iter() - .map(|sym| { - let (w, h) = natural_size(d, &sym.name); - json!({ - "name": sym.name, - "instances": instances_of(d, Some(&sym.name)).len(), - "size": [w, h], - "registration": d.symbol_registration(&sym.name).0, - "exportType": d.symbol_export_type(&sym.name).id(), - "nineSlice": nine_slice_json(d, &sym.name), - }) - }) - .collect(); - Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) -} - -/// 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()))?; - if separate(d, ids) { - let id = d.alloc_id(); - return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?); - } - let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; - let id = d.alloc_id(); - d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; - for r in ids { - d.remove(*r)?; - } - Ok(id) -} - -fn symbol_new(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.new"; - let ids = roots(s, p)?; - let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; - super::threads::keep_shown_text(&s.doc()?.doc, &mut art); - let (art, size, c) = normalize(art)?; - let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol")); - 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(); - // 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)); - 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 = super::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. - super::threads::leave_threads(d, &threaded); - 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) }), - ) -} - -fn symbol_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.options"; - let name = resolve_symbol(&s.doc()?.doc, p, C)?; - 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 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 let Some(ref n) = new_name - && n != &name - && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) - { - return Err(bad(C, format!("a symbol named `{n}` already exists"))); - } - let final_name = new_name.clone().unwrap_or_else(|| name.clone()); - s.edit("Symbol Options", |d, _| { - if let Some(ref n) = new_name - && n != &name - { - if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { - sym.name = n.clone(); - } - for id in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { - *symbol = n.clone(); - } - } - rename_natural_size(d, &name, n); - d.rename_symbol_registration(&name, n); - d.rename_symbol_export_type(&name, n); - d.rename_symbol_nine_slice(&name, n); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); - } - } - if let Some(r) = reg { - d.set_symbol_registration(&final_name, r); - } - if let Some(kind) = export { - d.set_symbol_export_type(&final_name, kind); - } - if let Some(g) = nine { - d.set_symbol_nine_slice(&final_name, g); - } - Ok(()) - })?; - let d = &s.doc()?.doc; - Ok(json!({ - "name": final_name, - "registration": d.symbol_registration(&final_name).0, - "exportType": d.symbol_export_type(&final_name).id(), - "nineSlice": nine_slice_json(d, &final_name), - })) -} - -fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { - match str_param(p, "name") { - Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), - Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), - None => { - current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) - } - } -} - -fn symbol_place(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; - let name = resolve_symbol(d, p, "symbol.place")?; - let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); - let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); - let size = natural_size(d, &name); - let reg = d.symbol_registration(&name); - let parent = s.doc()?.target_parent()?; - let id = s.edit("Place Symbol Instance", |d, sel| { - let id = d.alloc_id(); - d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?; - sel.set([id]); - Ok(id) - })?; - 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 { - 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(); - 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 mut art = d.reid(&art); - if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { - if !matches!(art.kind, NodeKind::Group { .. }) { - let id = d.alloc_id(); - art = Node::group(id, vec![Arc::new(art)]); - } - art.opacity *= inst.opacity; - art.blend = inst.blend; - } - Some(art) -} - -fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { - 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 (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let aid = art.id; - d.remove(*id)?; - d.insert(par, idx, art)?; - out.push(aid); - } - Ok(out) -} - -fn selected_instances(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let mut out = vec![]; - for id in roots(s, p)? { - if let Some(n) = d.node(id) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::SymbolInstance { .. }) { - out.push(c.id); - } - }); - } - } - Ok(out) -} - -fn symbol_break_link(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 out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&out) })) -} - -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) })) -} - -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}`"))); - } - let ids = roots(s, p)?; - let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; - super::threads::keep_shown_text(&s.doc()?.doc, &mut art); - let (art, size, c) = normalize(art)?; - let old = natural_size(&s.doc()?.doc, &name); - let id = s.edit("Redefine Symbol", |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) { - *xf *= fix; - } - } - let reg = d.symbol_registration(&name); - let threaded = super::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. - super::threads::leave_threads(d, &threaded); - sel.set([id]); - Ok(id) - })?; - Ok(json!({ "name": name, "id": id.0 })) -} - -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) { - 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)?; - } - 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(()) - })?; - ok() -} - -fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.duplicate"; - let name = name_param(p, C)?.to_string(); - let d = &s.doc()?.doc; - let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; - let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()); - let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string)); - let size = natural_size(d, &name); - let reg = d.symbol_registration(&name); - let export = d.symbol_export_type(&name); - 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) }); - set_natural_size(d, &new, Some(size)); - d.set_symbol_registration(&new, reg); - d.set_symbol_export_type(&new, export); - d.set_symbol_nine_slice(&new, nine); - Ok(()) - })?; - Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) -} - -fn symbol_replace(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.replace"; - 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}`"))); - } - let ids = selected_instances(s, p)?; - if ids.is_empty() { - return Err(EngineError::Other("no symbol instances selected".into())); - } - 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), - _ => continue, - }; - if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { - *symbol = name.clone(); - *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn symbol_set_current(s: &mut Session, p: &Value) -> Result { - let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; - set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; - ok() -} - -fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { - let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; - let ids = instances_of(&s.doc()?.doc, Some(&name)); - let n = ids.len(); - s.select(|_, sel| sel.set(ids))?; - Ok(json!({ "count": n })) -} - -// ---------- symbolism tools ---------- - -fn points(p: &Value, cmd: &str) -> Result> { - let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; - let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); - if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { - return Err(bad(cmd, "points must be finite [x,y] pairs")); - } - Ok(v) -} - -/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). -fn jitter(i: usize, q: Point) -> (f64, f64, f64) { - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; - for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { - h ^= v; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - h ^= h >> 29; - } - let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; - (f(0), f(1), f(2)) -} - -fn is_symbol_set(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") -} - -fn symbol_spray(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.spray"; - let pts = points(p, C)?; - let st = s.doc()?; - let name = resolve_symbol(&st.doc, p, C)?; - let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); - let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); - let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); - let alt = switch_or(p, "alt", false, C)?; - let size = natural_size(&st.doc, &name); - let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); - // Only a new Symbol Set needs a layer that takes new art. - let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; - if alt { - // Remove instances of this symbol under the brush (within the set, or anywhere). - let d = &st.doc; - let scope: Vec = match set { - Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), - None => instances_of(d, Some(&name)), - }; - let doomed: Vec = scope - .into_iter() - .filter(|id| { - d.node(*id).is_some_and(|n| { - matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) - && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) - }) - }) - .collect(); - let n = doomed.len(); - if n > 0 { - s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?; - } - return Ok(json!({ "count": n })); - } - // Keep instances at least this far apart along the stroke (denser = closer). - let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); - let mut placed: Vec = vec![]; - for (i, q) in pts.iter().enumerate() { - let (jx, jy, _) = jitter(i, *q); - let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); - if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { - continue; - } - placed.push(c); - } - let count = placed.len(); - let reg = s.doc()?.doc.symbol_registration(&name); - let id = s.edit("Symbol Sprayer", |d, sel| { - let mut nodes = vec![]; - for (i, c) in placed.iter().enumerate() { - let (_, _, js) = jitter(i + 7, *c); - let k = scale * (1.0 + js * 0.1); - let id = d.alloc_id(); - nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) })); - } - let gid = match set { - Some(g) => { - for n in nodes { - d.insert(Some(g), usize::MAX, n)?; - } - g - } - None => { - let gid = d.alloc_id(); - let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); - g.name = Some("Symbol Set".into()); - d.insert(parent, usize::MAX, g)?; - gid - } - }; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0, "count": count })) -} - -fn instance_center(n: &Node) -> Option { - match &n.kind { - NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), - _ => None, - } -} - -fn symbol_adjust(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.adjust"; - let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); - if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { - return Err(bad(C, format!("unknown tool `{tool}`"))); - } - super::paint::check_keep_model(p, C)?; - let pts = points(p, C)?; - let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); - let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; - let alt = switch_or(p, "alt", false, C)?; - let st = s.doc()?; - let color = match p.get("color").and_then(color_value) { - Some(c) => super::paint::new_color(s, p, c), - None => s.paint.fill.color().unwrap_or(Color::BLACK), - }; - let style_paint = match str_param(p, "style") { - Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), - None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), - }; - let candidates: Vec = { - let d = &st.doc; - let mut v = vec![]; - if st.selection.is_empty() { - v = instances_of(d, None); - } else { - for id in &st.selection.objects { - if let Some(n) = d.node(*id) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { - v.push(c.id); - } - }); - } - } - } - let editable = d.editable(); - v.retain(|id| editable(*id)); - v - }; - let label = match tool.as_str() { - "shift" => "Symbol Shifter", - "scrunch" => "Symbol Scruncher", - "size" => "Symbol Sizer", - "spin" => "Symbol Spinner", - "stain" => "Symbol Stainer", - "screen" => "Symbol Screener", - _ => "Symbol Styler", - }; - let count = s.edit(label, |d, _| { - let mut touched = std::collections::BTreeSet::new(); - for (j, q) in pts.iter().enumerate() { - let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; - for id in &candidates { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(c) = instance_center(n) else { continue }; - let dist = c.distance(*q); - if dist > radius { - continue; - } - let w = intensity * (1.0 - dist / radius).max(0.0); - if w <= 0.0 { - continue; - } - touched.insert(*id); - let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); - match tool.as_str() { - "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), - "scrunch" => { - let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; - n.transform(Affine::translate(v), false); - } - "size" => { - let k = 1.0 + 0.08 * w; - n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); - } - "spin" => { - // Turn towards the drag direction; a click spins a little. - let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; - n.transform(about(Affine::rotate(if alt { -a } else { a })), false); - } - "stain" | "style" => { - let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; - if tool == "style" && alt { - n.appearance.items.clear(); - continue; - } - let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); - let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; - let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; - n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); - if o > 0.0 { - let mut f = FillLayer::new(paint); - f.opacity = o; - n.appearance.items.insert(0, AppearanceItem::Fill(f)); - } - } - _ => { - let step = (0.1 * w) as f32; - n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; - } - } - } - } - Ok(touched.len()) - })?; - Ok(json!({ "count": count })) -} diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs new file mode 100644 index 000000000..18f744b1d --- /dev/null +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -0,0 +1,447 @@ +use super::*; + +// ---------- brushes ---------- + +/// Brushable nodes (paths and compound paths) under `ids`. +pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |c| { + let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); + let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if own && !in_compound && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + out +} + +pub(super) fn brush_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); + Ok(json!({ "brushes": list, "current": brush::current(d) })) +} + +pub(super) fn brush_get(s: &mut Session, p: &Value) -> Result { + let name = name_param(p, "brush.get")?; + let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; + Ok(serde_json::to_value(b).unwrap_or_default()) +} + +/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). +pub(super) fn brush_selection_art(s: &mut Session, p: &Value) -> Result { + let art = selection_art(s, p)?; + Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) +} + +/// The stroke paint a brush gives paths without one: the current stroke colour, else black. +pub(crate) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). +pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { + n.appearance.set_stroke(stroke.clone()); + } + if let Some(st) = n.appearance.stroke_mut() { + if st.width <= 0.0 { + st.width = 1.0; + } + st.brush = Some(name.to_string()); + } + } +} + +pub(super) fn brush_apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.apply"; + let name = name_param(p, C)?.to_string(); + if brush::find(&s.doc()?.doc, &name).is_none() { + return Err(bad(C, format!("no brush named `{name}`"))); + } + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let stroke = stroke_paint(s); + s.edit("Apply Brush", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); + apply_brush(d, &ids, &name, &stroke); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn brush_remove(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + s.edit("Remove Brush Stroke", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = it { + st.brush = None; + } + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn brush_set_current(s: &mut Session, p: &Value) -> Result { + match str_param(p, "name") { + Some(n) => { + if brush::find(&s.doc()?.doc, n).is_none() { + return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); + } + set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; + } + None => set_unknown(s, brush::CURRENT_KEY, None)?, + } + ok() +} + +/// The definition fields that `params` change field by field when the brush has them as objects: +/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only +/// `start` keeps `end`. +const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; + +fn merge(base: &mut Value, patch: &Value) { + if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { + for (k, v) in pt { + match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { + (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { + for (f, fv) in fields { + inner.insert(f.clone(), fv.clone()); + } + } + _ => { + b.insert(k.clone(), v.clone()); + } + } + } + } +} + +/// Sets brush `to` on the strokes in `items` that paint with brush `from`. +fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && st.brush.as_deref() == Some(from) + { + st.brush = to.map(str::to_string); + } + } +} + +/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. +fn paints_with(n: &Node, name: &str) -> bool { + let mut hit = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); + }); + hit +} + +/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that +/// hold such a stroke, and those that hold them, are copied. +fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { + set_stroke_brush(&mut n.appearance.items, from, to); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && paints_with(&mask.art, from) + { + set_brush_in(Arc::make_mut(&mut mask.art), from, to); + } + for c in n.children_mut().into_iter().flatten() { + if paints_with(c, from) { + set_brush_in(Arc::make_mut(c), from, to); + } + } +} + +/// Returns the art a brush is made of ([`Brush::art`]) for editing. +fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { + use vectorcraft_brush::BrushKind; + match &mut b.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => { + std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } +} + +/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art +/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern +/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. +fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { + let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { + if paints_with(n, from) { + set_brush_in(Arc::make_mut(n), from, to); + } + } + for g in &mut d.graphic_styles { + set_stroke_brush(&mut g.appearance.items, from, to); + } + for n in lib.iter_mut().flat_map(brush_art_mut) { + set_brush_in(n, from, to); + } +} + +fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { + s.edit(label, |d, _| { + for (from, to) in renames { + rename_brush_strokes(d, &mut lib, from, to.as_deref()); + if brush::current(d).as_deref() == Some(from.as_str()) { + match to { + Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), + None => d.unknown.remove(brush::CURRENT_KEY), + }; + } + } + brush::store(d, &lib); + Ok(()) + }) +} + +pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.new"; + let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; + let art_key = match ty { + "calligraphic" | "bristle" => None, + "scatter" | "art" => Some("art"), + "pattern" => Some("side"), + o => return Err(bad(C, format!("unknown brush type `{o}`"))), + }; + let mut lib = brush::library(&s.doc()?.doc); + let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); + let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); + let name = brush::unique_name(&lib, &base_name); + if ty == "bristle" { + bristle_name_fits(C, &name)?; + } + let mut v = json!({"name": name, "type": ty}); + if let Some(params) = p.get("params") { + merge(&mut v, params); + v["name"] = json!(name); + v["type"] = json!(ty); + } + if let Some(k) = art_key + && v.get(k).is_none() + { + let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; + if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + return Err(bad(C, "the selected art has no width")); + } + v[k] = serde_json::to_value(&art).unwrap_or_default(); + } + lib.push(parse_brush(C, v)?); + save_library(s, "New Brush", lib, &[])?; + Ok(json!({ "name": name })) +} + +/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. +fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { + let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); + if count > max { + return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); + } + Ok(()) +} + +/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). +fn parse_brush(cmd: &str, v: Value) -> Result { + Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) +} + +pub(super) fn brush_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.delete"; + let names = names_param(p, C)?; + let mut lib = brush::library(&s.doc()?.doc); + if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) { + return Err(bad(C, format!("no brush named `{name}`"))); + } + lib.retain(|b| !names.contains(&b.name)); + let renames: Vec<(String, Option)> = names.into_iter().map(|n| (n, None)).collect(); + save_library(s, "Delete Brush", lib, &renames)?; + ok() +} + +/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their +/// type in library order: before the first brush of that type at or after `to` that isn't moving, +/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` +/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. +pub(super) fn brush_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.move"; + const WITHIN: &str = "brushes move only within their type"; + let names = names_param(p, C)?; + check_numbers(p, &["to"], C)?; + let to = match p.get("to").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), + None => None, + }; + let old = brush::library(&s.doc()?.doc); + let mut lib = old.clone(); + // `names_param` refuses an empty list, so the loop sets `kind`. + let mut kind = ""; + for name in &names { + let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if !kind.is_empty() && kind != b.kind.type_id() { + return Err(bad(C, WITHIN)); + } + kind = b.kind.type_id(); + } + if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { + return Err(bad(C, WITHIN)); + } + let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); + let before = anchor(&same, to, &names, |b| &b.name); + let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); + let mut items = vec![]; + for name in &names { + if let Some(i) = lib.iter().position(|b| &b.name == name) { + items.push(lib.remove(i)); + } + } + let at = match before { + Some(a) => lib.iter().position(|b| b.name == a), + None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), + }; + let at = at.unwrap_or(first).min(lib.len()); + lib.splice(at..at, items); + if lib != old { + save_library(s, "Move Brush", lib, &[])?; + } + Ok(json!({ "moved": names.len() })) +} + +/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given +/// without `names`, names the copy. +pub(super) fn brush_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.duplicate"; + let names = names_param(p, C)?; + let before = brush::library(&s.doc()?.doc); + let mut lib = before.clone(); + let mut out = vec![]; + for name in &names { + let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); + let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); + b.name = brush::unique_name(&lib, &new_name); + out.push(b.name.clone()); + lib.push(b); + } + save_library(s, "Duplicate Brush", lib, &[])?; + Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) +} + +/// Adds the names of the brushes the strokes of `items` paint with to `out`. +fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && let Some(b) = &st.brush + { + out.push(b.clone()); + } + } +} + +/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to +/// `out`. +fn art_brushes(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); +} + +/// 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. +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)); + 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() { + if !used.contains(&name) { + lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + +pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.options"; + let name = name_param(p, C)?.to_string(); + let mut lib = brush::library(&s.doc()?.doc); + 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") { + merge(&mut v, params); + } + 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 { + if lib.iter().any(|b| &b.name == nn) { + return Err(bad(C, format!("a brush named `{nn}` exists"))); + } + // A `newName` that only trims the white space around the saved name is taken at any length. + if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { + bristle_name_fits(C, nn)?; + } + v["name"] = json!(nn); + } else { + v["name"] = json!(name); + } + v["type"] = json!(lib[i].kind.type_id()); + lib[i] = parse_brush(C, v)?; + let out = lib[i].name.clone(); + let rename = new_name.map(|nn| (name, Some(nn))); + save_library(s, "Brush Options", lib, rename.as_slice())?; + Ok(json!({ "name": out })) +} + +pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); + if ids.is_empty() { + return Err(EngineError::Other("Expand: no brush strokes selected".into())); + } + let out = s.edit("Expand", |d, sel| { + let mut out = vec![]; + for id in &ids { + let Some(n) = d.node(*id).cloned() else { continue }; + let Some(g) = brush::expand(d, &n) else { continue }; + let g = d.reid(&g); + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let gid = g.id; + d.remove(*id)?; + d.insert(par, idx, g)?; + out.push(gid); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); + let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; + let r = (spec.run)(s, p)?; + let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; + s.edit("Paintbrush", |d, _| { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.clone()); + } + Ok(()) + })?; + Ok(r) +} diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs new file mode 100644 index 000000000..63eeed6b5 --- /dev/null +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -0,0 +1,688 @@ +use super::*; + +// ---------- symbols ---------- + +pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { + d.unknown + .get(SIZES_KEY) + .and_then(|m| m.get(name)) + .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .unwrap_or((2.0 * HALF, 2.0 * HALF)) +} + +pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { + let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); + if let Some(o) = m.as_object_mut() { + match size { + Some((w, h)) => o.insert(name.into(), json!([w, h])), + None => o.remove(name), + }; + } +} + +/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. +pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { + let c = at - reg.offset_from_center(w, h); + Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) +} + +fn registration_param(p: &Value, cmd: &str) -> Result> { + match p.get("registration") { + None => Ok(None), + Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), + } +} + +fn export_type_param(p: &Value, cmd: &str) -> Result> { + match str_param(p, "exportType") { + None => Ok(None), + Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), + } +} + +/// `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). +fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { + let on = match p.get("nineSlice") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), + }; + let guides = + match p.get("nineSliceGuides") { + None | Some(Value::Null) => None, + Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { + bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") + })?), + }; + Ok(match (on, guides) { + (Some(false), _) => Some(None), + (None, None) => None, + (_, Some(g)) => Some(Some(g)), + (Some(true), None) => Some(Some(current.unwrap_or_default())), + }) +} + +fn nine_slice_json(d: &Document, name: &str) -> Value { + d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) +} + +fn rename_natural_size(d: &mut Document, from: &str, to: &str) { + if from == to { + return; + } + let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); + set_natural_size(d, from, None); + if let Some(v) = size + && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) + { + set_natural_size(d, to, Some((w, h))); + } +} + +/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). +pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { + let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; + let (w, h) = (b.width().max(1.0), b.height().max(1.0)); + let c = b.center(); + let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); + art.transform(n, false); + Ok((art, (w, h), c)) +} + +fn instances_of(d: &Document, name: Option<&str>) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && name.is_none_or(|x| x == symbol) + { + v.push(n.id); + } + }); + v +} + +fn symbol_names(d: &Document) -> Vec { + d.symbols.iter().map(|s| s.name.clone()).collect() +} + +fn current_symbol(d: &Document) -> Option { + d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) +} + +pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = d + .symbols + .iter() + .map(|sym| { + let (w, h) = natural_size(d, &sym.name); + json!({ + "name": sym.name, + "instances": instances_of(d, Some(&sym.name)).len(), + "size": [w, h], + "registration": d.symbol_registration(&sym.name).0, + "exportType": d.symbol_export_type(&sym.name).id(), + "nineSlice": nine_slice_json(d, &sym.name), + }) + }) + .collect(); + Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) +} + +/// 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()))?; + if separate(d, ids) { + let id = d.alloc_id(); + return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?); + } + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let id = d.alloc_id(); + d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; + for r in ids { + d.remove(*r)?; + } + Ok(id) +} + +pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.new"; + let ids = roots(s, p)?; + 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 name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol")); + 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(); + // 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)); + 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); + 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) }), + ) +} + +pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.options"; + let name = resolve_symbol(&s.doc()?.doc, p, C)?; + 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 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 let Some(ref n) = new_name + && n != &name + && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) + { + return Err(bad(C, format!("a symbol named `{n}` already exists"))); + } + let final_name = new_name.clone().unwrap_or_else(|| name.clone()); + s.edit("Symbol Options", |d, _| { + if let Some(ref n) = new_name + && n != &name + { + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.name = n.clone(); + } + for id in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { + *symbol = n.clone(); + } + } + rename_natural_size(d, &name, n); + d.rename_symbol_registration(&name, n); + d.rename_symbol_export_type(&name, n); + d.rename_symbol_nine_slice(&name, n); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); + } + } + if let Some(r) = reg { + d.set_symbol_registration(&final_name, r); + } + if let Some(kind) = export { + d.set_symbol_export_type(&final_name, kind); + } + if let Some(g) = nine { + d.set_symbol_nine_slice(&final_name, g); + } + Ok(()) + })?; + let d = &s.doc()?.doc; + Ok(json!({ + "name": final_name, + "registration": d.symbol_registration(&final_name).0, + "exportType": d.symbol_export_type(&final_name).id(), + "nineSlice": nine_slice_json(d, &final_name), + })) +} + +fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { + match str_param(p, "name") { + Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), + Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), + None => { + current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) + } + } +} + +pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let name = resolve_symbol(d, p, "symbol.place")?; + let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); + let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); + let size = natural_size(d, &name); + let reg = d.symbol_registration(&name); + let parent = s.doc()?.target_parent()?; + let id = s.edit("Place Symbol Instance", |d, sel| { + let id = d.alloc_id(); + d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?; + sel.set([id]); + Ok(id) + })?; + 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 { + 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(); + 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 mut art = d.reid(&art); + if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { + if !matches!(art.kind, NodeKind::Group { .. }) { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.opacity *= inst.opacity; + art.blend = inst.blend; + } + Some(art) +} + +fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { + 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 (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let aid = art.id; + d.remove(*id)?; + d.insert(par, idx, art)?; + out.push(aid); + } + Ok(out) +} + +fn selected_instances(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in roots(s, p)? { + if let Some(n) = d.node(id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) { + out.push(c.id); + } + }); + } + } + Ok(out) +} + +pub(super) fn symbol_break_link(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 out = s.edit("Break Link to Symbol", |d, sel| { + let out = break_links(d, &ids)?; + 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}`"))); + } + let ids = roots(s, p)?; + 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 id = s.edit("Redefine Symbol", |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) { + *xf *= fix; + } + } + 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); + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "name": name, "id": id.0 })) +} + +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) { + 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)?; + } + 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(()) + })?; + ok() +} + +pub(super) fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.duplicate"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()); + let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string)); + let size = natural_size(d, &name); + let reg = d.symbol_registration(&name); + let export = d.symbol_export_type(&name); + 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) }); + set_natural_size(d, &new, Some(size)); + d.set_symbol_registration(&new, reg); + d.set_symbol_export_type(&new, export); + d.set_symbol_nine_slice(&new, nine); + Ok(()) + })?; + Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) +} + +pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.replace"; + 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}`"))); + } + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + 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), + _ => continue, + }; + if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + *symbol = name.clone(); + *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn symbol_set_current(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; + set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; + ok() +} + +pub(super) fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; + let ids = instances_of(&s.doc()?.doc, Some(&name)); + let n = ids.len(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": n })) +} + +// ---------- symbolism tools ---------- + +fn points(p: &Value, cmd: &str) -> Result> { + let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; + let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); + if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + return Err(bad(cmd, "points must be finite [x,y] pairs")); + } + Ok(v) +} + +/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). +fn jitter(i: usize, q: Point) -> (f64, f64, f64) { + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; + for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { + h ^= v; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + h ^= h >> 29; + } + let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; + (f(0), f(1), f(2)) +} + +fn is_symbol_set(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") +} + +pub(super) fn symbol_spray(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.spray"; + let pts = points(p, C)?; + let st = s.doc()?; + let name = resolve_symbol(&st.doc, p, C)?; + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); + let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); + let alt = switch_or(p, "alt", false, C)?; + let size = natural_size(&st.doc, &name); + let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); + // Only a new Symbol Set needs a layer that takes new art. + let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; + if alt { + // Remove instances of this symbol under the brush (within the set, or anywhere). + let d = &st.doc; + let scope: Vec = match set { + Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), + None => instances_of(d, Some(&name)), + }; + let doomed: Vec = scope + .into_iter() + .filter(|id| { + d.node(*id).is_some_and(|n| { + matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) + && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) + }) + }) + .collect(); + let n = doomed.len(); + if n > 0 { + s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?; + } + return Ok(json!({ "count": n })); + } + // Keep instances at least this far apart along the stroke (denser = closer). + let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); + let mut placed: Vec = vec![]; + for (i, q) in pts.iter().enumerate() { + let (jx, jy, _) = jitter(i, *q); + let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); + if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { + continue; + } + placed.push(c); + } + let count = placed.len(); + let reg = s.doc()?.doc.symbol_registration(&name); + let id = s.edit("Symbol Sprayer", |d, sel| { + let mut nodes = vec![]; + for (i, c) in placed.iter().enumerate() { + let (_, _, js) = jitter(i + 7, *c); + let k = scale * (1.0 + js * 0.1); + let id = d.alloc_id(); + nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) })); + } + let gid = match set { + Some(g) => { + for n in nodes { + d.insert(Some(g), usize::MAX, n)?; + } + g + } + None => { + let gid = d.alloc_id(); + let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); + g.name = Some("Symbol Set".into()); + d.insert(parent, usize::MAX, g)?; + gid + } + }; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "count": count })) +} + +fn instance_center(n: &Node) -> Option { + match &n.kind { + NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), + _ => None, + } +} + +pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.adjust"; + let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); + if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { + return Err(bad(C, format!("unknown tool `{tool}`"))); + } + crate::cmd::paint::check_keep_model(p, C)?; + let pts = points(p, C)?; + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; + let alt = switch_or(p, "alt", false, C)?; + let st = s.doc()?; + let color = match p.get("color").and_then(color_value) { + Some(c) => crate::cmd::paint::new_color(s, p, c), + None => s.paint.fill.color().unwrap_or(Color::BLACK), + }; + let style_paint = match str_param(p, "style") { + Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), + None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), + }; + let candidates: Vec = { + let d = &st.doc; + let mut v = vec![]; + if st.selection.is_empty() { + v = instances_of(d, None); + } else { + for id in &st.selection.objects { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { + v.push(c.id); + } + }); + } + } + } + let editable = d.editable(); + v.retain(|id| editable(*id)); + v + }; + let label = match tool.as_str() { + "shift" => "Symbol Shifter", + "scrunch" => "Symbol Scruncher", + "size" => "Symbol Sizer", + "spin" => "Symbol Spinner", + "stain" => "Symbol Stainer", + "screen" => "Symbol Screener", + _ => "Symbol Styler", + }; + let count = s.edit(label, |d, _| { + let mut touched = std::collections::BTreeSet::new(); + for (j, q) in pts.iter().enumerate() { + let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; + for id in &candidates { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(c) = instance_center(n) else { continue }; + let dist = c.distance(*q); + if dist > radius { + continue; + } + let w = intensity * (1.0 - dist / radius).max(0.0); + if w <= 0.0 { + continue; + } + touched.insert(*id); + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + match tool.as_str() { + "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), + "scrunch" => { + let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; + n.transform(Affine::translate(v), false); + } + "size" => { + let k = 1.0 + 0.08 * w; + n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); + } + "spin" => { + // Turn towards the drag direction; a click spins a little. + let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; + n.transform(about(Affine::rotate(if alt { -a } else { a })), false); + } + "stain" | "style" => { + let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; + if tool == "style" && alt { + n.appearance.items.clear(); + continue; + } + let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); + let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; + let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; + n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); + if o > 0.0 { + let mut f = FillLayer::new(paint); + f.opacity = o; + n.appearance.items.insert(0, AppearanceItem::Fill(f)); + } + } + _ => { + let step = (0.1 * w) as f32; + n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; + } + } + } + } + Ok(touched.len()) + })?; + Ok(json!({ "count": count })) +} diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 3f7da1629..8d72ce335 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -1,10 +1,10 @@ //! Shape Builder (`shapeBuilder.*`), Live Paint (`livePaint.*`) and Image Trace (`imageTrace.*`). //! //! **Live Paint groups** need no special node kind: a Live Paint group is a plain group whose name -//! starts with "Live Paint". Its first child is a hidden group named "Live Paint Sources" holding -//! the original paths (the planar map's inputs, used by Release, Merge and re-computation); then -//! come the faces (paths named "Face", fill only) and the edges (open paths named "Edge", stroke -//! only). See `vectorcraft_tools::builder` for the shared helpers. +//! starts with "Live Paint" and that holds a group named "Live Paint Sources". That group is its +//! first child, hidden, and holds the original paths (the planar map's inputs, used by Release, +//! Merge and re-computation); then come the faces (paths named "Face", fill only) and the edges +//! (open paths named "Edge", stroke only). See `vectorcraft_tools::builder` for the shared helpers. //! //! **Image Trace** makes a group named "Image Trace" holding the source image (hidden, bottom) //! and the traced filled paths above it, mapped with the image's transform. Expand drops the @@ -29,6 +29,15 @@ use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::*; +mod live_paint; +mod shape_builder; +mod trace; + +pub use live_paint::live_paint_gaps; +use live_paint::{lp_expand, lp_fill, lp_gap_options, lp_info, lp_make, lp_merge, lp_release, lp_stroke_edge}; +use shape_builder::{sb_merge, sb_regions}; +use trace::{trace_expand, trace_make, trace_release, trace_set_view}; + /// Name of an Image Trace group. pub const TRACE_NAME: &str = "Image Trace"; @@ -39,7 +48,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", has_selection_or_ids, sb_merge ), @@ -129,7 +138,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -253,182 +262,6 @@ fn marquee_param(p: &Value, cmd: &str) -> Result> { } } -fn sb_merge(s: &mut Session, p: &Value) -> Result { - const C: &str = "shapeBuilder.merge"; - let marquee = marquee_param(p, C)?; - let pts = match points_param(p, "points") { - Some(pts) => pts, - // A marquee needs no drag path. - None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![], - None => return Err(bad(C, "missing points [[x,y]…]")), - }; - if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) { - return Err(bad(C, "points must be a non-empty list of finite [x,y]")); - } - let erase = switch_or(p, "erase", false, C)?; - check_numbers(p, &["tolerance", "gap"], C)?; - // Line pieces and edges are only deleted (erase mode), never merged. - let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); - // In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a - // marquee does not. - let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none()) - .then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); - let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?; - let swatches = match p.get("pickColorFrom") { - None | Some(Value::Null) => false, - Some(v) => match v.as_str() { - Some("artwork") => false, - Some("swatches") => true, - _ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))), - }, - }; - let fill = match fill_param(s, p, C)? { - None if swatches => Some(s.paint.fill.clone()), - f => f, - }; - let roots = root_ids(s, p)?; - let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; - let (ids, merged, touched, split) = s.edit(label, |d, sel| { - let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p)); - let outlines = b::drawn_outlines(d, &leaves); - let leaves: Vec = leaves.into_iter().cloned().collect(); - if map.shapes.is_empty() { - return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); - } - if let Some(tol) = split_tol - && let Some(&q) = pts.first() - && let Some((i, run)) = map.stroke_at(&outlines, q, tol) - && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) - && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) - && rest.length() > b::outline_snap(drawn) - { - // The path keeps the rest of it, and a copy of it with the run under the click goes - // directly above it; the other objects stay as they are. Gradients fitted to the - // path's bounds keep that fit on both. - if let Some(n) = d.node_mut(leaf.id) { - n.pin_gradients(); - } - let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect(); - let mut replaced = Beside::replacing(d, &[leaf.id]); - let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?; - replaced.finish(d)?; - let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?; - // Pick Color From Color Swatches (or `fill`): the stroke takes that paint. - if let Some(f) = &fill - && let Some(n) = d.node_mut(split) - { - n.appearance.set_stroke(f.clone()); - } - // The roots stay selected, with the two paths in place of the path when it was one. - let ids: Vec = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect(); - sel.set(ids.iter().copied()); - let mut touched = Touched::default(); - touched.add(map.hit(q, Some(tol))); - return Ok((ids, None, touched, Some(split))); - } - let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; - touched.extend(&map.hits_along(&pts, piece_tol)); - touched.edges = map.erased_edges(&touched); - if touched.is_empty() { - return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); - } - let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); - let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); - let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; - // Pick Color From Artwork: the merged shape takes the look of the front-most object under the - // drag's first point, else under its last point, else of the front-most object. Color - // Swatches: the current fill, with the rest of the look of the front-most object over the - // first region the drag touched that an object covers, else of the front-most object. A - // marquee's drag starts at its first corner and ends at the opposite one. - let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top()); - let (first, last) = match marquee { - Some((a, b)) => (Some(a), Some(b)), - None => (pts.first().copied(), pts.last().copied()), - }; - let covered = - if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; - let style_src = covered.or(map.shapes.len().checked_sub(1)); - let mut new_nodes: Vec = vec![]; - let mut merged_id = None; - for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { - let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); - if !pieces.is_empty() { - // An open path loses the pieces deleted and those between two merged (or deleted) regions. - let keep: Vec = - pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect(); - if keep.len() == pieces.len() { - new_nodes.push(d.reid(leaf)); - } else { - for k in keep { - if let Some((l, _)) = map.lines.get(k) { - new_nodes.push(remnant(d, leaf, l.path.clone(), None)); - } - } - } - } else { - // A shape loses the regions deleted or merged, then the edges deleted (each one on - // its outline, shared edges from every shape along them). - let in_union = picked.iter().any(|r| r.sources.contains(&i)); - let mut rest = if in_union { - // What is left of it: its faces not merged or deleted, united. They share their - // edges with the merged faces exactly, where subtracting the merged shape left - // slivers of the outline along them (#569). - let kept: Vec<&po::Region> = map - .regions - .iter() - .enumerate() - .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) - .map(|(_, (r, ..))| r) - .collect(); - if kept.is_empty() { PathData::default() } else { faces.merge(&kept) } - } else { - b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) - }; - let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); - // Edges come back from the planar map within a few hundredths of the outlines they - // follow, whatever else is selected. - let cut_tol = b::outline_snap(&sh.path); - let mut cut = false; - for piece in &cuts { - if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { - rest = r; - cut = true; - } - } - if !in_union && !cut { - new_nodes.push(d.reid(leaf)); - } else if !rest.is_empty() { - // What is left of its own outline keeps its fill rule; united faces fill with non-zero. - new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); - } - } - if !erase && !union.is_empty() && Some(i) == style_src { - let mut m = shape_node(d, union.clone(), Some(leaf)); - if let Some(f) = &fill { - m.appearance.set_fill(f.clone()); - } - merged_id = Some(m.id); - new_nodes.push(m); - } - } - let new_ids = replace_roots(d, &roots, new_nodes)?; - sel.set(new_ids.iter().copied()); - Ok((new_ids, merged_id, touched, None)) - })?; - let mut out = json!({ - "ids": ids_json(&ids), - "merged": merged.map(|m| m.0), - "regions": touched.regions.len(), - "lines": touched.lines.len(), - "edges": touched.edges.len(), - }); - if let Some(e) = split { - out["split"] = json!(e.0); - } - Ok(out) -} - /// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids. /// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.) fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec) -> Result> { @@ -465,30 +298,6 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) n } -fn sb_regions(s: &mut Session, p: &Value) -> Result { - let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?; - check_numbers(p, &["gap"], "shapeBuilder.regions")?; - let roots = root_ids(s, p)?; - let d = &s.doc()?.doc; - let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p)); - let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); - let regions: Vec = map - .regions - .iter() - .map(|(r, _, bb)| { - json!({ - "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], - "area": po::area(&r.path, FillRule::NonZero), - "sources": r.sources.iter().filter_map(|&i| id_of(i)).collect::>(), - }) - }) - .collect(); - let pieces = |list: &[(po::Shape, Rect)]| -> Vec { - list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect() - }; - Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) })) -} - // ---------- Live Paint ---------- /// The source nodes a root contributes (a Live Paint group contributes its own sources). @@ -591,744 +400,3 @@ fn repaint_from(g: &mut Node, old: &Node) { } } } - -/// `options` as stored on a Live Paint group: none for the defaults. -fn stored_gap_options(options: GapOptions) -> Option> { - (options != GapOptions::default()).then(|| Box::new(options)) -} - -/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most -/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another -/// group's is on; else the bottom-most group's. -fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { - let Some(&bottom) = groups.first() else { return GapOptions::default() }; - if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom } -} - -/// Refuse `roots` when one of them is something Live Paint can't take ([`b::live_paint_takes`]): -/// its faces and edges come from path outlines alone, so type or an image would vanish among the -/// group's hidden sources. Checked before anything changes. -fn check_live_paint_takes(d: &Document, roots: &[NodeId]) -> Result<()> { - for r in roots { - if !b::live_paint_takes(d.node(*r).ok_or(EngineError::NoNode(*r))?) { - return Err(EngineError::Other( - "Live Paint takes paths and compound paths: convert type, images and other objects to paths first".into(), - )); - } - } - Ok(()) -} - -/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them -/// as Merge does; returns (group, faces, edges). -fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { - check_live_paint_takes(d, roots)?; - if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) { - return merge_live(d, sel, roots); - } - let mut sources = vec![]; - for r in roots { - let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; - sources.extend(source_nodes(n)); - } - let (children, nf, ne) = build_children(d, sources, None)?; - let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.name = Some(LIVE_PAINT_NAME.into()); - replace_roots(d, roots, vec![g])?; - sel.set([gid]); - Ok((gid, nf, ne)) -} - -fn lp_make(s: &mut Session, p: &Value) -> Result { - let roots = root_ids(s, p)?; - let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?; - Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) -} - -/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its -/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which -/// are merged; returns (group, faces, edges). -fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { - let target = roots - .iter() - .copied() - .find(|r| d.node(*r).is_some_and(is_live_paint)) - .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; - check_live_paint_takes(d, roots)?; - let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, fill) and (point on edge, stroke). - let keep_faces: Vec<(Point, Paint)> = b::faces(&old) - .iter() - .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) - .collect(); - let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) - .iter() - .filter_map(|e| { - let pd = e.path_data()?; - let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) - }) - .collect(); - let mut sources = source_nodes(&old); - for r in roots { - if *r != target { - sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); - } - } - // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. - let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); - let options = merged_gap_options(&groups); - let (children, nf, ne) = build_children(d, sources, options.max_gap())?; - // The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic - // style link and the rest; its children are made again and its Gap Options set to the merged ones. - let mut g = old; - if let Some(ch) = g.children_mut() { - *ch = children; - } - g.extras_mut().live_paint_gaps = stored_gap_options(options); - for (pt, paint) in keep_faces { - if let Some(fid) = face_at(&g, pt).map(|f| f.id) - && let Some(ch) = g.children_mut() - && let Some(f) = ch.iter_mut().find(|c| c.id == fid) - { - Arc::make_mut(f).appearance.set_fill(paint); - } - } - for (pt, app) in keep_edges { - if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) - && let Some(ch) = g.children_mut() - && let Some(e) = ch.iter_mut().find(|c| c.id == eid) - { - Arc::make_mut(e).appearance = app.into(); - } - } - let mut merged = Beside::replacing(d, roots); - for r in roots.iter().filter(|r| **r != target) { - if d.node(*r).is_some() { - merged.put(d, *r, vec![])?; - } - } - merged.put(d, target, vec![g])?; - merged.finish(d)?; - sel.set([target]); - Ok((target, nf, ne)) -} - -fn lp_merge(s: &mut Session, p: &Value) -> Result { - let roots = root_ids(s, p)?; - let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?; - Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) -} - -fn live_targets(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let ids = root_ids(s, p)?; - let v: Vec = ids.into_iter().filter(|i| d.node(*i).is_some_and(is_live_paint)).collect(); - if v.is_empty() { - return Err(EngineError::Other("select a Live Paint group".into())); - } - Ok(v) -} - -fn lp_release(s: &mut Session, p: &Value) -> Result { - let targets = live_targets(s, p)?; - let ids = s.edit("Release Live Paint", |d, sel| { - let mut out = vec![]; - let mut released = Beside::replacing(d, &targets); - for g in &targets { - let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let srcs: Vec = source_nodes(&n).iter().map(|c| (**c).clone()).collect(); - out.extend(srcs.iter().map(|c| c.id)); - released.put(d, *g, srcs)?; - } - released.finish(d)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn lp_expand(s: &mut Session, p: &Value) -> Result { - let targets = live_targets(s, p)?; - s.edit("Expand Live Paint", |d, sel| { - for g in &targets { - let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - n.name = None; - { - let mut e = n.extras_mut(); - e.trace = None; - e.live_paint_gaps = None; - } - if let Some(ch) = n.children_mut() { - ch.retain(|c| match c.name.as_deref() { - Some(SOURCES_NAME) => false, - Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), - Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, - _ => true, - }); - } - } - sel.set(targets.iter().copied()); - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&targets) })) -} - -/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to). -fn gaps_json(gaps: &[[Point; 2]]) -> Value { - json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::>()) -} - -/// A group's Gap Options as `livePaint.info` gives them. -fn gap_options_json(o: GapOptions) -> Value { - let [r, g, b] = o.color; - let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id)); - json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") }) -} - -/// The gaps up to `options`' size wide among a Live Paint group's sources. -fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> { - po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points()) -} - -/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide, -/// as `livePaint.info` lists them. None when `group` is not a Live Paint group. -pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> { - if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] } -} - -fn lp_gap_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "livePaint.gapOptions"; - let detect = match p.get("detect").filter(|v| !v.is_null()) { - Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?), - None => None, - }; - let size = - match p.get("size").filter(|v| !v.is_null()) { - Some(v) => Some(GapSize::parse(v).ok_or_else(|| { - bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX)) - })?), - None => None, - }; - let color = match p.get("color").filter(|v| !v.is_null()) { - Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), - None => None, - }; - let close = switch_or(p, "closeGaps", false, C)?; - let targets = live_targets(s, p)?; - let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { - let (mut faces, mut gaps, mut closed) = (0, vec![], 0); - let mut changed = Beside::replacing(d, &targets); - for g in &targets { - let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let mut options = old.gap_options(); - options.detect = detect.unwrap_or(options.detect); - options.size = size.unwrap_or(options.size); - options.color = color.unwrap_or(options.color); - let mut sources = source_nodes(&old); - if close { - // Close Gaps With Paths adds an unpainted line across each gap to the group's paths. - for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) { - let line = PathData::single(SubPath::polyline(&gap, false)); - sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default()))); - closed += 1; - } - } - let mut n = old.clone(); - // The faces change only with what paint stops at, or with new paths. - if close || options.max_gap() != old.gap_options().max_gap() { - let (children, _, _) = build_children(d, sources, options.max_gap())?; - if let Some(ch) = n.children_mut() { - *ch = children; - } - repaint_from(&mut n, &old); - } - n.extras_mut().live_paint_gaps = stored_gap_options(options); - faces += b::faces(&n).len(); - gaps.extend(open_gaps(&n, options)); - changed.put(d, *g, vec![n])?; - } - changed.finish(d)?; - sel.set(targets.iter().copied()); - Ok((faces, gaps, closed)) - })?; - Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) -} - -fn lp_fill(s: &mut Session, p: &Value) -> Result { - const C: &str = "livePaint.fill"; - let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - super::paint::check_keep_model(p, C)?; - let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); - let group = id_param(p, "group"); - let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; - let (g, face) = s.edit("Live Paint Bucket", |d, sel| { - let gid = match group { - Some(g) => { - if !d.node(g).is_some_and(is_live_paint) { - return Err(bad(C, format!("{g} is not a Live Paint group"))); - } - g - } - None => { - let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { - roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) - }; - match (live_at(b::visible_face_at), live_at(face_at)) { - (Some(g), _) => g, - // At a point on a hidden face of a Live Paint group among the objects, the command returns an error - // and does not merge the objects. - (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), - (None, None) => make_live(d, sel, &roots)?.0, - } - } - }; - let fid = d - .node(gid) - .and_then(|n| b::visible_face_at(n, pt)) - .map(|f| f.id) - .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); - Ok((gid, fid)) - })?; - s.remember_paint(&paint); - Ok(json!({ "group": g.0, "face": face.0 })) -} - -fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { - const C: &str = "livePaint.strokeEdge"; - let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; - super::paint::check_keep_model(p, C)?; - let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); - let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; - if !(0.0..=1000.0).contains(&width) { - return Err(bad(C, "width out of range")); - } - let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); - let edge = s.edit("Live Paint Bucket", |d, _| { - let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; - let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; - let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; - e.appearance.set_stroke(paint.clone()); - if let Some(st) = e.appearance.stroke_mut() { - st.width = width; - } - Ok(eid) - })?; - s.remember_paint(&paint); - Ok(json!({ "group": g.0, "edge": edge.0 })) -} - -fn lp_info(s: &mut Session, p: &Value) -> Result { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; - let d = &s.doc()?.doc; - let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; - let faces: Vec = b::faces(n) - .iter() - .map(|f| { - let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); - json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) - }) - .collect(); - let edges: Vec = b::edges(n) - .iter() - .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) - .collect(); - let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); - let options = n.gap_options(); - Ok(json!({ - "faces": faces, - "edges": edges, - "sources": sources, - "gapOptions": gap_options_json(options), - "gaps": gaps_json(&open_gaps(n, options)), - })) -} - -// ---------- Image Trace ---------- - -fn is_trace_group(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) - && n.name.as_deref() == Some(TRACE_NAME) - && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) -} - -/// The `view` param ([`TraceView`] id), if given. -fn view_param(cmd: &str, p: &Value) -> Result> { - let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; - let ids = || TraceView::ALL.map(TraceView::id).join(", "); - v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) -} - -/// The parameters command `c` traces with: the preset's, with `params` over them. -fn trace_params(c: &str, p: &Value) -> Result { - let name = str_param(p, "preset").unwrap_or("Default"); - let enhanced = switch_or(p, "enhanced", false, c)?; - // (A preset without an enhanced version is traced as it is.) - let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name)); - let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?; - let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) }; - let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; - let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) }; - // A key that isn't one of the settings is an error, with the nearest setting. - if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) { - return Err(super::params::unknown(c, key, &o.keys().map(String::as_str).collect::>())); - } - for (k, val) in src { - o.insert(k.clone(), val.clone()); - } - let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; - if !params.fills && !params.strokes { - return Err(bad(c, "Create needs fills, strokes or both")); - } - if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { - return Err(bad(c, "strokeWidth must be a positive number of pixels")); - } - Ok(params) -} - -/// Document Library in Color mode: look the library's colours up into `params.swatches` → each -/// one as traced (sRGB) with the colour to fill with. Errors name command `c`. -fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { - if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { - return Ok(vec![]); - } - let key = params.library.as_str(); - let colors = super::swatchlib::library_colors(s, key) - .ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?; - if colors.is_empty() { - return Err(bad(c, format!("`{key}` has no colours"))); - } - let library: Vec<([u8; 3], Color)> = colors - .into_iter() - .map(|c| { - let [r, g, b, _] = c.to_rgba8(1.0); - ([r, g, b], c) - }) - .collect(); - params.swatches = library.iter().map(|l| l.0).collect(); - Ok(library) -} - -/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused -/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on -/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about -/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo -/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the -/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000. -#[cfg(not(target_arch = "wasm32"))] -pub const MAX_TRACE_ANCHORS: usize = 8_000_000; -/// The most anchor points an Image Trace may make on the web (see the native limit). -#[cfg(target_arch = "wasm32")] -pub const MAX_TRACE_ANCHORS: usize = 2_000_000; - -/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`. -fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape { - use vectorcraft_geom::Affine; - let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians()); - let mut live = match shape { - tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle { - w: width, - h: height, - radii: [radius; 4], - kinds: Default::default(), - radii_relative: false, - xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), - }, - tr::TracedShape::Ellipse { center, rx, ry, rotation } => { - LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } - } - tr::TracedShape::Polygon { center, radius, sides, rotation } => { - LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false } - } - }; - // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) - let _ = live.transform(xf); - live -} - -/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the -/// document by `xf`. -fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; - let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); - let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); - Paint::Gradient(Box::new(paint)) -} - -/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others -/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had: -/// abutting paths painted in another order would show a seam where they meet. Without parents, -/// the nodes as they are. -fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec> { - let n = nodes.len(); - let mut inside: Vec> = vec![vec![]; n]; - let mut roots = vec![]; - for i in 0..n { - match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) { - Some(of) => of.push(i), - None => roots.push(i), - } - } - // Outermost first, so that backwards each path comes after those inside it. - let mut order = roots.clone(); - let mut at = 0; - while let Some(&i) = order.get(at) { - order.extend(inside.get(i).into_iter().flatten()); - at += 1; - } - let mut nodes: Vec> = nodes.into_iter().map(Some).collect(); - let mut built: Vec>> = vec![None; n]; - for &i in order.iter().rev() { - let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue }; - let mut within: Vec<(usize, Arc)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect(); - let made = if within.is_empty() { - node - } else { - within.push((i, Arc::new(node))); - within.sort_by_key(|m| m.0); - Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect()) - }; - if let Some(slot) = built.get_mut(i) { - *slot = Some(Arc::new(made)); - } - } - // (Paths that are inside each other in a ring are reached from no root: the tracer makes none, - // and they are kept all the same.) - let ring = nodes.into_iter().flatten().map(Arc::new).collect::>(); - roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() -} - -fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { - let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; - let mut params = trace_params(c, p)?; - let view = view_param(c, p)?; - let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; - // The preset's name if the parameters are exactly that preset's or its enhanced version's, - // else "Custom". - let named = str_param(p, "preset").unwrap_or("Default"); - let is = |b: Option| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json); - let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() }; - let roots = root_ids(s, p)?; - // The image (inside a trace group, or selected directly). - let (target, img) = { - let d = &s.doc()?.doc; - roots - .iter() - .find_map(|r| { - let n = d.node(*r)?; - match &n.kind { - NodeKind::Image(i) => Some((*r, i.clone())), - _ if is_trace_group(n) => match &n.children()?.first()?.kind { - NodeKind::Image(i) => Some((*r, i.clone())), - _ => None, - }, - _ => None, - } - }) - .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? - }; - let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; - // An image over the tracer's size is traced scaled down (the paths are scaled back below). - let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; - let library = trace_library(c, s, &mut params)?; - let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; - let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at); - // Pixel space of the decoded raster → the image object's pixel space → document. - let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; - let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; - let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy); - // Document units per raster pixel (stroke widths). - let unit = xf.determinant().abs().sqrt(); - let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len()); - let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" }; - 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) { - src.children() - .and_then(|c| c.first()) - .map(|c| (**c).clone()) - .ok_or_else(|| EngineError::Other("Image Trace: the traced image is missing".into()))? - } else { - src.clone() - }; - image.visible = true; - let mut children: Vec> = vec![]; - if !expand { - let mut hidden = image.clone(); - hidden.visible = false; - children.push(Arc::new(hidden)); - } - let mut traced = Vec::with_capacity(res.paths.len()); - for tp in res.paths { - let path = tp.path.transformed(xf); - let mut n = shape_node(d, path, None); - // Shapes: a path the tracer found to be a shape is that live shape. - if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) { - let shape = traced_shape(shape, xf); - *path = shape.to_path(); - *live = Some(Box::new(shape)); - } - // A library colour fills with the swatch's own colour (its model kept). - let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); - // Gradients: a path the tracer found to be a linear gradient is filled with it. - let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf)); - n.appearance = match tp.stroke { - // Traced lines: round corners, and ends round or square as the tracer found them. - Some(width) => Appearance { - items: vec![AppearanceItem::Stroke(StrokeLayer { - cap: if tp.square_caps { LineCap::Square } else { LineCap::Round }, - join: LineJoin::Round, - ..StrokeLayer::new(paint, width * unit) - })], - ..Default::default() - } - .into(), - None => fill_only(paint).into(), - }; - traced.push(n); - } - children.extend(grouped(d, traced, &res.parents)); - let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.name = if expand { None } else { Some(TRACE_NAME.into()) }; - if !expand { - // Tracing again keeps the object's view unless another is asked for. - let view = view.unwrap_or_else(|| src.trace_view()); - g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); - } - let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; - d.remove(target)?; - d.insert(par, idx, g)?; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings })) -} - -/// Images larger than this on a side are traced, but more slowly: the trace says so. -pub const LARGE_TRACE_SIDE: u32 = 1500; - -/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that -/// Flat Logo averaged it down to `worked` pixels first, or else that a large image takes longer -/// (the public documentation's warning for images over 1500 px) and that one over the tracer's -/// [`tr::MAX_PIXELS`] was traced scaled down. -pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Option<(u32, u32)>) -> Vec { - let mut out = vec![]; - if worked.is_none() && size.0.max(size.1) > LARGE_TRACE_SIDE { - out.push(format!( - "The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.", - size.0, size.1 - )); - } - if let Some(worked) = worked { - out.push(format!("Flat Logo traced the image at {} × {} pixels, averaged down from {} × {}.", worked.0, worked.1, size.0, size.1)); - } else if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) { - out.push(format!( - "The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).", - traced.0, - traced.1, - size.0, - size.1, - tr::MAX_PIXELS >> 20 - )); - } - out -} - -fn trace_targets(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); - if v.is_empty() { - return Err(EngineError::Other("select an Image Trace object".into())); - } - Ok(v) -} - -fn trace_set_view(s: &mut Session, p: &Value) -> Result { - const C: &str = "imageTrace.setView"; - let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?; - let targets = trace_targets(s, p)?; - let d = &s.doc()?.doc; - if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) { - return Ok(json!({ "ids": ids_json(&targets), "view": view.id() })); - } - s.edit("Image Trace View", |d, _| { - for g in &targets { - let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { - t.insert("view".into(), json!(view.id())); - } else { - n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&targets), "view": view.id() })) -} - -fn trace_release(s: &mut Session, p: &Value) -> Result { - let targets = trace_targets(s, p)?; - let ids = s.edit("Release Image Trace", |d, sel| { - let mut out = vec![]; - let mut released = Beside::replacing(d, &targets); - for g in &targets { - let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone(); - img.visible = true; - out.push(img.id); - released.put(d, *g, vec![img])?; - } - released.finish(d)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn trace_expand(s: &mut Session, p: &Value) -> Result { - let targets = trace_targets(s, p)?; - s.edit("Expand Image Trace", |d, sel| { - for g in &targets { - let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - n.name = None; - n.extras_mut().trace = None; - if let Some(ch) = n.children_mut() { - ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); - } - } - sel.set(targets.iter().copied()); - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&targets) })) -} - -#[cfg(test)] -mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, trace_warnings}; - - #[test] - fn trace_warnings_name_a_large_or_scaled_down_image() { - assert!(trace_warnings((1500, 1500), (1500, 1500), None).is_empty()); - assert_eq!(trace_warnings((1501, 10), (1501, 10), None).len(), 1); - let w = trace_warnings((16000, 12000), (9459, 7094), None); - assert_eq!(w.len(), 2, "{w:?}"); - assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}"); - // Flat Logo's own working size is named instead of the megapixel limit. - let w = trace_warnings((1025, 1025), (1025, 1025), Some((513, 513))); - assert_eq!(w, ["Flat Logo traced the image at 513 × 513 pixels, averaged down from 1025 × 1025."]); - // Flat Logo traces a large image at its reduced size, so it gives no slow-trace warning. - let w = trace_warnings((1600, 1600), (1600, 1600), Some((800, 800))); - assert_eq!(w, ["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]); - let w = trace_warnings((16000, 12000), (9459, 7094), Some((946, 710))); - assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]); - } - - // A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit; - // the limit still guards memory (about 90 bytes an anchor). Checked when the tests build. - const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000); -} diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs new file mode 100644 index 000000000..f9b224ce8 --- /dev/null +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -0,0 +1,357 @@ +use super::*; + +/// `options` as stored on a Live Paint group: none for the defaults. +fn stored_gap_options(options: GapOptions) -> Option> { + (options != GapOptions::default()).then(|| Box::new(options)) +} + +/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most +/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another +/// group's is on; else the bottom-most group's. +fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { + let Some(&bottom) = groups.first() else { return GapOptions::default() }; + if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom } +} + +/// Refuse `roots` when one of them is something Live Paint can't take ([`b::live_paint_takes`]): +/// its faces and edges come from path outlines alone, so type or an image would vanish among the +/// group's hidden sources. Checked before anything changes. +fn check_live_paint_takes(d: &Document, roots: &[NodeId]) -> Result<()> { + for r in roots { + if !b::live_paint_takes(d.node(*r).ok_or(EngineError::NoNode(*r))?) { + return Err(EngineError::Other( + "Live Paint takes paths and compound paths: convert type, images and other objects to paths first".into(), + )); + } + } + Ok(()) +} + +/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them +/// as Merge does; returns (group, faces, edges). +fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + check_live_paint_takes(d, roots)?; + if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) { + return merge_live(d, sel, roots); + } + let mut sources = vec![]; + for r in roots { + let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; + sources.extend(source_nodes(n)); + } + let (children, nf, ne) = build_children(d, sources, None)?; + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some(LIVE_PAINT_NAME.into()); + replace_roots(d, roots, vec![g])?; + sel.set([gid]); + Ok((gid, nf, ne)) +} + +pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?; + Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) +} + +/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its +/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which +/// are merged; returns (group, faces, edges). +fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + let target = roots + .iter() + .copied() + .find(|r| d.node(*r).is_some_and(is_live_paint)) + .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; + check_live_paint_takes(d, roots)?; + let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; + // Paint to carry over: (point inside face, fill) and (point on edge, stroke). + let keep_faces: Vec<(Point, Paint)> = b::faces(&old) + .iter() + .filter(|f| !f.appearance.fill_paint().is_none()) + .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) + .collect(); + let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) + .iter() + .filter_map(|e| { + let pd = e.path_data()?; + let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) + }) + .collect(); + let mut sources = source_nodes(&old); + for r in roots { + if *r != target { + sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); + } + } + // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. + let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); + let options = merged_gap_options(&groups); + let (children, nf, ne) = build_children(d, sources, options.max_gap())?; + // The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic + // style link and the rest; its children are made again and its Gap Options set to the merged ones. + let mut g = old; + if let Some(ch) = g.children_mut() { + *ch = children; + } + g.extras_mut().live_paint_gaps = stored_gap_options(options); + for (pt, paint) in keep_faces { + if let Some(fid) = face_at(&g, pt).map(|f| f.id) + && let Some(ch) = g.children_mut() + && let Some(f) = ch.iter_mut().find(|c| c.id == fid) + { + Arc::make_mut(f).appearance.set_fill(paint); + } + } + for (pt, app) in keep_edges { + if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) + && let Some(ch) = g.children_mut() + && let Some(e) = ch.iter_mut().find(|c| c.id == eid) + { + Arc::make_mut(e).appearance = app.into(); + } + } + let mut merged = Beside::replacing(d, roots); + for r in roots.iter().filter(|r| **r != target) { + if d.node(*r).is_some() { + merged.put(d, *r, vec![])?; + } + } + merged.put(d, target, vec![g])?; + merged.finish(d)?; + sel.set([target]); + Ok((target, nf, ne)) +} + +pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?; + Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) +} + +fn live_targets(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let ids = root_ids(s, p)?; + let v: Vec = ids.into_iter().filter(|i| d.node(*i).is_some_and(is_live_paint)).collect(); + if v.is_empty() { + return Err(EngineError::Other("select a Live Paint group".into())); + } + Ok(v) +} + +pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result { + let targets = live_targets(s, p)?; + let ids = s.edit("Release Live Paint", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &targets); + for g in &targets { + let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let srcs: Vec = source_nodes(&n).iter().map(|c| (**c).clone()).collect(); + out.extend(srcs.iter().map(|c| c.id)); + released.put(d, *g, srcs)?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result { + let targets = live_targets(s, p)?; + s.edit("Expand Live Paint", |d, sel| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + n.name = None; + { + let mut e = n.extras_mut(); + e.trace = None; + e.live_paint_gaps = None; + } + if let Some(ch) = n.children_mut() { + ch.retain(|c| match c.name.as_deref() { + Some(SOURCES_NAME) => false, + Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), + Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, + _ => true, + }); + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets) })) +} + +/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to). +fn gaps_json(gaps: &[[Point; 2]]) -> Value { + json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::>()) +} + +/// A group's Gap Options as `livePaint.info` gives them. +fn gap_options_json(o: GapOptions) -> Value { + let [r, g, b] = o.color; + let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id)); + json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") }) +} + +/// The gaps up to `options`' size wide among a Live Paint group's sources. +fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> { + po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points()) +} + +/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide, +/// as `livePaint.info` lists them. None when `group` is not a Live Paint group. +pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> { + if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] } +} + +pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.gapOptions"; + let detect = match p.get("detect").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?), + None => None, + }; + let size = + match p.get("size").filter(|v| !v.is_null()) { + Some(v) => Some(GapSize::parse(v).ok_or_else(|| { + bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX)) + })?), + None => None, + }; + let color = match p.get("color").filter(|v| !v.is_null()) { + Some(v) => Some(crate::cmd::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), + None => None, + }; + let close = switch_or(p, "closeGaps", false, C)?; + let targets = live_targets(s, p)?; + let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { + let (mut faces, mut gaps, mut closed) = (0, vec![], 0); + let mut changed = Beside::replacing(d, &targets); + for g in &targets { + let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let mut options = old.gap_options(); + options.detect = detect.unwrap_or(options.detect); + options.size = size.unwrap_or(options.size); + options.color = color.unwrap_or(options.color); + let mut sources = source_nodes(&old); + if close { + // Close Gaps With Paths adds an unpainted line across each gap to the group's paths. + for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) { + let line = PathData::single(SubPath::polyline(&gap, false)); + sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default()))); + closed += 1; + } + } + let mut n = old.clone(); + // The faces change only with what paint stops at, or with new paths. + if close || options.max_gap() != old.gap_options().max_gap() { + let (children, _, _) = build_children(d, sources, options.max_gap())?; + if let Some(ch) = n.children_mut() { + *ch = children; + } + repaint_from(&mut n, &old); + } + n.extras_mut().live_paint_gaps = stored_gap_options(options); + faces += b::faces(&n).len(); + gaps.extend(open_gaps(&n, options)); + changed.put(d, *g, vec![n])?; + } + changed.finish(d)?; + sel.set(targets.iter().copied()); + Ok((faces, gaps, closed)) + })?; + Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) +} + +pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.fill"; + let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + crate::cmd::paint::check_keep_model(p, C)?; + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); + let group = id_param(p, "group"); + let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; + let (g, face) = s.edit("Live Paint Bucket", |d, sel| { + let gid = match group { + Some(g) => { + if !d.node(g).is_some_and(is_live_paint) { + return Err(bad(C, format!("{g} is not a Live Paint group"))); + } + g + } + None => { + let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { + roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) + }; + match (live_at(b::visible_face_at), live_at(face_at)) { + (Some(g), _) => g, + // At a point on a hidden face of a Live Paint group among the objects, the command returns an error + // and does not merge the objects. + (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), + (None, None) => make_live(d, sel, &roots)?.0, + } + } + }; + let fid = d + .node(gid) + .and_then(|n| b::visible_face_at(n, pt)) + .map(|f| f.id) + .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; + d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); + Ok((gid, fid)) + })?; + s.remember_paint(&paint); + Ok(json!({ "group": g.0, "face": face.0 })) +} + +pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.strokeEdge"; + let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + crate::cmd::paint::check_keep_model(p, C)?; + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); + let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; + if !(0.0..=1000.0).contains(&width) { + return Err(bad(C, "width out of range")); + } + let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); + let edge = s.edit("Live Paint Bucket", |d, _| { + let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; + let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; + e.appearance.set_stroke(paint.clone()); + if let Some(st) = e.appearance.stroke_mut() { + st.width = width; + } + Ok(eid) + })?; + s.remember_paint(&paint); + Ok(json!({ "group": g.0, "edge": edge.0 })) +} + +pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { + let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let d = &s.doc()?.doc; + let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; + let faces: Vec = b::faces(n) + .iter() + .map(|f| { + let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); + json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) + }) + .collect(); + let edges: Vec = b::edges(n) + .iter() + .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) + .collect(); + let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); + let options = n.gap_options(); + Ok(json!({ + "faces": faces, + "edges": edges, + "sources": sources, + "gapOptions": gap_options_json(options), + "gaps": gaps_json(&open_gaps(n, options)), + })) +} diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs new file mode 100644 index 000000000..07d4949a0 --- /dev/null +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -0,0 +1,214 @@ +use super::*; + +pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "shapeBuilder.merge"; + let marquee = marquee_param(p, C)?; + let pts = match points_param(p, "points") { + Some(pts) => pts, + // A marquee needs no drag path. + None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![], + None => return Err(bad(C, "missing points [[x,y]…]")), + }; + if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) { + return Err(bad(C, "points must be a non-empty list of finite [x,y]")); + } + let erase = switch_or(p, "erase", false, C)?; + check_numbers(p, &["tolerance", "gap"], C)?; + // Line pieces and edges are only deleted (erase mode), never merged. + let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); + // In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a + // marquee does not. + let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none()) + .then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); + let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?; + let swatches = match p.get("pickColorFrom") { + None | Some(Value::Null) => false, + Some(v) => match v.as_str() { + Some("artwork") => false, + Some("swatches") => true, + _ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))), + }, + }; + let fill = match fill_param(s, p, C)? { + None if swatches => Some(s.paint.fill.clone()), + f => f, + }; + let roots = root_ids(s, p)?; + let gap = gap_param(p); + // The map the Shape Builder tool already has, which a click or drag of it is made over. + let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap); + let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; + let (ids, merged, touched, split) = s.edit(label, |d, sel| { + let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed); + let built; + let map = match &shared { + // The tool's map has the outline edges, which erasing and splitting need. + Some((m, _)) if m.shapes == shapes => m.as_ref(), + _ => { + built = BuilderMap::of_shapes(shapes, erase || split_tol.is_some(), gap); + &built + } + }; + let outlines = b::drawn_outlines(d, &leaves); + let leaves: Vec = leaves.into_iter().cloned().collect(); + if map.shapes.is_empty() { + return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); + } + if let Some(tol) = split_tol + && let Some(&q) = pts.first() + && let Some((i, run)) = map.stroke_at(&outlines, q, tol) + && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) + && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) + && rest.length() > b::outline_snap(drawn) + { + // The path keeps the rest of it, and a copy of it with the run under the click goes + // directly above it; the other objects stay as they are. Gradients fitted to the + // path's bounds keep that fit on both. + if let Some(n) = d.node_mut(leaf.id) { + n.pin_gradients(); + } + let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect(); + let mut replaced = Beside::replacing(d, &[leaf.id]); + let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?; + replaced.finish(d)?; + let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?; + // Pick Color From Color Swatches (or `fill`): the stroke takes that paint. + if let Some(f) = &fill + && let Some(n) = d.node_mut(split) + { + n.appearance.set_stroke(f.clone()); + } + // The roots stay selected, with the two paths in place of the path when it was one. + let ids: Vec = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect(); + sel.set(ids.iter().copied()); + let mut touched = Touched::default(); + touched.add(map.hit(q, Some(tol))); + return Ok((ids, None, touched, Some(split))); + } + let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; + touched.extend(&map.builder_hits_along(&pts, piece_tol)); + touched.edges = map.erased_edges(&touched); + if touched.is_empty() { + return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); + } + let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); + let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); + let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + // Pick Color From Artwork: the merged shape takes the look of the front-most object under the + // drag's first point, else under its last point, else of the front-most object. Color + // Swatches: the current fill, with the rest of the look of the front-most object over the + // first region the drag touched that an object covers, else of the front-most object. A + // marquee's drag starts at its first corner and ends at the opposite one. + let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top()); + let (first, last) = match marquee { + Some((a, b)) => (Some(a), Some(b)), + None => (pts.first().copied(), pts.last().copied()), + }; + let covered = + if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; + let style_src = covered.or(map.shapes.len().checked_sub(1)); + let mut new_nodes: Vec = vec![]; + let mut merged_id = None; + for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { + let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); + if !pieces.is_empty() { + // An open path loses the pieces deleted and those between two merged (or deleted) regions. + let keep: Vec = + pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect(); + if keep.len() == pieces.len() { + new_nodes.push(d.reid(leaf)); + } else { + for k in keep { + if let Some((l, _)) = map.lines.get(k) { + new_nodes.push(remnant(d, leaf, l.path.clone(), None)); + } + } + } + } else { + // A shape loses the regions deleted or merged, then the edges deleted (each one on + // its outline, shared edges from every shape along them). + let in_union = picked.iter().any(|r| r.sources.contains(&i)); + let mut rest = if in_union { + // What is left of it: its faces not merged or deleted, united. They share their + // edges with the merged faces exactly, where subtracting the merged shape left + // slivers of the outline along them (#569). A sliver goes in only where the + // shape covers it and the merged shape does not take it in. + let kept: Vec<&po::Region> = map + .regions + .iter() + .enumerate() + .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) + .map(|(_, (r, ..))| r) + .collect(); + if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } + } else { + b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) + }; + let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); + // Edges come back from the planar map within a few hundredths of the outlines they + // follow, whatever else is selected. + let cut_tol = b::outline_snap(&sh.path); + let mut cut = false; + for piece in &cuts { + if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { + rest = r; + cut = true; + } + } + if !in_union && !cut { + new_nodes.push(d.reid(leaf)); + } else if !rest.is_empty() { + // What is left of its own outline keeps its fill rule; united faces fill with non-zero. + new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); + } + } + if !erase && !union.is_empty() && Some(i) == style_src { + let mut m = shape_node(d, union.clone(), Some(leaf)); + if let Some(f) = &fill { + m.appearance.set_fill(f.clone()); + } + merged_id = Some(m.id); + new_nodes.push(m); + } + } + let new_ids = replace_roots(d, &roots, new_nodes)?; + sel.set(new_ids.iter().copied()); + Ok((new_ids, merged_id, touched, None)) + })?; + let mut out = json!({ + "ids": ids_json(&ids), + "merged": merged.map(|m| m.0), + "regions": touched.regions.len(), + "lines": touched.lines.len(), + "edges": touched.edges.len(), + }); + if let Some(e) = split { + out["split"] = json!(e.0); + } + Ok(out) +} + +pub(super) fn sb_regions(s: &mut Session, p: &Value) -> Result { + let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?; + check_numbers(p, &["gap"], "shapeBuilder.regions")?; + let roots = root_ids(s, p)?; + let d = &s.doc()?.doc; + let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p)); + let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); + let regions: Vec = map + .regions + .iter() + .map(|(r, _, bb)| { + json!({ + "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], + "area": po::area(&r.path, FillRule::NonZero), + "sources": r.sources.iter().filter_map(|&i| id_of(i)).collect::>(), + }) + }) + .collect(); + let pieces = |list: &[(po::Shape, Rect)]| -> Vec { + list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect() + }; + Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) })) +} diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs new file mode 100644 index 000000000..2eb4bcfcc --- /dev/null +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -0,0 +1,418 @@ +use super::*; + +// ---------- Image Trace ---------- + +fn is_trace_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.name.as_deref() == Some(TRACE_NAME) + && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) +} + +/// The `view` param ([`TraceView`] id), if given. +fn view_param(cmd: &str, p: &Value) -> Result> { + let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; + let ids = || TraceView::ALL.map(TraceView::id).join(", "); + v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) +} + +/// The parameters command `c` traces with: the preset's, with `params` over them. +fn trace_params(c: &str, p: &Value) -> Result { + let name = str_param(p, "preset").unwrap_or("Default"); + let enhanced = switch_or(p, "enhanced", false, c)?; + // (A preset without an enhanced version is traced as it is.) + let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name)); + let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?; + let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) }; + let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; + let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) }; + // A key that isn't one of the settings is an error, with the nearest setting. + if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) { + return Err(crate::cmd::params::unknown(c, key, &o.keys().map(String::as_str).collect::>())); + } + for (k, val) in src { + o.insert(k.clone(), val.clone()); + } + let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if !params.fills && !params.strokes { + return Err(bad(c, "Create needs fills, strokes or both")); + } + if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { + return Err(bad(c, "strokeWidth must be a positive number of pixels")); + } + // A color group is refused unless the trace uses it: Color mode with Document Library and the + // document's swatches. + if params.color_group.as_deref().is_some_and(|g| !g.is_empty()) { + if params.palette != tr::Palette::DocumentLibrary { + return Err(bad(c, "`colorGroup` needs `palette: \"documentLibrary\"`")); + } + if params.library != crate::cmd::swatchlib::DOCUMENT_SWATCHES { + return Err(bad(c, "`colorGroup` needs `library: \"document\"`")); + } + if params.mode != tr::Mode::Color { + return Err(bad(c, "`colorGroup` needs `mode: \"color\"`")); + } + } + Ok(params) +} + +/// Document Library with `colorGroup`: the solid colors of the active document's color group +/// `name` (so named exactly, else ignoring case). 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> { + let groups = &s.doc()?.doc.swatch_groups; + let lower = name.to_lowercase(); + let group = groups + .iter() + .find(|g| g.name == name) + .or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower)) + .ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?; + Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect()) +} + +/// Document Library in Color mode: look the library's colors (or its color group's) up into +/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`. +fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { + if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { + return Ok(vec![]); + } + let group = params.color_group.as_deref().filter(|g| !g.is_empty()); + let key = group.unwrap_or(params.library.as_str()); + let colors = match group { + Some(name) => trace_group_colors(c, s, name)?, + None => crate::cmd::swatchlib::library_colors(s, key) + .ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?, + }; + if colors.is_empty() { + return Err(bad(c, format!("`{key}` has no colours"))); + } + let library: Vec<([u8; 3], Color)> = colors + .into_iter() + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + ([r, g, b], c) + }) + .collect(); + params.swatches = library.iter().map(|l| l.0).collect(); + Ok(library) +} + +/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused +/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on +/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about +/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo +/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the +/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000. +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_TRACE_ANCHORS: usize = 8_000_000; + +/// The most anchor points an Image Trace may make on the web (see the native limit). +#[cfg(target_arch = "wasm32")] +pub const MAX_TRACE_ANCHORS: usize = 2_000_000; + +/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`. +fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape { + use vectorcraft_geom::Affine; + let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians()); + let mut live = match shape { + tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle { + w: width, + h: height, + radii: [radius; 4], + kinds: Default::default(), + radii_relative: false, + xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), + }, + tr::TracedShape::Ellipse { center, rx, ry, rotation } => { + LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } + } + tr::TracedShape::Polygon { center, radius, sides, rotation } => { + LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false } + } + }; + // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) + let _ = live.transform(xf); + live +} + +/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the +/// document by `xf`. +fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); + let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); + Paint::Gradient(Box::new(paint)) +} + +/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others +/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had: +/// abutting paths painted in another order would show a seam where they meet. Without parents, +/// the nodes as they are. +fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec> { + let n = nodes.len(); + let mut inside: Vec> = vec![vec![]; n]; + let mut roots = vec![]; + for i in 0..n { + match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) { + Some(of) => of.push(i), + None => roots.push(i), + } + } + // Outermost first, so that backwards each path comes after those inside it. + let mut order = roots.clone(); + let mut at = 0; + while let Some(&i) = order.get(at) { + order.extend(inside.get(i).into_iter().flatten()); + at += 1; + } + let mut nodes: Vec> = nodes.into_iter().map(Some).collect(); + let mut built: Vec>> = vec![None; n]; + for &i in order.iter().rev() { + let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue }; + let mut within: Vec<(usize, Arc)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect(); + let made = if within.is_empty() { + node + } else { + within.push((i, Arc::new(node))); + within.sort_by_key(|m| m.0); + Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect()) + }; + if let Some(slot) = built.get_mut(i) { + *slot = Some(Arc::new(made)); + } + } + // (Paths that are inside each other in a ring are reached from no root: the tracer makes none, + // and they are kept all the same.) + let ring = nodes.into_iter().flatten().map(Arc::new).collect::>(); + roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() +} + +pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { + let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; + let mut params = trace_params(c, p)?; + let view = view_param(c, p)?; + let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; + // The preset's name if the parameters are exactly that preset's or its enhanced version's, + // else "Custom". + let named = str_param(p, "preset").unwrap_or("Default"); + let is = |b: Option| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json); + let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() }; + let roots = root_ids(s, p)?; + // The image (inside a trace group, or selected directly). + let (target, img) = { + let d = &s.doc()?.doc; + roots + .iter() + .find_map(|r| { + let n = d.node(*r)?; + match &n.kind { + NodeKind::Image(i) => Some((*r, i.clone())), + _ if is_trace_group(n) => match &n.children()?.first()?.kind { + NodeKind::Image(i) => Some((*r, i.clone())), + _ => None, + }, + _ => None, + } + }) + .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? + }; + let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; + // An image over the tracer's size is traced scaled down (the paths are scaled back below). + let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let library = trace_library(c, s, &mut params)?; + let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; + let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at); + // Pixel space of the decoded raster → the image object's pixel space → document. + let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; + let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; + let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy); + // Document units per raster pixel (stroke widths). + let unit = xf.determinant().abs().sqrt(); + let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len()); + let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" }; + 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) { + src.children() + .and_then(|c| c.first()) + .map(|c| (**c).clone()) + .ok_or_else(|| EngineError::Other("Image Trace: the traced image is missing".into()))? + } else { + src.clone() + }; + image.visible = true; + let mut children: Vec> = vec![]; + if !expand { + let mut hidden = image.clone(); + hidden.visible = false; + children.push(Arc::new(hidden)); + } + let mut traced = Vec::with_capacity(res.paths.len()); + for tp in res.paths { + let path = tp.path.transformed(xf); + let mut n = shape_node(d, path, None); + // Shapes: a path the tracer found to be a shape is that live shape. + if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) { + let shape = traced_shape(shape, xf); + *path = shape.to_path(); + *live = Some(Box::new(shape)); + } + // A library colour fills with the swatch's own colour (its model kept). + let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); + // Gradients: a path the tracer found to be a linear gradient is filled with it. + let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf)); + n.appearance = match tp.stroke { + // Traced lines: round corners, and ends round or square as the tracer found them. + Some(width) => Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { + cap: if tp.square_caps { LineCap::Square } else { LineCap::Round }, + join: LineJoin::Round, + ..StrokeLayer::new(paint, width * unit) + })], + ..Default::default() + } + .into(), + None => fill_only(paint).into(), + }; + traced.push(n); + } + children.extend(grouped(d, traced, &res.parents)); + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = if expand { None } else { Some(TRACE_NAME.into()) }; + if !expand { + // Tracing again keeps the object's view unless another is asked for. + let view = view.unwrap_or_else(|| src.trace_view()); + g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); + } + let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; + d.remove(target)?; + d.insert(par, idx, g)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings })) +} + +/// Images larger than this on a side are traced, but more slowly: the trace says so. +pub const LARGE_TRACE_SIDE: u32 = 1500; + +/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that +/// Flat Logo averaged it down to `worked` pixels first, or else that a large image takes longer +/// (the public documentation's warning for images over 1500 px) and that one over the tracer's +/// [`tr::MAX_PIXELS`] was traced scaled down. +pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Option<(u32, u32)>) -> Vec { + let mut out = vec![]; + if worked.is_none() && size.0.max(size.1) > LARGE_TRACE_SIDE { + out.push(format!( + "The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.", + size.0, size.1 + )); + } + if let Some(worked) = worked { + out.push(format!("Flat Logo traced the image at {} × {} pixels, averaged down from {} × {}.", worked.0, worked.1, size.0, size.1)); + } else if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) { + out.push(format!( + "The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).", + traced.0, + traced.1, + size.0, + size.1, + tr::MAX_PIXELS >> 20 + )); + } + out +} + +fn trace_targets(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); + if v.is_empty() { + return Err(EngineError::Other("select an Image Trace object".into())); + } + Ok(v) +} + +pub(super) fn trace_set_view(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.setView"; + let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?; + let targets = trace_targets(s, p)?; + let d = &s.doc()?.doc; + if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) { + return Ok(json!({ "ids": ids_json(&targets), "view": view.id() })); + } + s.edit("Image Trace View", |d, _| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { + t.insert("view".into(), json!(view.id())); + } else { + n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets), "view": view.id() })) +} + +pub(super) fn trace_release(s: &mut Session, p: &Value) -> Result { + let targets = trace_targets(s, p)?; + let ids = s.edit("Release Image Trace", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &targets); + for g in &targets { + let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone(); + img.visible = true; + out.push(img.id); + released.put(d, *g, vec![img])?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { + let targets = trace_targets(s, p)?; + s.edit("Expand Image Trace", |d, sel| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + n.name = None; + n.extras_mut().trace = None; + if let Some(ch) = n.children_mut() { + ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets) })) +} + +#[cfg(test)] +mod trace_limit_tests { + use super::{MAX_TRACE_ANCHORS, trace_warnings}; + + #[test] + fn trace_warnings_name_a_large_or_scaled_down_image() { + assert!(trace_warnings((1500, 1500), (1500, 1500), None).is_empty()); + assert_eq!(trace_warnings((1501, 10), (1501, 10), None).len(), 1); + let w = trace_warnings((16000, 12000), (9459, 7094), None); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}"); + // Flat Logo's own working size is named instead of the megapixel limit. + let w = trace_warnings((1025, 1025), (1025, 1025), Some((513, 513))); + assert_eq!(w, ["Flat Logo traced the image at 513 × 513 pixels, averaged down from 1025 × 1025."]); + // Flat Logo traces a large image at its reduced size, so it gives no slow-trace warning. + let w = trace_warnings((1600, 1600), (1600, 1600), Some((800, 800))); + assert_eq!(w, ["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]); + let w = trace_warnings((16000, 12000), (9459, 7094), Some((946, 710))); + assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]); + } + + // A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit; + // the limit still guards memory (about 90 bytes an anchor). Checked when the tests build. + const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000); +} diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d51ef6907..a01f095fc 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -23,6 +23,7 @@ use vectorcraft_geom::Rect; pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture}; pub(crate) use resources::SwatchChoices; +pub use resources::{ArtResources, art_resources}; use super::*; use crate::DocState; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index ac01a8129..e6bd96371 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -2,7 +2,7 @@ //! //! Objects refer to resources by name (image blobs, symbols, patterns, swatches, character and //! paragraph styles, brushes) and to graphic styles by id. Copy follows those references, also -//! through the symbols, patterns and styles it finds, and keeps what they lead to. +//! through the symbols, patterns, styles and brush art it finds, and keeps what they lead to. //! //! Pasting matches each resource by name: an identical one in the document is used, a missing one //! is added, and a different one of the same name is added under a free name, which the pasted @@ -13,6 +13,7 @@ //! the document's Spot Colors option says. Pasting back into the document the objects came from //! uses its resources as they are now. +use std::cell::OnceCell; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -147,10 +148,28 @@ struct Defs<'a> { styles: &'a [GraphicStyle], char_styles: &'a [TextStyleDef], para_styles: &'a [TextStyleDef], + brushes: Brushes<'a>, +} + +/// The brushes references lead to: a document's library, read the first time a brush is used +/// (`vectorcraft_brush::library` parses it), or the clipboard's own. +enum Brushes<'a> { + Lazy(&'a Document, &'a OnceCell>), + Ready(&'a [Brush]), +} + +impl<'a> Brushes<'a> { + fn get(&self) -> &'a [Brush] { + match *self { + Brushes::Lazy(d, cell) => cell.get_or_init(|| vectorcraft_brush::library(d)), + Brushes::Ready(list) => list, + } + } } impl<'a> Defs<'a> { - fn of(d: &'a Document) -> Self { + /// `d`'s definitions; `brushes` holds its brush library once a brush is used. + fn of(d: &'a Document, brushes: &'a OnceCell>) -> Self { Self { swatches: d.swatches_iter().collect(), symbols: &d.symbols, @@ -158,6 +177,7 @@ impl<'a> Defs<'a> { styles: &d.graphic_styles, char_styles: &d.char_styles, para_styles: &d.para_styles, + brushes: Brushes::Lazy(d, brushes), } } } @@ -180,9 +200,9 @@ pub(super) struct Used { } impl Used { - fn of<'a>(defs: &Defs<'a>, nodes: &'a [Node]) -> Self { + fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator) -> Self { let mut used = Used::default(); - let mut todo: Vec> = nodes.iter().map(Source::Node).collect(); + let mut todo: Vec> = nodes.into_iter().map(Source::Node).collect(); while let Some(src) = todo.pop() { let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo); match src { @@ -224,6 +244,8 @@ impl Used { 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))), + // 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))), _ => {} } } @@ -242,6 +264,80 @@ impl Used { } } +/// What a preview of some art shows besides the art itself ([`art_resources`]). +pub struct ArtResources { + /// Changes when any resource the art uses changes, and only then. + pub key: u64, + /// The art and image bytes counted in `key` by their address, held weakly: their allocations + /// stay theirs (no other art can take an address) for as long as the key is kept. + _kept: (Vec>, Vec>>), +} + +/// The resources `art` uses in `doc`, through the definitions they lead to as Copy follows them +/// ([`Used`]): images, symbols, patterns (tile type, overlap and spacing too), swatches (linked +/// colours, tints, gradient swatches; colour groups' included), character and paragraph styles, +/// brushes and graphic styles, and the document's Spot Colors option. Art and image bytes count +/// by identity, the other definitions by their saved form: working the key out walks `art` and +/// the definitions it leads to, and never reads an image's bytes. +pub fn art_resources<'a>(doc: &'a Document, art: impl IntoIterator) -> ArtResources { + use std::hash::{Hash, Hasher}; + let library = OnceCell::new(); + let defs = Defs::of(doc, &library); + // The art's references shortened to the brush library's life. + let mut nodes: Vec<&Node> = vec![]; + for n in art { + nodes.push(n); + } + let used = Used::of(&defs, nodes); + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut kept = (vec![], vec![]); + fn saved(h: &mut impl Hasher, v: &impl serde::Serialize) { + serde_json::to_vec(v).unwrap_or_default().hash(h); + } + fn node(h: &mut impl Hasher, kept: &mut Vec>, a: &Arc) { + (Arc::as_ptr(a) as usize).hash(h); + kept.push(Arc::downgrade(a)); + } + for (res, names) in &used.names { + for name in names { + (*res as u8, name).hash(&mut h); + match res { + Res::Image => { + if let Some(b) = doc.images.get(name) { + b.mime.hash(&mut h); + for bytes in std::iter::once(&b.bytes).chain(&b.proxy) { + (Arc::as_ptr(bytes) as usize).hash(&mut h); + kept.1.push(Arc::downgrade(bytes)); + } + } + } + Res::Symbol => doc.symbols.iter().filter(|s| s.name == *name).for_each(|s| node(&mut h, &mut kept.0, &s.art)), + Res::Pattern => { + for p in doc.patterns.iter().filter(|p| p.name == *name) { + saved(&mut h, &PatternDef { art: vec![], ..p.clone() }); + p.art.iter().for_each(|a| node(&mut h, &mut kept.0, a)); + } + } + Res::CharStyle => doc.char_styles.iter().filter(|s| s.name == *name).for_each(|s| saved(&mut h, s)), + Res::ParaStyle => doc.para_styles.iter().filter(|s| s.name == *name).for_each(|s| saved(&mut h, s)), + Res::Brush => defs.brushes.get().iter().filter(|b| b.name == *name).for_each(|b| saved(&mut h, b)), + // Swatches below (tints count too); graphic styles go by id. + Res::Swatch | Res::GraphicStyle => {} + } + } + } + for w in doc.swatches_iter().filter(|w| used.carries(w) || used.has(Res::Swatch, &w.name)) { + saved(&mut h, w); + } + for g in doc.graphic_styles.iter().filter(|g| g.id != 0 && used.styles.contains(&g.id)) { + saved(&mut h, g); + } + if used.names.contains_key(&Res::Swatch) { + doc.spot_use_lab.hash(&mut h); + } + ArtResources { key: h.finish(), _kept: kept } +} + /// A resource list entry known by its name. trait Named: Clone { fn name(&self) -> &str; @@ -469,6 +565,26 @@ fn same_art(r: &Relink, ours: &[Arc], theirs: &[Arc]) -> bool { ours.len() == theirs.len() && ours.iter().zip(theirs).all(|(a, b)| relinked(&unlinked, a) == relinked(r, b)) } +/// Is `ours` (a document brush) brush `theirs` (a pasted one) once the art of that is relinked by +/// `r`? Graphic style links don't count, as in [`same_art`]. +fn same_brush(r: &Relink, ours: &Brush, theirs: &Brush) -> bool { + let relinked = |r: &Relink, b: &Brush| { + let mut b = b.clone(); + b.art_mut().into_iter().for_each(|a| r.node(a)); + b + }; + relinked(&Relink::default(), ours) == relinked(r, theirs) +} + +/// Does brush `b`'s art show symbols or patterns? +fn shows_definitions(b: &Brush) -> bool { + let mut found = false; + for a in b.art() { + node_refs(a, &mut |r| found |= matches!(r, Ref::Name(Res::Symbol | Res::Pattern, _))); + } + found +} + /// What pasting did to the document's resources (part of the paste result). #[derive(Debug, Default)] pub(crate) struct Imported { @@ -501,11 +617,13 @@ impl Imported { impl Clipboard { /// `nodes` (from `src`) and the resources of `src` they use. pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { - let used = Used::of(&Defs::of(src), &nodes); + let library = OnceCell::new(); + let used = Used::of(&Defs::of(src, &library), &nodes); let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); - let brushes = match used.names.get(&Res::Brush) { - Some(names) => vectorcraft_brush::library(src).into_iter().filter(|b| names.contains(&b.name)).collect(), - None => vec![], + // Read when a brush was used. + let brushes = match (used.names.get(&Res::Brush), library.get()) { + (Some(names), Some(library)) => library.iter().filter(|b| names.contains(&b.name)).cloned().collect(), + _ => vec![], }; Self { images: src.images.iter().filter(|(k, _)| used.has(Res::Image, k)).map(|(k, b)| (k.clone(), b.clone())).collect(), @@ -530,6 +648,7 @@ impl Clipboard { styles: &self.graphic_styles, char_styles: &self.char_styles, para_styles: &self.para_styles, + brushes: Brushes::Ready(&self.brushes), }; Used::of(&defs, &self.nodes) } @@ -549,8 +668,10 @@ impl Clipboard { let used = self.used(); let mut out = Imported::default(); self.import_images(d, &used, same_doc, &mut out); - self.import_brushes(d, &used, same_doc, &mut out); 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); // 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)) { @@ -583,6 +704,7 @@ impl Clipboard { added_patterns.extend(p.new_name(&def.name)); out.placed(Res::Pattern, &def.name, &p); } + added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out)); self.import_graphic_styles(d, &used, same_doc, &mut out); for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) { out.relink.node(Arc::make_mut(&mut s.art)); @@ -592,6 +714,13 @@ impl Clipboard { out.relink.node(Arc::make_mut(a)); } } + if !added_brushes.is_empty() { + let mut lib = vectorcraft_brush::library(d); + for b in lib.iter_mut().filter(|b| added_brushes.contains(&b.name)) { + b.art_mut().into_iter().for_each(|a| out.relink.node(a)); + } + vectorcraft_brush::store(d, &lib); + } out } @@ -607,20 +736,26 @@ impl Clipboard { } } - fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) { - let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).collect(); + /// The brushes whose art shows symbols or patterns (`late`), or the others: each one the + /// document has (its art compared once relinked) is used, the others are added. → the names of + /// those added, whose art is relinked at the end. + fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, late: bool, out: &mut Imported) -> Vec { + let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name) && shows_definitions(b) == late).collect(); if brushes.is_empty() { - return; + return vec![]; } let mut lib = vectorcraft_brush::library(d); - let before = lib.len(); + let mut added = vec![]; for b in brushes { - let p = place(&mut lib, b, same_doc, PartialEq::eq); + let r = &out.relink; + let p = place(&mut lib, b, same_doc, |ours, theirs| same_brush(r, ours, theirs)); + added.extend(p.new_name(&b.name)); out.placed(Res::Brush, &b.name, &p); } - if lib.len() != before { + if !added.is_empty() { vectorcraft_brush::store(d, &lib); } + added } /// Global and spot colours (a name conflict merges or adds as `choices` say), then the tint diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dd841c661..d5588ff44 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -339,11 +339,22 @@ fn ellipse(s: &mut Session, p: &Value) -> Result { add_art_turned(s, "Ellipse", path_kind(live.to_path(), Some(live)), f64_or(p, "rotation", 0.0)) } +/// The whole-number count `p` gives for `key` (`5` or `5.0`), or `default` when it is left out or +/// null. A fraction, a negative, a non-finite or a non-number is an error rather than the default. +fn count_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result { + let n = number_or(p, key, default as f64, cmd)?; + if n.is_finite() && n >= 0.0 && n.fract() == 0.0 { + Ok(n as u64) + } else { + Err(bad(cmd, format!("`{key}` must be a whole number of at least 0, not {n}"))) + } +} + fn polygon(s: &mut Session, p: &Value) -> Result { let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?); let r = f64_req(p, "radius", "shape.polygon")?.abs(); - check_numbers(p, &["sides", "rotation"], "shape.polygon")?; - let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32; + check_numbers(p, &["rotation"], "shape.polygon")?; + let sides = count_or(p, "sides", 6, "shape.polygon")?.clamp(3, 1000) as u32; let rot = f64_or(p, "rotation", 0.0); let xf = Affine::translate(c.to_vec2()) * vectorcraft_geom::rotate_deg(rot); let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false }; @@ -353,9 +364,9 @@ fn polygon(s: &mut Session, p: &Value) -> Result { fn star(s: &mut Session, p: &Value) -> Result { let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?); let r1 = f64_req(p, "radius1", "shape.star")?.abs(); - check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?; + check_numbers(p, &["radius2", "rotation"], "shape.star")?; let r2 = f64_or(p, "radius2", r1 / 2.0).abs(); - let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32; + let n = count_or(p, "points", 5, "shape.star")?.clamp(2, 1000) as u32; let rot = f64_or(p, "rotation", 0.0); let turn = vectorcraft_geom::rotate_deg(rot); // A quarter or whole turn (a Constrain Angle of 90°, say) turns the upright star exactly. @@ -500,12 +511,12 @@ fn line(s: &mut Session, p: &Value) -> Result { fn spiral(s: &mut Session, p: &Value) -> Result { let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?); - check_numbers(p, &["segments"], "shape.spiral")?; + let segments = count_or(p, "segments", 10, "shape.spiral")?.clamp(2, 1000) as u32; let path = shapes::spiral( c, f64_req(p, "radius", "shape.spiral")?, number_or(p, "decay", 80.0, "shape.spiral")?, - p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32, + segments, switch_or(p, "clockwise", true, "shape.spiral")?, ); add_art(s, "Spiral", path_kind(path, None), None) @@ -794,6 +805,9 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { } if let Some(f) = str_param(p, "style") { style.font_style = f.to_string(); + } else if str_param(p, "font").is_some() { + // A family without a Regular (#4128): its closest style, as text.setRangeStyle takes. + super::textedit::fit_font_style(&mut style); } style.fill = match p.get("color").and_then(color_value) { Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)), diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 7b18289e9..faf905052 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -209,8 +209,8 @@ fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option= 0.0)?; + let a = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0)?; + let b = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)?; Some(if pd.subpaths.len() == 1 { a.into_iter().chain(b).collect() } else { @@ -249,7 +249,9 @@ fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option> { return Some(if r.is_empty() { vec![] } else { vec![r] }); } let inside = rect.inflate(1e-9, 1e-9); - let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?; + // Sampled at least every half of the rectangle's shorter side, so a line through it is found. + let step = rect.width().min(rect.height()) / 2.0; + let pieces = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?; Some(if pd.subpaths.len() == 1 || pieces.is_empty() { pieces } else { @@ -317,7 +319,7 @@ fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec pieces.into_iter().flat_map(|p| p.subpaths).collect(), None => pd.subpaths.clone(), }; diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 0d7a69639..674d44362 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -17,6 +17,14 @@ use super::edit::{duplicate_in, outermost, selected_roots}; use super::*; use crate::EngineError; +mod perspective; + +pub use perspective::perspective_click; +use perspective::{ + attach, edit_text, grid_set, grid_show, persp_draw, persp_move, persp_nudge, persp_transform, plane_match, plane_move, plane_set, release, +}; +pub(crate) use perspective::{finish_edit_text, grid_of, perspective_rewrite, silent, store_grid, transform_again}; + pub fn specs() -> Vec { vec![ cmd!( @@ -775,612 +783,3 @@ fn puppet_pins(s: &mut Session, p: &Value) -> Result { v["auto"] = json!(set.auto); Ok(v) } - -// ---------- perspective grid ---------- - -/// The document's perspective grid (the default preset when none was defined). -pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid { - PerspectiveGrid::effective(d) -} - -/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`). -pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { - g.store(d); -} - -/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g` -/// leaves `d` as it is. -fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool { - let mut g = g.clone(); - g.attached.clear(); - serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v)) -} - -/// Change view state of the grid without an undo step. -pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { - let st = s.doc_mut()?; - let mut g = grid_of(&st.doc); - f(&mut g); - store_grid(Arc::make_mut(&mut st.doc), &g); - if let Some(it) = &mut st.interaction { - store_grid(Arc::make_mut(&mut it.doc), &g); - } - st.revision += 1; - Ok(g) -} - -fn grid_set(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.grid.set"; - let old = grid_of(&s.doc()?.doc); - let g = old.merged(p).map_err(|e| bad(C, e))?; - let reproject = switch_or(p, "reproject", false, C)?; - // When the document holds this grid already, no object moves, and the document and its undo - // history stay as they are. - if grid_kept(&s.doc()?.doc, &g) { - return Ok(g.definition_json()); - } - s.edit("Define Perspective Grid", |d, _| { - if reproject { - old.adopt_stored_attachments(d); - for (id, plane, depth) in persp::attached_roots(d) { - let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); - let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; - let m = h.then_after(&hi); - warp_checked(d, id, &m)?; - } - } - store_grid(d, &g); - Ok(()) - })?; - Ok(g.definition_json()) -} - -fn grid_show(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.grid.show"; - // A missing or null `visible` toggles the grid. - let want = match p.get("visible") { - None | Some(Value::Null) => None, - Some(_) => Some(switch_or(p, "visible", false, C)?), - }; - let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; - Ok(json!({ "visible": g.visible })) -} - -fn plane_set(s: &mut Session, p: &Value) -> Result { - let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?; - silent(s, |g| g.plane = plane)?; - Ok(json!({ "plane": plane.id() })) -} - -fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result { - let plane = match str_param(p, "plane") { - Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?, - None => g.plane, - }; - if plane == Plane::None { - return Err(EngineError::Other("no active perspective plane".into())); - } - Ok(plane) -} - -fn roots(s: &Session, p: &Value) -> Result> { - match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { - Some(v) => Ok(outermost(&s.doc()?.doc, v)), - None => selected_roots(s), - } -} - -/// Map `n` with `f`, failing if any point would leave the plane's visible side. -/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it), -/// failing if any point would leave the plane's visible side. -fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut pts = vec![]; - collect_points(n, &mut pts); - if pts.iter().any(|q| h.apply(*q).is_none()) { - return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); - } - project_node(n, h); - Ok(()) -} - -/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners -/// (on the nearest gridlines with `snap`). -fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option) -> Result<()> { - let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?; - let m = - map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; - warp_checked(d, id, &m)?; - persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane)); - g.attached.insert(id.0.to_string(), plane); - Ok(()) -} - -fn attach(s: &mut Session, p: &Value) -> Result { - let ids = roots(s, p)?; - let mut g = grid_of(&s.doc()?.doc); - let plane = plane_param(&g, p, "perspective.attach")?; - // With no object to attach, when the document holds the grid already, the document and its undo - // history stay as they are. - if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) { - return Ok(json!({ "ids": [], "plane": plane.id() })); - } - s.edit("Attach to Active Plane", |d, _| { - for id in &ids { - attach_in(d, &mut g, *id, plane, false, None)?; - } - store_grid(d, &g); - Ok(()) - })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "plane": plane.id() })) -} - -fn release(s: &mut Session, p: &Value) -> Result { - let ids = roots(s, p)?; - let mut g = grid_of(&s.doc()?.doc); - // When releasing leaves every object as it is (none is in perspective) and the document holds - // the grid already, the document and its undo history stay as they are. - let d = &s.doc()?.doc; - let unchanged = |id: &NodeId| { - d.node(*id).is_none_or(|n| { - let mut released = n.clone(); - persp::release(&mut released); - released == *n - }) - }; - if ids.iter().all(unchanged) && grid_kept(d, &g) { - return ok(); - } - s.edit("Release with Perspective", |d, _| { - for id in &ids { - g.attached.remove(&id.0.to_string()); - if let Some(n) = d.node_mut(*id) { - persp::release(n); - } - } - store_grid(d, &g); - Ok(()) - })?; - ok() -} - -/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and -/// the depth it ends at. -type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>; - -/// Where an object is in perspective: its plane and depth. -type Attached = (Plane, f64); - -fn beyond_horizon() -> EngineError { - EngineError::Other("the pointer is beyond the plane's horizon".into()) -} - -/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments -/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]). -fn stores_attachments(d: &Document) -> bool { - !PerspectiveGrid::current(d).attached.is_empty() -} - -/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay -/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach -/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an -/// interaction whose document is still its snapshot, the duplicates are the copies the interaction -/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its -/// own ([`stores_attachments`]). -fn persp_edit( - s: &mut Session, - label: &str, - ids: &[NodeId], - fallback: Option, - copy: bool, - step: PerspStep, -) -> Result<(Vec, Option)> { - if ids.is_empty() { - return Err(EngineError::Other("select the objects to transform in perspective".into())); - } - let mut g = grid_of(&s.doc()?.doc); - let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None }; - s.edit(label, |d, sel| { - g.adopt_stored_attachments(d); - let targets = match &kept { - // The document becomes the snapshot with the kept copies, not yet mapped, and the loop - // below maps only the copies. - Some(k) => { - *d = Document::clone(&k.doc); - sel.clone_from(&k.selection); - k.ids.clone() - } - None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?, - None => ids.to_vec(), - }; - let mut first = None; - for id in &targets { - let (plane, depth) = match g.attachment_of(d, *id) { - Some(a) => a, - None => { - let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; - attach_in(d, &mut g, *id, pl, false, None)?; - (pl, g.offset(pl)) - } - }; - first.get_or_insert((plane, depth)); - let (h, depth) = step(&g, plane, depth)?; - warp_checked(d, *id, &h)?; - persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); - } - store_grid(d, &g); - Ok((targets, first)) - }) -} - -/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one -/// of them is in perspective already, `still` holds for each one's plane and depth, and the document -/// holds the grid already; None otherwise. -fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result> { - let d = &s.doc()?.doc; - let g = grid_of(d); - if ids.is_empty() || !grid_kept(d, &g) { - return Ok(None); - } - let at: Option> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect(); - Ok(at.and_then(|at| at.first().copied())) -} - -/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once -/// the drag that made it is committed. -fn record_again(s: &mut Session, again: Value) -> Result<()> { - let st = s.doc_mut()?; - match &mut st.interaction { - Some(it) => it.perspective_again = Some(again), - None => st.last_perspective = Some(again), - } - Ok(()) -} - -fn ids_json(ids: &[NodeId]) -> Value { - json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) -} - -/// A plane-space affine map as `perspective.transform`'s `matrix` param. -fn matrix_json(m: Affine) -> Value { - json!(m.as_coeffs()) -} - -fn persp_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.move"; - let ids = roots(s, p)?; - let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; - let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; - if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) { - return Err(bad(C, "from and to must be finite")); - } - let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); - let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); - // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. - check_switches(p, &["snap"], C)?; - // Snap to Grid: a correction for the objects on the first one's plane and depth. - let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; - let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) - }; - // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. - let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { - if perp { - let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; - Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) - } else { - let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); - Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) - } - }; - let label = if copy { "Copy in Perspective" } else { "Move in Perspective" }; - // When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid - // included) and there is no copy, the document and its undo history stay as they are. - let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| { - if perp { - g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) - } else { - g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) - } - }; - let first_still = if copy { None } else { unmoved(s, &ids, still)? }; - let (targets, first) = match first_still { - Some(at) => (ids, Some(at)), - None => persp_edit(s, label, &ids, fallback, copy, &step)?, - }; - // Transform Again repeats the first object's move, and the identity when no object moved. - if let Some((pl, depth)) = first { - let g = grid_of(&s.doc()?.doc); - let again = match perp { - _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), - true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), - false => { - g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) - } - }; - if let Some(a) = again { - record_again(s, a)?; - } - } - Ok(ids_json(&targets)) -} - -fn persp_transform(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.transform"; - let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; - let dz = number_or(p, "depth", 0.0, C)?; - if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { - return Err(bad(C, "matrix must be finite and invertible, depth finite")); - } - let ids = roots(s, p)?; - let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); - let copy = switch_or(p, "copy", false, C)?; - let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { - let to = depth + dz; - if to.abs() > persp::MAX_DEPTH { - return Err(EngineError::Other("the objects would leave the grid".into())); - } - Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to)) - }; - // An identity without a copy moves no object that is in perspective already: then the document - // and its undo history stay as they are, and Transform Again repeats the identity. - let identity = !copy && m == Affine::IDENTITY && dz == 0.0; - let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() { - ids - } else { - persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0 - }; - record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?; - Ok(ids_json(&targets)) -} - -/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space -/// transform on the selection. -pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { - let mut p = again.clone(); - if let Some(o) = p.as_object_mut() { - o.remove("ids"); - } - persp_transform(s, &p) -} - -fn plane_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.plane.move"; - check_numbers(p, &["offset", "by"], C)?; - let old = grid_of(&s.doc()?.doc); - let plane = plane_param(&old, p, C)?; - let from = old.offset(plane); - let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) { - (Some(o), _) => o, - (None, Some(b)) => from + b, - (None, None) => return Err(bad(C, "give offset or by")), - }; - let objects = str_param(p, "objects").unwrap_or("none"); - if !matches!(objects, "none" | "move" | "copy") { - return Err(bad(C, "objects must be none, move or copy")); - } - let mut g = old.clone(); - g.set_offset(plane, to); - g.validate().map_err(|e| bad(C, e))?; - let label = match objects { - "copy" => "Move Plane and Copy Objects", - "move" => "Move Plane and Objects", - _ => "Move Plane", - }; - // The objects on the plane where it was (not those moved off it along its normal). - let on_plane = |d: &Document| -> Vec { - let editable = d.editable(); - persp::attached_roots(d) - .into_iter() - .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id)) - .map(|(id, ..)| id) - .collect() - }; - // A plane moved to where it is, with its objects or without them, moves nothing: when the - // document holds the grid already, the document and its undo history stay as they are. - if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) { - let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] }; - return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })); - } - // In an interaction whose document is still its snapshot, the copies of the objects on the - // plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid - // lists attachments of its own (`stores_attachments`). - let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) { - let on = on_plane(&s.doc()?.doc); - super::object::preview_copies(s, &on, false)? - } else { - None - }; - let ids = s.edit(label, |d, sel| { - old.adopt_stored_attachments(d); - let mut ids = vec![]; - if objects != "none" { - ids = match &kept { - // The document becomes the snapshot with the kept copies, not yet moved, and the - // loop below moves only the copies. - Some(k) => { - *d = Document::clone(&k.doc); - sel.clone_from(&k.selection); - k.ids.clone() - } - None => { - let on = on_plane(d); - if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on } - } - }; - let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?; - for id in &ids { - warp_checked(d, *id, &m)?; - persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to); - } - } - store_grid(d, &g); - Ok(ids) - })?; - Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })) -} - -fn plane_match(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = id_param(p, "id") - .or_else(|| st.selection.objects.first().copied()) - .ok_or_else(|| EngineError::Other("select an object in perspective".into()))?; - let mut g = grid_of(&st.doc); - let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?; - g.set_offset(plane, depth); - g.plane = plane; - g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?; - // When the plane is at the object and active already, and the document holds that grid, the - // document and its undo history stay as they are. - if grid_kept(&st.doc, &g) { - return Ok(json!({ "plane": plane.id(), "offset": depth })); - } - s.edit("Move Plane to Match Object", |d, _| { - store_grid(d, &g); - Ok(()) - })?; - Ok(json!({ "plane": plane.id(), "offset": depth })) -} - -fn persp_nudge(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.nudge"; - let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); - if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { - return Err(bad(C, "dx and dy give the arrow's direction")); - } - let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 }; - let copy = switch_or(p, "copy", false, C)?; - let ids = selected_roots(s)?; - // The step is measured on the page at the centre of the selection's perspective box. - let st = s.doc()?; - let g = grid_of(&st.doc); - let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; - let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; - let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; - persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy})) -} - -fn persp_draw(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.draw"; - let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string(); - let params = p.get("params").cloned().unwrap_or_else(|| json!({})); - let mut g = grid_of(&s.doc()?.doc); - let plane = plane_param(&g, p, C)?; - let snap = switch_or(p, "snap", g.snap, C)?; - // A click-to-size dialog's shape keeps its sizes in plane units from the click. - let map = match p.get("at") { - None | Some(Value::Null) => None, - Some(v) => { - let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; - Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?) - } - }; - let undo_before = s.doc()?.history.undo.len(); - let r = s.execute(&command, ¶ms)?; - let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?; - let res = s.edit("Draw in Perspective", |d, _| { - attach_in(d, &mut g, id, plane, snap, map)?; - store_grid(d, &g); - Ok(()) - }); - // One undo step for the whole gesture. - let st = s.doc_mut()?; - if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 { - st.history.undo.truncate(undo_before + 1); - if let Some(e) = st.history.undo.last_mut() { - e.label = "Draw in Perspective".into(); - } - st.history.recost_last(&st.doc); - } - res?; - Ok(json!({ "id": id.0, "plane": plane.id() })) -} - -/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane. -pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { - const SHAPES: &[&str] = &[ - "shape.rectangle", - "shape.ellipse", - "shape.polygon", - "shape.star", - "shape.line", - "shape.rectangularGrid", - "shape.arc", - "shape.flare", - "shape.spiral", - "shape.polarGrid", - ]; - if !SHAPES.contains(&cmd) { - return None; - } - let g = PerspectiveGrid::current(&s.active()?.doc); - (g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params}))) -} - -/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the -/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the -/// click), else `None` (run it as it is). -pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> { - let (c, mut p) = perspective_rewrite(s, cmd, params)?; - p["at"] = json!([at.x, at.y]); - Some((c, p)) -} - -/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation -/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]). -fn edit_text(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = - id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?; - if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) { - return Err(EngineError::Other("select type in perspective".into())); - } - s.edit("Edit Text", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?; - let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) }; - // Shown flat where it is drawn: the type moves there and the projection moves the other - // way, so the picture stays. - let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; - let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); - t.transform(shift); - if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { - rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); - rec.editing = true; - } - Ok(()) - })?; - let st = s.doc_mut()?; - st.isolation = Some(id); - st.selection.set([id]); - st.revision += 1; - Ok(json!({ "id": id.0 })) -} - -/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and -/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved -/// state stays saved, [`crate::DocState::change_unsaved`]). -pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { - let clear = |doc: &mut Arc| { - // Undo steps keep no lookup cache: look without building one, and drop the one an edit - // builds (the document now keeps its own). - if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) - && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) - { - p.editing = false; - } - }; - st.change_unsaved(clear); - for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { - let saved = Arc::ptr_eq(&e.doc, &st.saved_doc); - clear(&mut e.doc); - e.doc.drop_lookup_cache(); - if saved { - st.saved_doc = e.doc.clone(); - } - } - if let Some(it) = &mut st.interaction { - clear(&mut it.doc); - } - st.revision += 1; -} diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs new file mode 100644 index 000000000..a655e3e3f --- /dev/null +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -0,0 +1,610 @@ +use super::*; + +// ---------- perspective grid ---------- + +/// The document's perspective grid (the default preset when none was defined). +pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid { + PerspectiveGrid::effective(d) +} + +/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`). +pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { + g.store(d); +} + +/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g` +/// leaves `d` as it is. +fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool { + let mut g = g.clone(); + g.attached.clear(); + serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v)) +} + +/// Change view state of the grid without an undo step. +pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { + let st = s.doc_mut()?; + let mut g = grid_of(&st.doc); + f(&mut g); + store_grid(Arc::make_mut(&mut st.doc), &g); + if let Some(it) = &mut st.interaction { + store_grid(Arc::make_mut(&mut it.doc), &g); + } + st.revision += 1; + Ok(g) +} + +pub(super) fn grid_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.set"; + let old = grid_of(&s.doc()?.doc); + let g = old.merged(p).map_err(|e| bad(C, e))?; + let reproject = switch_or(p, "reproject", false, C)?; + // When the document holds this grid already, no object moves, and the document and its undo + // history stay as they are. + if grid_kept(&s.doc()?.doc, &g) { + return Ok(g.definition_json()); + } + s.edit("Define Perspective Grid", |d, _| { + if reproject { + old.adopt_stored_attachments(d); + for (id, plane, depth) in persp::attached_roots(d) { + let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); + let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; + let m = h.then_after(&hi); + warp_checked(d, id, &m)?; + } + } + store_grid(d, &g); + Ok(()) + })?; + Ok(g.definition_json()) +} + +pub(super) fn grid_show(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.show"; + // A missing or null `visible` toggles the grid. + let want = match p.get("visible") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "visible", false, C)?), + }; + let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; + Ok(json!({ "visible": g.visible })) +} + +pub(super) fn plane_set(s: &mut Session, p: &Value) -> Result { + let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?; + silent(s, |g| g.plane = plane)?; + Ok(json!({ "plane": plane.id() })) +} + +fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result { + let plane = match str_param(p, "plane") { + Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?, + None => g.plane, + }; + if plane == Plane::None { + return Err(EngineError::Other("no active perspective plane".into())); + } + Ok(plane) +} + +pub(super) fn roots(s: &Session, p: &Value) -> Result> { + match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => Ok(outermost(&s.doc()?.doc, v)), + None => selected_roots(s), + } +} + +/// Map `n` with `f`, failing if any point would leave the plane's visible side. +/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it), +/// failing if any point would leave the plane's visible side. +fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut pts = vec![]; + collect_points(n, &mut pts); + if pts.iter().any(|q| h.apply(*q).is_none()) { + return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); + } + project_node(n, h); + Ok(()) +} + +/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners +/// (on the nearest gridlines with `snap`). +fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option) -> Result<()> { + let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?; + let m = + map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; + warp_checked(d, id, &m)?; + persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane)); + g.attached.insert(id.0.to_string(), plane); + Ok(()) +} + +pub(super) fn attach(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let mut g = grid_of(&s.doc()?.doc); + let plane = plane_param(&g, p, "perspective.attach")?; + // With no object to attach, when the document holds the grid already, the document and its undo + // history stay as they are. + if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) { + return Ok(json!({ "ids": [], "plane": plane.id() })); + } + s.edit("Attach to Active Plane", |d, _| { + for id in &ids { + attach_in(d, &mut g, *id, plane, false, None)?; + } + store_grid(d, &g); + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "plane": plane.id() })) +} + +pub(super) fn release(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let mut g = grid_of(&s.doc()?.doc); + // When releasing leaves every object as it is (none is in perspective) and the document holds + // the grid already, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + let unchanged = |id: &NodeId| { + d.node(*id).is_none_or(|n| { + let mut released = n.clone(); + persp::release(&mut released); + released == *n + }) + }; + if ids.iter().all(unchanged) && grid_kept(d, &g) { + return ok(); + } + s.edit("Release with Perspective", |d, _| { + for id in &ids { + g.attached.remove(&id.0.to_string()); + if let Some(n) = d.node_mut(*id) { + persp::release(n); + } + } + store_grid(d, &g); + Ok(()) + })?; + ok() +} + +/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and +/// the depth it ends at. +type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>; + +/// Where an object is in perspective: its plane and depth. +type Attached = (Plane, f64); + +fn beyond_horizon() -> EngineError { + EngineError::Other("the pointer is beyond the plane's horizon".into()) +} + +/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments +/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]). +fn stores_attachments(d: &Document) -> bool { + !PerspectiveGrid::current(d).attached.is_empty() +} + +/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay +/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach +/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an +/// interaction whose document is still its snapshot, the duplicates are the copies the interaction +/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its +/// own ([`stores_attachments`]). +fn persp_edit( + s: &mut Session, + label: &str, + ids: &[NodeId], + fallback: Option, + copy: bool, + step: PerspStep, +) -> Result<(Vec, Option)> { + if ids.is_empty() { + return Err(EngineError::Other("select the objects to transform in perspective".into())); + } + let mut g = grid_of(&s.doc()?.doc); + let kept = if copy && !stores_attachments(&s.doc()?.doc) { crate::cmd::object::preview_copies(s, ids, false)? } else { None }; + s.edit(label, |d, sel| { + g.adopt_stored_attachments(d); + let targets = match &kept { + // The document becomes the snapshot with the kept copies, not yet mapped, and the loop + // below maps only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?, + None => ids.to_vec(), + }; + let mut first = None; + for id in &targets { + let (plane, depth) = match g.attachment_of(d, *id) { + Some(a) => a, + None => { + let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; + attach_in(d, &mut g, *id, pl, false, None)?; + (pl, g.offset(pl)) + } + }; + first.get_or_insert((plane, depth)); + let (h, depth) = step(&g, plane, depth)?; + warp_checked(d, *id, &h)?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); + } + store_grid(d, &g); + Ok((targets, first)) + }) +} + +/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one +/// of them is in perspective already, `still` holds for each one's plane and depth, and the document +/// holds the grid already; None otherwise. +fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result> { + let d = &s.doc()?.doc; + let g = grid_of(d); + if ids.is_empty() || !grid_kept(d, &g) { + return Ok(None); + } + let at: Option> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect(); + Ok(at.and_then(|at| at.first().copied())) +} + +/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once +/// the drag that made it is committed. +fn record_again(s: &mut Session, again: Value) -> Result<()> { + let st = s.doc_mut()?; + match &mut st.interaction { + Some(it) => it.perspective_again = Some(again), + None => st.last_perspective = Some(again), + } + Ok(()) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// A plane-space affine map as `perspective.transform`'s `matrix` param. +fn matrix_json(m: Affine) -> Value { + json!(m.as_coeffs()) +} + +pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.move"; + let ids = roots(s, p)?; + let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; + let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; + if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) { + return Err(bad(C, "from and to must be finite")); + } + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); + // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. + check_switches(p, &["snap"], C)?; + // Snap to Grid: a correction for the objects on the first one's plane and depth. + let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; + let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { + dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) + }; + // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + if perp { + let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; + Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) + } else { + let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); + Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) + } + }; + let label = if copy { "Copy in Perspective" } else { "Move in Perspective" }; + // When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid + // included) and there is no copy, the document and its undo history stay as they are. + let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| { + if perp { + g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) + } else { + g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) + } + }; + let first_still = if copy { None } else { unmoved(s, &ids, still)? }; + let (targets, first) = match first_still { + Some(at) => (ids, Some(at)), + None => persp_edit(s, label, &ids, fallback, copy, &step)?, + }; + // Transform Again repeats the first object's move, and the identity when no object moved. + if let Some((pl, depth)) = first { + let g = grid_of(&s.doc()?.doc); + let again = match perp { + _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), + true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), + false => { + g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) + } + }; + if let Some(a) = again { + record_again(s, a)?; + } + } + Ok(ids_json(&targets)) +} + +pub(super) fn persp_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.transform"; + let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; + let dz = number_or(p, "depth", 0.0, C)?; + if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { + return Err(bad(C, "matrix must be finite and invertible, depth finite")); + } + let ids = roots(s, p)?; + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let copy = switch_or(p, "copy", false, C)?; + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + let to = depth + dz; + if to.abs() > persp::MAX_DEPTH { + return Err(EngineError::Other("the objects would leave the grid".into())); + } + Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to)) + }; + // An identity without a copy moves no object that is in perspective already: then the document + // and its undo history stay as they are, and Transform Again repeats the identity. + let identity = !copy && m == Affine::IDENTITY && dz == 0.0; + let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() { + ids + } else { + persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0 + }; + record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?; + Ok(ids_json(&targets)) +} + +/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space +/// transform on the selection. +pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { + let mut p = again.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("ids"); + } + persp_transform(s, &p) +} + +pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.plane.move"; + check_numbers(p, &["offset", "by"], C)?; + let old = grid_of(&s.doc()?.doc); + let plane = plane_param(&old, p, C)?; + let from = old.offset(plane); + let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) { + (Some(o), _) => o, + (None, Some(b)) => from + b, + (None, None) => return Err(bad(C, "give offset or by")), + }; + let objects = str_param(p, "objects").unwrap_or("none"); + if !matches!(objects, "none" | "move" | "copy") { + return Err(bad(C, "objects must be none, move or copy")); + } + let mut g = old.clone(); + g.set_offset(plane, to); + g.validate().map_err(|e| bad(C, e))?; + let label = match objects { + "copy" => "Move Plane and Copy Objects", + "move" => "Move Plane and Objects", + _ => "Move Plane", + }; + // The objects on the plane where it was (not those moved off it along its normal). + let on_plane = |d: &Document| -> Vec { + let editable = d.editable(); + persp::attached_roots(d) + .into_iter() + .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id)) + .map(|(id, ..)| id) + .collect() + }; + // A plane moved to where it is, with its objects or without them, moves nothing: when the + // document holds the grid already, the document and its undo history stay as they are. + if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) { + let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] }; + return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })); + } + // In an interaction whose document is still its snapshot, the copies of the objects on the + // plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid + // lists attachments of its own (`stores_attachments`). + let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) { + let on = on_plane(&s.doc()?.doc); + crate::cmd::object::preview_copies(s, &on, false)? + } else { + None + }; + let ids = s.edit(label, |d, sel| { + old.adopt_stored_attachments(d); + let mut ids = vec![]; + if objects != "none" { + ids = match &kept { + // The document becomes the snapshot with the kept copies, not yet moved, and the + // loop below moves only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None => { + let on = on_plane(d); + if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on } + } + }; + let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?; + for id in &ids { + warp_checked(d, *id, &m)?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to); + } + } + store_grid(d, &g); + Ok(ids) + })?; + Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = id_param(p, "id") + .or_else(|| st.selection.objects.first().copied()) + .ok_or_else(|| EngineError::Other("select an object in perspective".into()))?; + let mut g = grid_of(&st.doc); + let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?; + g.set_offset(plane, depth); + g.plane = plane; + g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?; + // When the plane is at the object and active already, and the document holds that grid, the + // document and its undo history stay as they are. + if grid_kept(&st.doc, &g) { + return Ok(json!({ "plane": plane.id(), "offset": depth })); + } + s.edit("Move Plane to Match Object", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + Ok(json!({ "plane": plane.id(), "offset": depth })) +} + +pub(super) fn persp_nudge(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.nudge"; + let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return Err(bad(C, "dx and dy give the arrow's direction")); + } + let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 }; + let copy = switch_or(p, "copy", false, C)?; + let ids = selected_roots(s)?; + // The step is measured on the page at the centre of the selection's perspective box. + let st = s.doc()?; + let g = grid_of(&st.doc); + let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; + let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; + let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; + persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy})) +} + +pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.draw"; + let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string(); + let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + let mut g = grid_of(&s.doc()?.doc); + let plane = plane_param(&g, p, C)?; + let snap = switch_or(p, "snap", g.snap, C)?; + // A click-to-size dialog's shape keeps its sizes in plane units from the click. + let map = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => { + let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?) + } + }; + let undo_before = s.doc()?.history.undo.len(); + let r = s.execute(&command, ¶ms)?; + let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?; + let res = s.edit("Draw in Perspective", |d, _| { + attach_in(d, &mut g, id, plane, snap, map)?; + store_grid(d, &g); + Ok(()) + }); + // One undo step for the whole gesture. + let st = s.doc_mut()?; + if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 { + st.history.undo.truncate(undo_before + 1); + if let Some(e) = st.history.undo.last_mut() { + e.label = "Draw in Perspective".into(); + } + st.history.recost_last(&st.doc); + } + res?; + Ok(json!({ "id": id.0, "plane": plane.id() })) +} + +/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane. +pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { + const SHAPES: &[&str] = &[ + "shape.rectangle", + "shape.ellipse", + "shape.polygon", + "shape.star", + "shape.line", + "shape.rectangularGrid", + "shape.arc", + "shape.flare", + "shape.spiral", + "shape.polarGrid", + ]; + if !SHAPES.contains(&cmd) { + return None; + } + let g = PerspectiveGrid::current(&s.active()?.doc); + (g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params}))) +} + +/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the +/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the +/// click), else `None` (run it as it is). +pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> { + let (c, mut p) = perspective_rewrite(s, cmd, params)?; + p["at"] = json!([at.x, at.y]); + Some((c, p)) +} + +/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation +/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]). +pub(super) fn edit_text(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = + id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?; + if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) { + return Err(EngineError::Other("select type in perspective".into())); + } + s.edit("Edit Text", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?; + let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) }; + // Shown flat where it is drawn: the type moves there and the projection moves the other + // way, so the picture stays. + let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; + let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); + t.transform(shift); + if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { + rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); + rec.editing = true; + } + Ok(()) + })?; + let st = s.doc_mut()?; + st.isolation = Some(id); + st.selection.set([id]); + st.revision += 1; + Ok(json!({ "id": id.0 })) +} + +/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and +/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved +/// state stays saved, [`crate::DocState::change_unsaved`]). +pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { + let clear = |doc: &mut Arc| { + // Undo steps keep no lookup cache: look without building one, and drop the one an edit + // builds (the document now keeps its own). + if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) + { + p.editing = false; + } + }; + st.change_unsaved(clear); + for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { + let saved = Arc::ptr_eq(&e.doc, &st.saved_doc); + clear(&mut e.doc); + e.doc.drop_lookup_cache(); + if saved { + st.saved_doc = e.doc.clone(); + } + } + if let Some(it) = &mut st.interaction { + clear(&mut it.doc); + } + st.revision += 1; +} diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 4446bc0ad..0dc6fbad3 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Document Setup", [], None, - "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", + "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", has_doc, setup )] @@ -31,7 +31,6 @@ pub(crate) fn setup_json(d: &Document) -> Value { "units": d.units.label(), "bleed": s.bleed, "outlineImages": s.outline_images, - "highlightSubstitutedFonts": s.highlight_substituted_fonts, "highlightSubstitutedGlyphs": s.highlight_substituted_glyphs, "gridSize": s.grid_size.id(), "gridColors": s.grid_colors.map(|c| c.to_hex()), @@ -72,9 +71,19 @@ fn setup(s: &mut Session, p: &Value) -> Result { } apply(&mut new, &mut units, k, v, &s.prefs.flattener_presets)?; } + // An older build's reply passed back still applies; the moved key only gets a warning. + let reply = |d: &Document| { + let mut r = setup_json(d); + if o.contains_key("highlightSubstitutedFonts") + && let Some(m) = r.as_object_mut() + { + m.insert("warnings".into(), json!([MOVED_FONTS_HIGHLIGHT])); + } + r + }; let st = s.doc()?; if new == st.doc.setup && units == st.doc.units { - return Ok(setup_json(&st.doc)); + return Ok(reply(&st.doc)); } s.edit("Document Setup", |d, _| { let scripts_changed = @@ -86,9 +95,13 @@ fn setup(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(setup_json(&s.doc()?.doc)) + Ok(reply(&s.doc()?.doc)) } +/// The warning for `highlightSubstitutedFonts`, which `document.setup` ignores. +const MOVED_FONTS_HIGHLIGHT: &str = + "highlightSubstitutedFonts is ignored: it is a preference (Preferences › Type), set with prefs.set {key: \"highlightSubstitutedFonts\", value}"; + fn flag(k: &str, v: &Value) -> Result { v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false"))) } @@ -102,7 +115,10 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "units" => *units = v.as_str().and_then(vectorcraft_doc::Unit::named).ok_or_else(|| bad(C, "unknown units"))?, "bleed" => s.bleed = bleed_param(v, s.bleed, C)?, "outlineImages" => s.outline_images = flag(k, v)?, - "highlightSubstitutedFonts" => s.highlight_substituted_fonts = flag(k, v)?, + // Preferences › Type holds it for every document: ignored, with a warning in the reply. + "highlightSubstitutedFonts" => { + flag(k, v)?; + } "highlightSubstitutedGlyphs" => s.highlight_substituted_glyphs = flag(k, v)?, "gridSize" => s.grid_size = v.as_str().and_then(GridSize::parse).ok_or_else(|| bad(C, "gridSize must be small, medium or large"))?, "gridColors" => s.grid_colors = grid_colors(v)?, @@ -125,7 +141,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, "backgroundContents" => s.background = background_param(v, C)?, // Read-only values `document.setup` reports. - "gridColorsName" => {} + "gridColorsName" | "warnings" => {} _ => return Err(bad(C, format!("unknown setting `{k}`"))), } Ok(()) diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index ca5ef0909..5b884b3ee 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -12,6 +12,7 @@ use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions}; +use vectorcraft_tools::draw2::{eraser_area, eraser_tip}; use super::create::{add_look, insert_look}; use super::newart::NewArt; @@ -19,1517 +20,13 @@ use super::pathops::{Beside, remove_each}; use super::*; use crate::EngineError; -pub fn specs() -> Vec { - vec![ - cmd!( - "path.freehand", - "Pencil", - [], - None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", - has_doc, - freehand - ), - cmd!( - "path.curvature", - "Curvature", - [], - None, - "{points: [{x, y, corner?}], closed?, id?} create (or with id: replace) a path curving through the points → {id}", - has_doc, - curvature - ), - cmd!( - "path.curvatureEdit", - "Curvature", - [], - None, - "{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner", - has_doc, - curvature_edit - ), - cmd!( - "path.removeAnchor", - "Delete Anchor Point", - [], - None, - "{id, subpath?, anchor} remove one anchor, re-fitting the curve", - has_doc, - remove_anchor - ), - cmd!( - "path.removeAnchors", - "Remove Anchor Points", - ["Object", "Path"], - None, - "{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}", - has_anchors, - remove_anchors - ), - cmd!( - "path.convertAnchor", - "Convert Anchor Point", - [], - None, - "{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)", - has_doc, - convert_anchor - ), - cmd!( - "path.reshapeSegment", - "Reshape Segment", - [], - None, - "{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does", - has_doc, - reshape_segment - ), - cmd!( - "path.split", - "Split Path", - [], - None, - "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}", - has_doc, - split - ), - cmd!( - "path.knife", - "Knife", - [], - None, - "{points: [[x,y]…]} cut closed shapes (selected, or all when nothing is selected) along the polyline → {ids}", - has_doc, - knife - ), - cmd!( - "path.eraseRegion", - "Eraser", - [], - None, - "{points: [[x,y]…], size?: pt (10)} erase a round-brush stroke from paths (selected, or all) → {ids}", - has_doc, - erase_region - ), - cmd!( - "path.eraseSegments", - "Path Eraser", - [], - None, - "{points: [[x,y]…], width?: pt (8)} erase the parts of selected paths under the drag → {ids}", - has_selection, - erase_segments - ), - cmd!( - "path.smoothRegion", - "Smooth", - [], - None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", - has_selection, - smooth_region - ), - cmd!( - "path.joinScrub", - "Join", - [], - None, - "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}", - has_doc, - join_scrub - ), - cmd!( - "path.blob", - "Blob Brush", - [], - None, - "{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}", - has_doc, - blob - ), - ] -} +mod anchors; +mod commands; +mod common; +mod cut; +mod freehand; +mod smooth; -// ---------- helpers ---------- - -/// `[[x,y]…]` or `[{x,y}…]`. -fn points_param(p: &Value, key: &str, cmd: &str) -> Result> { - Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect()) -} - -/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite -/// number. -fn point_entry(v: &Value, cmd: &str) -> Result { - let xy = match v { - Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)), - Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)), - _ => None, - }; - xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers")) -} - -/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or -/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a -/// number nor null, is an error. -fn samples_param(p: &Value, key: &str, cmd: &str) -> Result)>> { - let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?; - let pts: Vec<(Point, Option)> = a - .iter() - .map(|v| { - let pressure = match v { - Value::Array(xy) => xy.get(2), - _ => v.get("pressure"), - }; - match pressure { - None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))), - Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))), - } - }) - .collect::>()?; - if pts.is_empty() { - return Err(bad(cmd, "need at least one point")); - } - Ok(pts) -} - -/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of -/// its samples has one; samples without one count as full pressure, as a pen event without one. -fn stroke_pressure(samples: &[(Point, Option)]) -> Option { - if samples.iter().all(|(_, p)| p.is_none()) { - return None; - } - let mut at = 0.0; - let mut prev: Option = None; - let along: Vec<(f64, f64)> = samples - .iter() - .map(|(pt, p)| { - at += prev.map_or(0.0, |q| q.distance(*pt)); - prev = Some(*pt); - (at, p.unwrap_or(1.0)) - }) - .collect(); - PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p))) -} - -/// The length of a subpath. -fn subpath_len(sp: &SubPath) -> f64 { - use kurbo::ParamCurveArclen; - PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum() -} - -/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a -/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same -/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`). -const MAX_INPUT: f64 = 1e9; - -/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size. -fn within_input_bound(q: Point) -> bool { - q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT -} - -/// The whole number of 0 or more that `key` holds, read with [`index`]. -fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) -} - -/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other -/// value is [`usize_req`]'s error. -pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(_) => usize_req(p, key, cmd).map(Some), - } -} - -fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - match &mut n.kind { - NodeKind::Path { path, live, .. } => { - *live = None; - Ok(path) - } - _ => Err(EngineError::Other(format!("object {id} is not a path"))), - } -} - -fn path_of(d: &Document, id: NodeId) -> Result<(PathData, FillRule)> { - match d.node(id).map(|n| &n.kind) { - Some(NodeKind::Path { path, rule, .. }) => Ok((path.clone(), *rule)), - Some(_) => Err(EngineError::Other(format!("object {id} is not a path"))), - None => Err(EngineError::NoNode(id)), - } -} - -fn dist_to_polyline(pts: &[Point], q: Point) -> f64 { - if pts.len() == 1 { - return pts[0].distance(q); - } - pts.windows(2) - .map(|w| { - let (a, b) = (w[0], w[1]); - let ab = b - a; - let l2 = ab.hypot2(); - let t = if l2 < 1e-18 { 0.0 } else { ((q - a).dot(ab) / l2).clamp(0.0, 1.0) }; - (a + ab * t).distance(q) - }) - .fold(f64::INFINITY, f64::min) -} - -fn poly_bounds(pts: &[Point], pad: f64) -> Rect { - let r = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)); - r.inflate(pad, pad) -} - -/// The filled area painted by a round brush of diameter `size` along `pts`. -fn brush_region(pts: &[Point], size: f64) -> PathData { - let mut pts = pts.to_vec(); - if pts.len() == 1 { - pts.push(pts[0] + Vec2::new(0.01, 0.0)); - } - po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), size, Cap::Round, Join::Round, 4.0) -} - -/// Path leaves the area tools act on: under the selected objects, or every path when nothing is -/// selected. Only editable, non-guide paths whose bounds meet `area`. -fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { - let mut out = vec![]; - let mut push = |n: &Node| { - if let NodeKind::Path { guide: false, path, .. } = &n.kind - && path.control_bounds().is_some_and(|b| b.inflate(0.5, 0.5).intersect(area).area() > 0.0 || b.width() == 0.0 || b.height() == 0.0) - && !out.contains(&n.id) - { - out.push(n.id); - } - }; - if sel.is_empty() { - doc.walk(&mut push); - } else { - for id in &sel.objects { - if let Some(n) = doc.node(*id) { - n.walk(&mut |c: &Node| push(c)); - } - } - } - let editable = doc.editable(); - out.retain(|id| editable(*id)); - out -} - -/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. -fn compound_members(d: &mut Document, pd: PathData) -> Vec> { - pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() -} - -/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members -/// are rebuilt, one per subpath). -pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> { - if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) { - let members = compound_members(d, pd); - if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { - *children = members; - } - return Ok(()); - } - *path_mut(d, id)? = pd; - Ok(()) -} - -/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`. -fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result { - let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone(); - n.id = d.alloc_id(); - match &mut n.kind { - NodeKind::Path { path, live, .. } => { - *path = pd; - *live = None; - } - NodeKind::Compound { children, .. } => *children = compound_members(d, pd), - _ => {} - } - Ok(n) -} - -/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above -/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids. -pub(super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec) -> Result> { - let mut it = pieces.into_iter(); - let Some(first) = it.next() else { - cut.put(d, id, vec![])?; - return Ok(vec![]); - }; - let mut ids = vec![id]; - let mut above = vec![]; - for pd in it { - let n = sibling_with(d, id, pd)?; - ids.push(n.id); - above.push(n); - } - set_geometry(d, id, first)?; - if !above.is_empty() { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; - cut.put(d, id, [n].into_iter().chain(above).collect())?; - } - Ok(ids) -} - -pub(super) fn ids_json(ids: &[NodeId]) -> Value { - json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) -} - -/// Split a filled path into connected pieces (each outer contour with its holes). -pub(super) fn components(pd: &PathData) -> Vec { - let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect(); - if subs.len() <= 1 { - return if pd.is_empty() { vec![] } else { vec![pd.clone()] }; - } - let bps: Vec = subs - .iter() - .map(|s| { - let mut b = BezPath::new(); - s.to_bezpath_into(&mut b); - b - }) - .collect(); - let samples: Vec = subs.iter().map(|s| s.segment(0).eval(0.5)).collect(); - let n = subs.len(); - let inside = |j: usize, i: usize| fill_contains(&bps[j], FillRule::NonZero, samples[i]); - let depth: Vec = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(j, i)).count()).collect(); - let areas: Vec = subs.iter().map(|s| s.area().abs()).collect(); - let outers: Vec = (0..n).filter(|&i| depth[i].is_multiple_of(2)).collect(); - let mut groups: Vec> = outers.iter().map(|&o| vec![subs[o].clone()]).collect(); - for h in (0..n).filter(|&i| !depth[i].is_multiple_of(2)) { - let best = outers - .iter() - .enumerate() - .filter(|(_, o)| depth[**o] + 1 == depth[h] && inside(**o, h)) - .min_by(|a, b| areas[*a.1].total_cmp(&areas[*b.1])); - if let Some((k, _)) = best { - groups[k].push(subs[h].clone()); - } - } - groups.into_iter().map(PathData::new).collect() -} - -/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. -fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option> { - let nseg = sp.segment_count(); - if nseg == 0 { - return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); - } - const K: usize = 24; - let mut runs: Vec> = vec![]; - let mut cur: Vec<(usize, f64, f64)> = vec![]; - let mut any = false; - for i in 0..nseg { - let c = sp.segment(i); - let mut prev_t = 0.0; - let mut prev_rm = remove(c.eval(0.0)); - let mut start = if prev_rm { None } else { Some(0.0) }; - if prev_rm { - any = true; - if !cur.is_empty() { - runs.push(std::mem::take(&mut cur)); - } - } - for k in 1..=K { - let t = k as f64 / K as f64; - let rm = remove(c.eval(t)); - if rm != prev_rm { - let (mut lo, mut hi) = (prev_t, t); - for _ in 0..30 { - let m = 0.5 * (lo + hi); - if remove(c.eval(m)) == prev_rm { lo = m } else { hi = m } - } - let b = 0.5 * (lo + hi); - if prev_rm { - start = Some(b); - } else { - any = true; - if let Some(s) = start.take() { - cur.push((i, s, b)); - } - runs.push(std::mem::take(&mut cur)); - } - } - prev_t = t; - prev_rm = rm; - } - if let Some(s) = start { - cur.push((i, s, 1.0)); - } - } - if !cur.is_empty() { - runs.push(cur); - } - runs.retain(|r| !r.is_empty()); - if !any { - return None; - } - // A closed path's last and first runs meet at anchor 0. - if sp.closed && runs.len() >= 2 { - let first_starts = runs.first().and_then(|r| r.first()).is_some_and(|s| s.0 == 0 && s.1 == 0.0); - let last_ends = runs.last().and_then(|r| r.last()).is_some_and(|s| s.0 + 1 == nseg && s.2 == 1.0); - if first_starts && last_ends { - let first = runs.remove(0); - if let Some(last) = runs.last_mut() { - last.extend(first); - } - } - } - let out = runs - .iter() - .filter(|r| r.len() > 1 || r[0].2 - r[0].1 > 1e-4) - .map(|r| { - let mut anchors: Vec = vec![]; - for &(i, t0, t1) in r { - let c = sp.segment(i).subsegment(t0..t1); - let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) }; - match anchors.last_mut() { - Some(a) => a.h_out = h1, - None => anchors.push(Anchor { h_out: h1, ..Anchor::corner(c.p0) }), - } - anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner }); - } - for a in &mut anchors { - *a = Anchor::with_handles(a.p, a.h_in, a.h_out); - } - SubPath::new(anchors, false) - }) - .filter(|s| s.anchors.len() >= 2) - .collect(); - Some(out) -} - -/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per -/// output object (one object per piece when the path had a single subpath), or None if unchanged. -pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option> { - let mut changed = false; - let mut subs = vec![]; - for sp in &pd.subpaths { - match cut_subpath(sp, remove) { - Some(pieces) => { - changed = true; - subs.extend(pieces); - } - None => subs.push(sp.clone()), - } - } - if !changed { - return None; - } - Some(if pd.subpaths.len() == 1 { - subs.into_iter().map(PathData::single).collect() - } else if subs.is_empty() { - vec![] - } else { - vec![PathData::new(subs)] - }) -} - -fn stroke_paint(s: &Session) -> Paint { - if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } -} - -fn path_kind(path: PathData) -> NodeKind { - NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false } -} - -// ---------- Pencil / Paintbrush ---------- - -/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay -/// corners) and simplify it to `tol`. -fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { - let min = (tol * 0.3).max(0.05); - let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) }; - let mut kept: Vec = vec![p0]; - for &p in pts.iter().skip(1) { - if kept.last().is_none_or(|k| k.distance(p) >= min) { - kept.push(p); - } - } - // The last sample always ends the stroke. - if kept.len() > 1 { - if let Some(k) = kept.last_mut() { - *k = pn; - } - } else if pts.len() > 1 { - kept.push(pn); - } - let n = kept.len(); - let marked: Vec<(Point, bool)> = (0..n) - .map(|i| { - let corner = if i == 0 || i + 1 == n { - false - } else { - let (a, b) = (kept[i] - kept[i - 1], kept[i + 1] - kept[i]); - a.dot(b) / (a.hypot() * b.hypot()).max(1e-12) < -0.2 // turn > ~100° - }; - (kept[i], corner) - }) - .collect(); - let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed); - if sp.anchors.len() < 3 { - return sp; - } - let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false }); - fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) -} - -/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of -/// `pd` to another, each within `within` of it, replaces the section between them (the shorter -/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new -/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once. -fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> { - use kurbo::ParamCurveArclen; - let (first, last) = (*pts.first()?, *pts.last()?); - let (si, _, _, _, d_a) = pd.nearest(first)?; - let (sj, _, _, _, d_b) = pd.nearest(last)?; - if si != sj || d_a > within || d_b > within || pts.len() < 2 { - return None; - } - let mut sp = pd.subpaths.get(si)?.clone(); - if sp.anchors.len() < 2 { - return None; - } - // The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each - // point is looked up on the subpath as it is by then: splitting a straight segment makes two - // new ones, which a `t` along the old one doesn't measure. - let place = |sp: &mut SubPath, q: Point| { - let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?; - let n = sp.anchors.len(); - Some(if t <= 1e-6 { - (seg % n, false) - } else if t >= 1.0 - 1e-6 { - ((seg + 1) % n, false) - } else { - (sp.insert_anchor(seg, t), true) - }) - }; - let (mut from, _) = place(&mut sp, first)?; - let (to, added) = place(&mut sp, last)?; - if added && to <= from { - from += 1; - } - if from == to { - return None; - } - let n = sp.anchors.len(); - // The length of the run of segments from anchor i forward to anchor j. - let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::(); - // The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from - // `end` round to `start`, then the stroke's inner anchors follow. - let (start, end, reversed) = if sp.closed { - if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) } - } else if from < to { - (from, to, false) - } else { - (to, from, true) - }; - let mut stroke = pts.to_vec(); - if reversed { - stroke.reverse(); - } - let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None }; - *s0 = s1.p; - let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None }; - *e0 = e1.p; - let fit = fit_freehand(&stroke, tol, false).anchors; - let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None }; - let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default(); - let (before, after): (Vec, Vec) = if sp.closed { - ((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![]) - } else { - (sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec()) - }; - let mut anchors = before; - if let Some(a) = anchors.last_mut() { - *a = Anchor::with_handles(a.p, a.h_in, f0.h_out); - } - anchors.extend_from_slice(inner); - let at_end = anchors.len(); - anchors.extend(after); - // The anchor the stroke ends on: the first after it, or round to the first of a closed one. - let end_at = if sp.closed { 0 } else { at_end }; - if let Some(a) = anchors.get_mut(end_at) { - *a = Anchor::with_handles(a.p, f1.h_in, a.h_out); - } - Some((si, SubPath::new(anchors, sp.closed))) -} - -fn freehand(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.freehand"; - let samples = samples_param(p, "points", C)?; - if samples.iter().any(|(q, _)| !within_input_bound(*q)) { - return Err(bad(C, "x and y must be finite")); - } - let pressure = stroke_pressure(&samples); - let mut pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); - let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0); - let closed = switch_or(p, "closed", false, C)?; - let brush = str_param(p, "style") == Some("brush"); - let keep_selected = switch_or(p, "keepSelected", true, C)?; - let filled = switch_or(p, "fill", false, C)?; - if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; - let at_start = str_param(ext, "end") == Some("start"); - let (pd, _) = path_of(&s.doc()?.doc, id)?; - let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; - let mut sp = pd.subpaths[si].clone(); - let old_len = subpath_len(&sp); - if at_start { - sp.reverse(); - } - let (Some(first), Some(end)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) else { - return Err(bad(C, "path has no open end")); - }; - let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; - *p0 = end; - if pts.len() < 2 { - return Ok(json!({ "id": id.0 })); - } - let close_tol = (tol * 4.0).max(6.0); - let closing = sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); - if closing && let Some(l) = pts.last_mut() { - *l = first; - } - let fit = fit_freehand(&pts, tol, false); - let mut add = fit.anchors; - if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) { - last.h_out = a0.h_out; - } - if !add.is_empty() { - add.remove(0); - } - sp.anchors.extend(add); - if closing - && sp.anchors.len() > 2 - && let Some(l) = sp.anchors.pop() - && let Some(a0) = sp.anchors.first_mut() - { - a0.h_in = l.h_in; - sp.closed = true; - } - for a in &mut sp.anchors { - *a = Anchor::with_handles(a.p, a.h_in, a.h_out); - } - // The path's pressure (in the direction it is continued in) followed by the stroke's. - let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone()); - let joined = (old_pressure.is_some() || pressure.is_some()).then(|| { - let old = old_pressure.map(|p| if at_start { p.reversed() } else { p }); - let total = subpath_len(&sp); - PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len) - }); - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { - let reverse = at_start && !sp.closed; - if reverse { - sp.reverse(); - } - path_mut(d, id)?.subpaths[si] = sp; - if let Some(joined) = joined - && let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) - { - st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined }; - } - if keep_selected { - sel.set([id]); - } else { - sel.remove(id); - } - Ok(()) - })?; - return Ok(json!({ "id": id.0 })); - } - if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; - let within = number_or(rd, "within", 12.0, C)?; - if !within.is_finite() || within < 0.0 { - return Err(bad(C, "redraw within must be a finite distance of 0 or more")); - } - let (pd, _) = path_of(&s.doc()?.doc, id)?; - // shortcut: a stroke pressure profile is left as it was, measured along the old path; fit - // it to the new length if Calligraphic pressure strokes get redrawn. - if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) { - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { - *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; - if keep_selected { - sel.set([id]); - } else { - sel.remove(id); - } - Ok(()) - })?; - return Ok(json!({ "id": id.0 })); - } - } - if pts.len() < 2 { - return Err(bad(C, "need at least two points")); - } - let sp = fit_freehand(&pts, tol, closed); - let fill = if filled { s.paint.fill.clone() } else { Paint::None }; - // A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing - // gets black. A brush is its stroke. - let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() }; - let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1)); - if let Some(st) = look.appearance.stroke_mut() { - st.pressure = pressure; - } - insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected) -} - -// ---------- Curvature ---------- - -fn curvature(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.curvature"; - let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; - let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::>()?; - if pts.is_empty() { - return Err(bad(C, "need at least one point")); - } - let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); - if let Some(id) = id_param(p, "id") { - s.edit("Curvature", |d, sel| { - *path_mut(d, id)? = PathData::single(sp); - sel.set([id]); - Ok(()) - })?; - return Ok(json!({ "id": id.0 })); - } - let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); - let ap = &mut look.appearance; - if ap.stroke_paint().is_none() && ap.fill_paint().is_none() { - ap.set_stroke(Paint::solid(Color::BLACK)); - } - add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None) -} - -/// An edit `path.curvatureEdit` makes. -enum CurvatureOp { - Move(usize, Point), - Insert(usize, f64, Option), - Extend(Point), - Close, -} - -fn curvature_edit(s: &mut Session, p: &Value) -> Result { - use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; - const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - check_numbers(p, &["x", "y", "t"], C)?; - let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)), - (None, None) => None, - _ => return Err(bad(C, "x and y must be finite numbers")), - }; - let to = || at.ok_or_else(|| bad(C, "missing x, y")); - let start = match str_param(p, "end") { - None | Some("end") => false, - Some("start") => true, - Some(e) => return Err(bad(C, format!("unknown end {e:?}"))), - }; - let how = match str_param(p, "from") { - None => EndCurve::Keep, - Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?, - }; - let corner = switch_or(p, "corner", false, C)?; - let op = match str_param(p, "op") { - Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?), - Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at), - Some("extend") => CurvatureOp::Extend(to()?), - Some("close") => CurvatureOp::Close, - Some(o) => return Err(bad(C, format!("unknown op {o:?}"))), - None => return Err(bad(C, "missing op")), - }; - let anchor = s.edit("Curvature", |d, sel| { - let path = path_mut(d, id)?; - let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed { - return Err(EngineError::Other("path is already closed".into())); - } - let ai = match op { - CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?, - CurvatureOp::Insert(seg, t, q) => { - let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?; - if let Some(q) = q { - curvature_move(sp, ai, q); - } - ai - } - CurvatureOp::Extend(q) => { - curvature_extend(sp, start, q, how, corner); - if start { 0 } else { sp.anchors.len() - 1 } - } - CurvatureOp::Close => { - if sp.anchors.len() < 3 { - return Err(EngineError::Other("closing needs three anchors".into())); - } - curvature_close(sp, start, how); - if start { sp.anchors.len() - 1 } else { 0 } - } - }; - sel.set([id]); - sel.anchors.insert(id, [(si, ai)].into()); - Ok(ai) - })?; - Ok(json!({ "anchor": anchor })) -} - -// ---------- anchor tools ---------- - -fn remove_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let ai = usize_req(p, "anchor", C)?; - let removed = s.edit("Delete Anchor Point", |d, sel| { - let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - if ai >= sp.anchors.len() { - return Err(EngineError::Other("no such anchor".into())); - } - let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?; - emptied.finish(d)?; - Ok(removed) - })?; - Ok(json!({ "removedObject": removed })) -} - -fn remove_anchors(s: &mut Session, _: &Value) -> Result { - let st = s.doc()?; - let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st - .selection - .anchors - .iter() - .filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))) - .map(|(id, set)| (*id, set.iter().copied().collect())) - .collect(); - if jobs.is_empty() { - return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); - } - let removed = s.edit("Remove Anchor Points", |d, sel| { - let mut removed = 0; - let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); - for (id, anchors) in jobs { - let shape: Vec<(usize, bool)> = - d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); - let next = following_anchors(&shape, &anchors); - if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { - removed += 1; - } else if !next.is_empty() { - // The next anchor along is selected, so the next Remove Anchor Points goes on from - // there without picking it first (#2241). - sel.anchors.insert(id, next); - } - } - emptied.finish(d)?; - Ok(removed) - })?; - Ok(json!({ "removedObjects": removed })) -} - -/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a -/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its -/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's -/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. -fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { - let mut next = BTreeSet::new(); - let mut kept = 0; - for (si, &(n, closed)) in shape.iter().enumerate() { - let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); - if n - gone.len() < 2 { - continue; - } - if let Some(&last) = gone.last() { - let wrap = if closed { 0..last } else { 0..0 }; - let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); - if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { - next.insert((kept, a - gone.range(..a).count())); - } - } - kept += 1; - } - next -} - -/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round -/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed -/// through `emptied`. True when the path went. -fn remove_anchors_of( - d: &mut Document, - sel: &mut Selection, - emptied: &mut Beside, - id: NodeId, - anchors: impl IntoIterator, -) -> Result { - let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); - // The last first, so the indexes still to come stay valid. - anchors.sort_unstable_by(|a, b| b.cmp(a)); - anchors.dedup(); - let path = path_mut(d, id)?; - for (si, ai) in anchors { - if let Some(sp) = path.subpaths.get_mut(si) { - po::remove_anchor(sp, ai); - } - } - path.subpaths.retain(|sp| sp.anchors.len() >= 2); - let gone = path.is_empty(); - if gone { - emptied.put(d, id, vec![])?; - sel.remove(id); - } else { - sel.anchors.remove(&id); - sel.segments.remove(&id); - } - Ok(gone) -} - -fn convert_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let ai = usize_req(p, "anchor", C)?; - let smooth = str_param(p, "to") == Some("smooth"); - check_numbers(p, &["x", "y"], C)?; - let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => Some(Point::new(x, y)), - (None, None) => None, - _ => return Err(bad(C, "x and y must be given together")), - }; - if target.is_some_and(|q| !within_input_bound(q)) { - return Err(bad(C, "x and y must be finite")); - } - s.edit("Convert Anchor Point", |d, _| { - let path = path_mut(d, id)?; - let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - if smooth && target.is_none() { - if ai >= sp.anchors.len() { - return Err(EngineError::Other("no such anchor".into())); - } - // Without a handle position the anchor curves as the Curvature tool curves a point; one - // with no direction to take stays as it is. - vectorcraft_tools::draw2::curvature_smooth(sp, ai); - return Ok(()); - } - let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; - match target { - Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t), - _ => a.retract(), - } - Ok(()) - })?; - ok() -} - -fn reshape_segment(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let seg = usize_req(p, "segment", C)?; - let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); - let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); - if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) { - return Err(bad(C, "invalid delta")); - } - let perpendicular = switch_or(p, "perpendicular", false, C)?; - s.edit("Reshape", |d, _| { - let path = path_mut(d, id)?; - let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; - if !bent { - return Err(EngineError::Other("no such segment".into())); - } - Ok(()) - })?; - ok() -} - -// ---------- Scissors ---------- - -fn split(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let anchor = usize_opt(p, "anchor", C)?; - let seg = usize_opt(p, "segment", C)?; - if anchor.is_none() && seg.is_none() { - return Err(bad(C, "need `anchor` or `segment` + `t`")); - } - let t = f64_or(p, "t", 0.5); - if !t.is_finite() || p.get("t").is_some_and(|v| !v.is_f64() && !v.is_i64() && !v.is_u64()) { - return Err(bad(C, "invalid t")); - } - let ids = s.edit("Split Path", |d, sel| { - let path = path_mut(d, id)?; - let mut sp = path.subpaths.get(si).cloned().ok_or_else(|| EngineError::Other("no such subpath".into()))?; - let n = sp.anchors.len(); - let k = match (anchor, seg) { - (Some(a), _) if a < n => a, - (Some(_), _) => return Err(EngineError::Other("no such anchor".into())), - (None, Some(g)) if g < sp.segment_count() => { - if t <= 1e-6 { - g - } else if t >= 1.0 - 1e-6 { - (g + 1) % n - } else { - sp.insert_anchor(g, t) - } - } - _ => return Err(EngineError::Other("no such segment".into())), - }; - let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k])); - if sp.closed { - path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?; - sel.set([id]); - return Ok(vec![id]); - } - let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?; - if path.subpaths.len() == 1 { - path.subpaths[0] = left; - let node = sibling_with(d, id, PathData::single(right))?; - let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; - let nid = d.insert(parent, idx + 1, node)?; - sel.set([id, nid]); - Ok(vec![id, nid]) - } else { - path.subpaths[si] = left; - path.subpaths.insert(si + 1, right); - sel.set([id]); - Ok(vec![id]) - } - })?; - Ok(ids_json(&ids)) -} - -// ---------- Knife / Eraser ---------- - -/// Width of the slit the knife leaves between pieces. -const KNIFE_GAP: f64 = 0.05; - -fn knife(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.knife"; - let pts = points_param(p, "points", C)?; - if pts.len() < 2 { - return Err(bad(C, "need at least two points")); - } - let st = s.doc()?; - let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0); - let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) { - let (pd, rule) = path_of(&st.doc, id)?; - if !pd.is_closed() { - continue; - } - let pieces = components(&po::boolean(&pd, rule, &strip, FillRule::NonZero, BoolOp::Difference)); - if pieces.len() >= 2 { - plan.push((id, pieces)); - } - } - if plan.is_empty() { - return Ok(json!({ "ids": [] })); - } - let ids = s.edit("Knife", |d, sel| { - let mut all = vec![]; - let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); - for (id, pieces) in plan { - all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); - } - cut.finish(d)?; - sel.set(all.iter().copied()); - Ok(all) - })?; - Ok(ids_json(&ids)) -} - -fn erase_region(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.eraseRegion"; - let pts = points_param(p, "points", C)?; - let size = number_or(p, "size", 10.0, C)?; - if !(size.is_finite() && size > 0.0) { - return Err(bad(C, "size must be positive")); - } - let r = size / 2.0; - let st = s.doc()?; - let region = brush_region(&pts, size); - let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { - let (pd, rule) = path_of(&st.doc, id)?; - if pd.is_closed() { - if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { - continue; - } - plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); - } else if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= r) { - plan.push((id, pieces)); - } - } - apply_plan(s, "Eraser", plan) -} - -fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { - if plan.is_empty() { - return Ok(json!({ "ids": [] })); - } - let ids = s.edit(label, |d, sel| { - let mut all = vec![]; - let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); - for (id, pieces) in plan { - all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); - } - cut.finish(d)?; - sel.set(all.iter().copied()); - Ok(all) - })?; - Ok(ids_json(&ids)) -} - -fn erase_segments(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.eraseSegments"; - let pts = points_param(p, "points", C)?; - let w = number_or(p, "width", 8.0, C)?; - if !(w.is_finite() && w > 0.0) { - return Err(bad(C, "width must be positive")); - } - let st = s.doc()?; - let mut plan = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) { - let (pd, _) = path_of(&st.doc, id)?; - if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { - plan.push((id, pieces)); - } - } - apply_plan(s, "Path Eraser", plan) -} - -// ---------- Smooth ---------- - -/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { - let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); - let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; - let (f, l) = (anchors[0], anchors[anchors.len() - 1]); - let last = out.len() - 1; - out[0].p = f.p; - out[0].h_in = f.h_in; - out[last].p = l.p; - out[last].h_out = l.h_out; - for a in &mut out { - *a = Anchor::with_handles(a.p, a.h_in, a.h_out); - } - out -} - -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { - let n = sp.anchors.len(); - let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); - if n < 3 || !marked.iter().any(|m| *m) { - return None; - } - if sp.closed { - let Some(k) = marked.iter().position(|m| !m) else { - // Everything is in range: smooth the whole closed path. - let f = - po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); - return f.subpaths.into_iter().next(); - }; - // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. - let mut open = sp.clone(); - open.anchors.rotate_left(k); - let mut m = marked.clone(); - m.rotate_left(k); - open.anchors.push(open.anchors[0]); - m.push(false); - open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol); - let l = res.pop()?; - if let Some(r0) = res.first_mut() { - r0.h_in = l.h_in; - } - return Some(SubPath::new(res, true)); - } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) -} - -/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it -/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver -/// is outside. -fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> { - const STEPS: u32 = 32; - let step = 1.0 / f64::from(STEPS); - // Back from the end in reach to the first sample outside, then halving to the crossing. - let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?; - let (mut lo, mut hi) = (outside, outside + step); - for _ in 0..20 { - let mid = (lo + hi) / 2.0; - if near(c.eval(mid)) { - hi = mid; - } else { - lo = mid; - } - } - // A straight segment's parts stay straight, their handles on their ends. - let straight = c.p1 == c.p0 && c.p2 == c.p3; - let part = |range| { - let s = c.subsegment(range); - if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s } - }; - (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) -} - -/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments -/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under -/// it, and the rest of a long segment next to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { - let n = anchors.len(); - let mut runs = vec![]; - let mut i = 0; - while i < n { - if marked[i] { - let a = i; - while i + 1 < n && marked[i + 1] { - i += 1; - } - runs.push((a, i)); - } - i += 1; - } - let mut out = anchors.to_vec(); - for (a, b) in runs.into_iter().rev() { - let lo = a.saturating_sub(1); - let hi = (b + 1).min(n - 1); - if hi - lo < 2 { - continue; - } - let mut piece = out[lo..=hi].to_vec(); - // The anchors outside the reach, kept with the part of their segment that is outside. - let (mut head, mut tail) = (None, None); - if hi > b - && let [.., inner, outer] = piece.as_mut_slice() - && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near) - { - inner.h_out = reach.p2; - tail = Some(Anchor { h_in: far.p1, ..*outer }); - *outer = Anchor::with_handles(far.p3, reach.p1, far.p2); - } - if lo < a - && let [outer, inner, ..] = piece.as_mut_slice() - && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near) - { - inner.h_in = reach.p2; - head = Some(Anchor { h_out: far.p1, ..*outer }); - *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); - } - let new = refit_run(&piece, tol); - out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); - } - out -} - -fn smooth_region(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.smoothRegion"; - let pts = points_param(p, "points", C)?; - let radius = number_or(p, "radius", 12.0, C)?.max(0.0); - let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0); - let st = s.doc()?; - let near = |q: Point| dist_to_polyline(&pts, q) <= radius; - let mut plan = vec![]; - let (mut before, mut after) = (0, 0); - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { - let (pd, _) = path_of(&st.doc, id)?; - let mut changed = false; - let subs: Vec = pd - .subpaths - .iter() - .map(|sp| match smooth_subpath(sp, &near, tol) { - Some(n) if n != *sp && n.anchors.len() >= 2 => { - changed = true; - n - } - _ => sp.clone(), - }) - .collect(); - if changed { - let np = PathData::new(subs); - before += pd.anchor_count(); - after += np.anchor_count(); - plan.push((id, np)); - } - } - if plan.is_empty() { - return Ok(json!({ "ids": [], "before": 0, "after": 0 })); - } - let ids: Vec = plan.iter().map(|(id, _)| *id).collect(); - s.edit("Smooth", |d, _| { - for (id, np) in plan { - *path_mut(d, id)? = np; - } - Ok(()) - })?; - let mut v = ids_json(&ids); - v["before"] = json!(before); - v["after"] = json!(after); - Ok(v) -} - -// ---------- Join tool ---------- - -fn join_scrub(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.joinScrub"; - let pts = points_param(p, "points", C)?; - let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0); - let st = s.doc()?; - // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). - let poly = { - let mut bp = BezPath::new(); - bp.move_to(pts[0]); - for q in &pts[1..] { - bp.line_to(*q); - } - bp - }; - let order = |q: Point| -> f64 { - poly.segments() - .enumerate() - .map(|(i, sg)| (i as f64 + sg.nearest(q, 1e-6).t, sg.nearest(q, 1e-6).distance_sq)) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map_or(0.0, |x| x.0) - }; - let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { - let (pd, _) = path_of(&st.doc, id)?; - for (si, sp) in pd.subpaths.iter().enumerate() { - if sp.closed || sp.anchors.len() < 2 { - continue; - } - for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] { - if dist_to_polyline(&pts, a) <= tol { - ends.push((order(a), id, si, start, a)); - } - } - } - } - ends.sort_by(|a, b| a.0.total_cmp(&b.0)); - if ends.len() < 2 { - return Ok(json!({ "id": null })); - } - let (_, ida, sa, starta, pa) = ends[0]; - let (_, idb, sb, startb, pb) = ends[1]; - let mid = pa.midpoint(pb); - let merge = pa.distance(pb) <= tol; - s.edit("Join", |d, sel| { - if ida == idb && sa == sb { - let path = path_mut(d, ida)?; - let sp = &mut path.subpaths[sa]; - if merge - && sp.anchors.len() > 2 - && let Some(l) = sp.anchors.pop() - { - let d0 = mid - sp.anchors[0].p; - sp.anchors[0].translate(d0); - sp.anchors[0].h_in = l.h_in + (mid - l.p); - } - sp.closed = true; - sel.set([ida]); - return Ok(()); - } - let (pdb, _) = path_of(d, idb)?; - let mut x = path_of(d, ida)?.0.subpaths[sa].clone(); - let mut y = pdb.subpaths[sb].clone(); - if starta { - x.reverse(); - } - if !startb { - y.reverse(); - } - if merge - && !y.anchors.is_empty() - && let Some(last) = x.anchors.last_mut() - { - let yf = y.anchors.remove(0); - let lp = last.p; - last.translate(mid - lp); - last.h_out = yf.h_out + (mid - yf.p); - *last = Anchor::with_handles(last.p, last.h_in, last.h_out); - } - x.anchors.extend(y.anchors); - if ida == idb { - let path = path_mut(d, ida)?; - path.subpaths[sa] = x; - path.subpaths.remove(sb); - } else { - path_mut(d, ida)?.subpaths[sa] = x; - let pbm = path_mut(d, idb)?; - pbm.subpaths.remove(sb); - if pbm.is_empty() { - d.remove(idb)?; - } - } - sel.set([ida]); - Ok(()) - })?; - Ok(json!({ "id": ida.0 })) -} - -// ---------- Blob Brush ---------- - -fn blob(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.blob"; - let pts = points_param(p, "points", C)?; - let size = number_or(p, "size", 10.0, C)?; - if !(size.is_finite() && size > 0.0) { - return Err(bad(C, "size must be positive")); - } - let region = brush_region(&pts, size); - if region.is_empty() { - return Err(EngineError::Other("empty brush stroke".into())); - } - let paint = if !s.paint.stroke.is_none() { - s.paint.stroke.clone() - } else if !s.paint.fill.is_none() { - s.paint.fill.clone() - } else { - Paint::solid(Color::BLACK) - }; - let keep_selected = switch_or(p, "keepSelected", false, C)?; - let only_selection = switch_or(p, "mergeOnlySelection", false, C)?; - let merge = switch_or(p, "merge", true, C)?; - let st = s.doc()?; - let mut merge_ids = vec![]; - if merge && !(only_selection && st.selection.is_empty()) { - let all = Selection::default(); - let from = if only_selection { &st.selection } else { &all }; - for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { - let Some(n) = st.doc.node(id) else { continue }; - if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { - continue; - } - let (pd, rule) = path_of(&st.doc, id)?; - if pd.is_closed() && po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) > 1e-6 { - merge_ids.push((id, pd, rule)); - } - } - } - let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); - if merge_ids.is_empty() { - return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); - } - let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; - inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); - let merged = po::unite_all(&inputs); - let Some(((keep, _, _), rest)) = merge_ids.split_last() else { - return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); - }; - let keep = *keep; - let others: Vec = rest.iter().map(|m| m.0).collect(); - s.edit("Blob Brush", |d, sel| { - *path_mut(d, keep)? = merged; - remove_each(d, &others)?; - if keep_selected { - sel.set([keep]); - } else { - sel.remove(keep); - } - Ok(()) - })?; - Ok(json!({ "id": keep.0 })) -} +pub use commands::specs; +pub(super) use common::{ids_json, replace_with_pieces, usize_opt}; +pub(super) use cut::{components, erase_outline}; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs new file mode 100644 index 000000000..540627fe0 --- /dev/null +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -0,0 +1,269 @@ +use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; +use super::*; + +// ---------- Curvature ---------- + +pub(super) fn curvature(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.curvature"; + let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; + let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::>()?; + if pts.is_empty() { + return Err(bad(C, "need at least one point")); + } + let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); + if let Some(id) = id_param(p, "id") { + s.edit("Curvature", |d, sel| { + *path_mut(d, id)? = PathData::single(sp); + sel.set([id]); + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + let ap = &mut look.appearance; + if ap.stroke_paint().is_none() && ap.fill_paint().is_none() { + ap.set_stroke(Paint::solid(Color::BLACK)); + } + add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None) +} + +/// An edit `path.curvatureEdit` makes. +enum CurvatureOp { + Move(usize, Point), + Insert(usize, f64, Option), + Extend(Point), + Close, +} + +pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result { + use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; + const C: &str = "path.curvatureEdit"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + check_numbers(p, &["x", "y", "t"], C)?; + let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)), + (None, None) => None, + _ => return Err(bad(C, "x and y must be finite numbers")), + }; + let to = || at.ok_or_else(|| bad(C, "missing x, y")); + let start = match str_param(p, "end") { + None | Some("end") => false, + Some("start") => true, + Some(e) => return Err(bad(C, format!("unknown end {e:?}"))), + }; + let how = match str_param(p, "from") { + None => EndCurve::Keep, + Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?, + }; + let corner = switch_or(p, "corner", false, C)?; + let op = match str_param(p, "op") { + Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?), + Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at), + Some("extend") => CurvatureOp::Extend(to()?), + Some("close") => CurvatureOp::Close, + Some(o) => return Err(bad(C, format!("unknown op {o:?}"))), + None => return Err(bad(C, "missing op")), + }; + let anchor = s.edit("Curvature", |d, sel| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed { + return Err(EngineError::Other("path is already closed".into())); + } + let ai = match op { + CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?, + CurvatureOp::Insert(seg, t, q) => { + let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?; + if let Some(q) = q { + curvature_move(sp, ai, q); + } + ai + } + CurvatureOp::Extend(q) => { + curvature_extend(sp, start, q, how, corner); + if start { 0 } else { sp.anchors.len() - 1 } + } + CurvatureOp::Close => { + if sp.anchors.len() < 3 { + return Err(EngineError::Other("closing needs three anchors".into())); + } + curvature_close(sp, start, how); + if start { sp.anchors.len() - 1 } else { 0 } + } + }; + sel.set([id]); + sel.anchors.insert(id, [(si, ai)].into()); + Ok(ai) + })?; + Ok(json!({ "anchor": anchor })) +} + +// ---------- anchor tools ---------- + +pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.removeAnchor"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let ai = usize_req(p, "anchor", C)?; + let removed = s.edit("Delete Anchor Point", |d, sel| { + let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if ai >= sp.anchors.len() { + return Err(EngineError::Other("no such anchor".into())); + } + let mut emptied = Beside::replacing(d, &[id]); + let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?; + emptied.finish(d)?; + Ok(removed) + })?; + Ok(json!({ "removedObject": removed })) +} + +pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st + .selection + .anchors + .iter() + .filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))) + .map(|(id, set)| (*id, set.iter().copied().collect())) + .collect(); + if jobs.is_empty() { + return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); + } + let removed = s.edit("Remove Anchor Points", |d, sel| { + let mut removed = 0; + let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); + for (id, anchors) in jobs { + let shape: Vec<(usize, bool)> = + d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); + let next = following_anchors(&shape, &anchors); + if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { + removed += 1; + } else if !next.is_empty() { + // The next anchor along is selected, so the next Remove Anchor Points goes on from + // there without picking it first (#2241). + sel.anchors.insert(id, next); + } + } + emptied.finish(d)?; + Ok(removed) + })?; + Ok(json!({ "removedObjects": removed })) +} + +/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a +/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its +/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's +/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. +fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { + let mut next = BTreeSet::new(); + let mut kept = 0; + for (si, &(n, closed)) in shape.iter().enumerate() { + let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + if n - gone.len() < 2 { + continue; + } + if let Some(&last) = gone.last() { + let wrap = if closed { 0..last } else { 0..0 }; + let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); + if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { + next.insert((kept, a - gone.range(..a).count())); + } + } + kept += 1; + } + next +} + +/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round +/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed +/// through `emptied`. True when the path went. +fn remove_anchors_of( + d: &mut Document, + sel: &mut Selection, + emptied: &mut Beside, + id: NodeId, + anchors: impl IntoIterator, +) -> Result { + let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); + // The last first, so the indexes still to come stay valid. + anchors.sort_unstable_by(|a, b| b.cmp(a)); + anchors.dedup(); + let path = path_mut(d, id)?; + for (si, ai) in anchors { + if let Some(sp) = path.subpaths.get_mut(si) { + po::remove_anchor(sp, ai); + } + } + path.subpaths.retain(|sp| sp.anchors.len() >= 2); + let gone = path.is_empty(); + if gone { + emptied.put(d, id, vec![])?; + sel.remove(id); + } else { + sel.anchors.remove(&id); + sel.segments.remove(&id); + } + Ok(gone) +} + +pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.convertAnchor"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let ai = usize_req(p, "anchor", C)?; + let smooth = str_param(p, "to") == Some("smooth"); + check_numbers(p, &["x", "y"], C)?; + let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => Some(Point::new(x, y)), + (None, None) => None, + _ => return Err(bad(C, "x and y must be given together")), + }; + if target.is_some_and(|q| !within_input_bound(q)) { + return Err(bad(C, "x and y must be finite")); + } + s.edit("Convert Anchor Point", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if smooth && target.is_none() { + if ai >= sp.anchors.len() { + return Err(EngineError::Other("no such anchor".into())); + } + // Without a handle position the anchor curves as the Curvature tool curves a point; one + // with no direction to take stays as it is. + vectorcraft_tools::draw2::curvature_smooth(sp, ai); + return Ok(()); + } + let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; + match target { + Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t), + _ => a.retract(), + } + Ok(()) + })?; + ok() +} + +pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.reshapeSegment"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let seg = usize_req(p, "segment", C)?; + let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); + let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); + if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) { + return Err(bad(C, "invalid delta")); + } + let perpendicular = switch_or(p, "perpendicular", false, C)?; + s.edit("Reshape", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; + if !bent { + return Err(EngineError::Other("no such segment".into())); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs new file mode 100644 index 000000000..84337aa27 --- /dev/null +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -0,0 +1,136 @@ +use super::anchors::{convert_anchor, curvature, curvature_edit, remove_anchor, remove_anchors, reshape_segment}; +use super::cut::{erase_region, erase_segments, knife, split}; +use super::freehand::{blob, freehand}; +use super::smooth::{join_scrub, smooth_region}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "path.freehand", + "Pencil", + [], + None, + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + has_doc, + freehand + ), + cmd!( + "path.curvature", + "Curvature", + [], + None, + "{points: [{x, y, corner?}], closed?, id?} create (or with id: replace) a path curving through the points → {id}", + has_doc, + curvature + ), + cmd!( + "path.curvatureEdit", + "Curvature", + [], + None, + "{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner", + has_doc, + curvature_edit + ), + cmd!( + "path.removeAnchor", + "Delete Anchor Point", + [], + None, + "{id, subpath?, anchor} remove one anchor, re-fitting the curve", + has_doc, + remove_anchor + ), + cmd!( + "path.removeAnchors", + "Remove Anchor Points", + ["Object", "Path"], + None, + "{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}", + has_anchors, + remove_anchors + ), + cmd!( + "path.convertAnchor", + "Convert Anchor Point", + [], + None, + "{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)", + has_doc, + convert_anchor + ), + cmd!( + "path.reshapeSegment", + "Reshape Segment", + [], + None, + "{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does", + has_doc, + reshape_segment + ), + cmd!( + "path.split", + "Split Path", + [], + None, + "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}", + has_doc, + split + ), + cmd!( + "path.knife", + "Knife", + [], + None, + "{points: [[x,y]…]} cut closed shapes (selected, or all when nothing is selected) along the polyline → {ids}", + has_doc, + knife + ), + cmd!( + "path.eraseRegion", + "Eraser", + [], + None, + "{points: [[x,y]…], size?: pt (10), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + has_doc, + erase_region + ), + cmd!( + "path.eraseSegments", + "Path Eraser", + [], + None, + "{points: [[x,y]…], width?: pt (8)} erase the parts of selected paths under the drag → {ids}", + has_selection, + erase_segments + ), + cmd!( + "path.smoothRegion", + "Smooth", + [], + None, + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", + has_selection, + smooth_region + ), + cmd!( + "path.joinScrub", + "Join", + [], + None, + "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}", + has_doc, + join_scrub + ), + cmd!( + "path.blob", + "Blob Brush", + [], + None, + "{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}", + has_doc, + blob + ), + ] +} diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs new file mode 100644 index 000000000..db5d7e372 --- /dev/null +++ b/crates/engine/src/cmd/draw2/common.rs @@ -0,0 +1,235 @@ +use super::*; + +// ---------- helpers ---------- + +/// `[[x,y]…]` or `[{x,y}…]`. +pub(super) fn points_param(p: &Value, key: &str, cmd: &str) -> Result> { + Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect()) +} + +/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite +/// number. +pub(super) fn point_entry(v: &Value, cmd: &str) -> Result { + let xy = match v { + Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)), + Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)), + _ => None, + }; + xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers")) +} + +/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or +/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a +/// number nor null, is an error. +pub(super) fn samples_param(p: &Value, key: &str, cmd: &str) -> Result)>> { + let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?; + let pts: Vec<(Point, Option)> = a + .iter() + .map(|v| { + let pressure = match v { + Value::Array(xy) => xy.get(2), + _ => v.get("pressure"), + }; + match pressure { + None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))), + Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))), + } + }) + .collect::>()?; + if pts.is_empty() { + return Err(bad(cmd, "need at least one point")); + } + Ok(pts) +} + +/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of +/// its samples has one; samples without one count as full pressure, as a pen event without one. +pub(super) fn stroke_pressure(samples: &[(Point, Option)]) -> Option { + if samples.iter().all(|(_, p)| p.is_none()) { + return None; + } + let mut at = 0.0; + let mut prev: Option = None; + let along: Vec<(f64, f64)> = samples + .iter() + .map(|(pt, p)| { + at += prev.map_or(0.0, |q| q.distance(*pt)); + prev = Some(*pt); + (at, p.unwrap_or(1.0)) + }) + .collect(); + PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p))) +} + +/// The length of a subpath. +pub(super) fn subpath_len(sp: &SubPath) -> f64 { + use kurbo::ParamCurveArclen; + PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum() +} + +/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a +/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same +/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`). +pub(super) const MAX_INPUT: f64 = 1e9; + +/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size. +pub(super) fn within_input_bound(q: Point) -> bool { + q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT +} + +/// The whole number of 0 or more that `key` holds, read with [`index`]. +pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) +} + +/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other +/// value is [`usize_req`]'s error. +pub(in super::super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(_) => usize_req(p, key, cmd).map(Some), + } +} + +pub(super) fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *live = None; + Ok(path) + } + _ => Err(EngineError::Other(format!("object {id} is not a path"))), + } +} + +pub(super) fn path_of(d: &Document, id: NodeId) -> Result<(PathData, FillRule)> { + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, rule, .. }) => Ok((path.clone(), *rule)), + Some(_) => Err(EngineError::Other(format!("object {id} is not a path"))), + None => Err(EngineError::NoNode(id)), + } +} + +pub(super) fn dist_to_polyline(pts: &[Point], q: Point) -> f64 { + if pts.len() == 1 { + return pts[0].distance(q); + } + pts.windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let ab = b - a; + let l2 = ab.hypot2(); + let t = if l2 < 1e-18 { 0.0 } else { ((q - a).dot(ab) / l2).clamp(0.0, 1.0) }; + (a + ab * t).distance(q) + }) + .fold(f64::INFINITY, f64::min) +} + +pub(super) fn poly_bounds(pts: &[Point], pad: f64) -> Rect { + let r = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)); + r.inflate(pad, pad) +} + +/// The filled area painted by a round brush of diameter `size` along `pts`. +pub(super) fn brush_region(pts: &[Point], size: f64) -> PathData { + let mut pts = pts.to_vec(); + if pts.len() == 1 { + pts.push(pts[0] + Vec2::new(0.01, 0.0)); + } + po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), size, Cap::Round, Join::Round, 4.0) +} + +/// Path leaves the area tools act on: under the selected objects, or every path when nothing is +/// selected. Only editable, non-guide paths whose bounds meet `area`. +pub(super) fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { + let mut out = vec![]; + let mut push = |n: &Node| { + if let NodeKind::Path { guide: false, path, .. } = &n.kind + && path.control_bounds().is_some_and(|b| b.inflate(0.5, 0.5).intersect(area).area() > 0.0 || b.width() == 0.0 || b.height() == 0.0) + && !out.contains(&n.id) + { + out.push(n.id); + } + }; + if sel.is_empty() { + doc.walk(&mut push); + } else { + for id in &sel.objects { + if let Some(n) = doc.node(*id) { + n.walk(&mut |c: &Node| push(c)); + } + } + } + let editable = doc.editable(); + out.retain(|id| editable(*id)); + out +} + +/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. +fn compound_members(d: &mut Document, pd: PathData) -> Vec> { + pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() +} + +/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members +/// are rebuilt, one per subpath). +pub(in super::super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> { + if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) { + let members = compound_members(d, pd); + if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + *children = members; + } + return Ok(()); + } + *path_mut(d, id)? = pd; + Ok(()) +} + +/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`. +pub(super) fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result { + let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone(); + n.id = d.alloc_id(); + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *path = pd; + *live = None; + } + NodeKind::Compound { children, .. } => *children = compound_members(d, pd), + _ => {} + } + Ok(n) +} + +/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above +/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids. +pub(in super::super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec) -> Result> { + let mut it = pieces.into_iter(); + let Some(first) = it.next() else { + cut.put(d, id, vec![])?; + return Ok(vec![]); + }; + let mut ids = vec![id]; + let mut above = vec![]; + for pd in it { + let n = sibling_with(d, id, pd)?; + ids.push(n.id); + above.push(n); + } + set_geometry(d, id, first)?; + if !above.is_empty() { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + cut.put(d, id, [n].into_iter().chain(above).collect())?; + } + Ok(ids) +} + +pub(in super::super) fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +pub(super) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +pub(super) fn path_kind(path: PathData) -> NodeKind { + NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false } +} diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs new file mode 100644 index 000000000..69306ce8f --- /dev/null +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -0,0 +1,328 @@ +use super::common::{ + dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt, +}; +use super::*; + +/// Split a filled path into connected pieces (each outer contour with its holes). +pub(in super::super) fn components(pd: &PathData) -> Vec { + let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect(); + if subs.len() <= 1 { + return if pd.is_empty() { vec![] } else { vec![pd.clone()] }; + } + let bps: Vec = subs + .iter() + .map(|s| { + let mut b = BezPath::new(); + s.to_bezpath_into(&mut b); + b + }) + .collect(); + let samples: Vec = subs.iter().map(|s| s.segment(0).eval(0.5)).collect(); + let n = subs.len(); + let inside = |j: usize, i: usize| fill_contains(&bps[j], FillRule::NonZero, samples[i]); + let depth: Vec = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(j, i)).count()).collect(); + let areas: Vec = subs.iter().map(|s| s.area().abs()).collect(); + let outers: Vec = (0..n).filter(|&i| depth[i].is_multiple_of(2)).collect(); + let mut groups: Vec> = outers.iter().map(|&o| vec![subs[o].clone()]).collect(); + for h in (0..n).filter(|&i| !depth[i].is_multiple_of(2)) { + let best = outers + .iter() + .enumerate() + .filter(|(_, o)| depth[**o] + 1 == depth[h] && inside(**o, h)) + .min_by(|a, b| areas[*a.1].total_cmp(&areas[*b.1])); + if let Some((k, _)) = best { + groups[k].push(subs[h].clone()); + } + } + groups.into_iter().map(PathData::new).collect() +} + +/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment +/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least +/// `step` long is found. +fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { + let nseg = sp.segment_count(); + if nseg == 0 { + return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); + } + const K: usize = 24; + const MAX_SAMPLES: usize = 4096; + let mut runs: Vec> = vec![]; + let mut cur: Vec<(usize, f64, f64)> = vec![]; + let mut any = false; + for i in 0..nseg { + let c = sp.segment(i); + // The curve moves at most 3 times its longest control-polygon edge per unit of t, so with + // n samples, neighboring samples are at most 3 · edge / n apart. + let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot()); + let wanted = (3.0 * edge / step).ceil(); + let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K }; + let mut prev_t = 0.0; + let mut prev_rm = remove(c.eval(0.0)); + let mut start = if prev_rm { None } else { Some(0.0) }; + if prev_rm { + any = true; + if !cur.is_empty() { + runs.push(std::mem::take(&mut cur)); + } + } + for k in 1..=samples { + let t = k as f64 / samples as f64; + let rm = remove(c.eval(t)); + if rm != prev_rm { + let (mut lo, mut hi) = (prev_t, t); + for _ in 0..30 { + let m = 0.5 * (lo + hi); + if remove(c.eval(m)) == prev_rm { lo = m } else { hi = m } + } + let b = 0.5 * (lo + hi); + if prev_rm { + start = Some(b); + } else { + any = true; + if let Some(s) = start.take() { + cur.push((i, s, b)); + } + runs.push(std::mem::take(&mut cur)); + } + } + prev_t = t; + prev_rm = rm; + } + if let Some(s) = start { + cur.push((i, s, 1.0)); + } + } + if !cur.is_empty() { + runs.push(cur); + } + runs.retain(|r| !r.is_empty()); + if !any { + return None; + } + // A closed path's last and first runs meet at anchor 0. + if sp.closed && runs.len() >= 2 { + let first_starts = runs.first().and_then(|r| r.first()).is_some_and(|s| s.0 == 0 && s.1 == 0.0); + let last_ends = runs.last().and_then(|r| r.last()).is_some_and(|s| s.0 + 1 == nseg && s.2 == 1.0); + if first_starts && last_ends { + let first = runs.remove(0); + if let Some(last) = runs.last_mut() { + last.extend(first); + } + } + } + let out = runs + .iter() + .filter(|r| r.len() > 1 || r[0].2 - r[0].1 > 1e-4) + .map(|r| { + let mut anchors: Vec = vec![]; + for &(i, t0, t1) in r { + let c = sp.segment(i).subsegment(t0..t1); + let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) }; + match anchors.last_mut() { + Some(a) => a.h_out = h1, + None => anchors.push(Anchor { h_out: h1, ..Anchor::corner(c.p0) }), + } + anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner }); + } + for a in &mut anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + SubPath::new(anchors, false) + }) + .filter(|s| s.anchors.len() >= 2) + .collect(); + Some(out) +} + +/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least +/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output +/// object (one object per piece when the path had a single subpath), or None if unchanged. +pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { + let mut changed = false; + let mut subs = vec![]; + for sp in &pd.subpaths { + match cut_subpath(sp, step, remove) { + Some(pieces) => { + changed = true; + subs.extend(pieces); + } + None => subs.push(sp.clone()), + } + } + if !changed { + return None; + } + Some(if pd.subpaths.len() == 1 { + subs.into_iter().map(PathData::single).collect() + } else if subs.is_empty() { + vec![] + } else { + vec![PathData::new(subs)] + }) +} + +// ---------- Scissors ---------- + +pub(super) fn split(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.split"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let anchor = usize_opt(p, "anchor", C)?; + let seg = usize_opt(p, "segment", C)?; + if anchor.is_none() && seg.is_none() { + return Err(bad(C, "need `anchor` or `segment` + `t`")); + } + let t = f64_or(p, "t", 0.5); + if !t.is_finite() || p.get("t").is_some_and(|v| !v.is_f64() && !v.is_i64() && !v.is_u64()) { + return Err(bad(C, "invalid t")); + } + let ids = s.edit("Split Path", |d, sel| { + let path = path_mut(d, id)?; + let mut sp = path.subpaths.get(si).cloned().ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let n = sp.anchors.len(); + let k = match (anchor, seg) { + (Some(a), _) if a < n => a, + (Some(_), _) => return Err(EngineError::Other("no such anchor".into())), + (None, Some(g)) if g < sp.segment_count() => { + if t <= 1e-6 { + g + } else if t >= 1.0 - 1e-6 { + (g + 1) % n + } else { + sp.insert_anchor(g, t) + } + } + _ => return Err(EngineError::Other("no such segment".into())), + }; + let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k])); + if sp.closed { + path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?; + sel.set([id]); + return Ok(vec![id]); + } + let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?; + if path.subpaths.len() == 1 { + path.subpaths[0] = left; + let node = sibling_with(d, id, PathData::single(right))?; + let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let nid = d.insert(parent, idx + 1, node)?; + sel.set([id, nid]); + Ok(vec![id, nid]) + } else { + path.subpaths[si] = left; + path.subpaths.insert(si + 1, right); + sel.set([id]); + Ok(vec![id]) + } + })?; + Ok(ids_json(&ids)) +} + +// ---------- Knife / Eraser ---------- + +/// Width of the slit the knife leaves between pieces. +const KNIFE_GAP: f64 = 0.05; + +pub(super) fn knife(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.knife"; + let pts = points_param(p, "points", C)?; + if pts.len() < 2 { + return Err(bad(C, "need at least two points")); + } + let st = s.doc()?; + let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0); + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) { + let (pd, rule) = path_of(&st.doc, id)?; + if !pd.is_closed() { + continue; + } + let pieces = components(&po::boolean(&pd, rule, &strip, FillRule::NonZero, BoolOp::Difference)); + if pieces.len() >= 2 { + plan.push((id, pieces)); + } + } + if plan.is_empty() { + return Ok(json!({ "ids": [] })); + } + let ids = s.edit("Knife", |d, sel| { + let mut all = vec![]; + let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in plan { + all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); + } + cut.finish(d)?; + sel.set(all.iter().copied()); + Ok(all) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.eraseRegion"; + let pts = points_param(p, "points", C)?; + let size = number_or(p, "size", 10.0, C)?; + if !(size.is_finite() && size > 0.0) { + return Err(bad(C, "size must be positive")); + } + let angle = number_or(p, "angle", 0.0, C)?; + let roundness = number_or(p, "roundness", 100.0, C)?; + let r = size / 2.0; + // The tip's short axis: an open path is sampled at least every half of it. + let short = size * roundness.clamp(1.0, 100.0) / 100.0; + let st = s.doc()?; + let region = eraser_area(&pts, size, angle, roundness); + // A point is under the tip when, with the tip squashed back into a circle, it is within the + // circle's radius of the squashed drag. + let squash = eraser_tip(angle, roundness).inverse(); + let round: Vec = pts.iter().map(|q| squash * *q).collect(); + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { + let (pd, rule) = path_of(&st.doc, id)?; + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| dist_to_polyline(&round, squash * q) <= r) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + +fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { + if plan.is_empty() { + return Ok(json!({ "ids": [] })); + } + let ids = s.edit(label, |d, sel| { + let mut all = vec![]; + let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in plan { + all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); + } + cut.finish(d)?; + sel.set(all.iter().copied()); + Ok(all) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn erase_segments(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.eraseSegments"; + let pts = points_param(p, "points", C)?; + let w = number_or(p, "width", 8.0, C)?; + if !(w.is_finite() && w > 0.0) { + return Err(bad(C, "width must be positive")); + } + let st = s.doc()?; + let mut plan = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) { + let (pd, _) = path_of(&st.doc, id)?; + if let Some(pieces) = erase_outline(&pd, w / 2.0, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Path Eraser", plan) +} diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs new file mode 100644 index 000000000..e6df1a60b --- /dev/null +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -0,0 +1,396 @@ +use super::common::{ + brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, + within_input_bound, +}; +use super::*; + +// ---------- Pencil / Paintbrush ---------- + +/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay +/// corners) and simplify it to `tol`. +fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { + let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIDELITY * 0.3); + let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) }; + let mut kept: Vec = vec![p0]; + for &p in pts.iter().skip(1) { + if kept.last().is_none_or(|k| k.distance(p) >= min) { + kept.push(p); + } + } + // The last sample always ends the stroke. + if kept.len() > 1 { + if let Some(k) = kept.last_mut() { + *k = pn; + } + } else if pts.len() > 1 { + kept.push(pn); + } + let n = kept.len(); + let marked: Vec<(Point, bool)> = (0..n) + .map(|i| { + let corner = if i == 0 || i + 1 == n { + false + } else { + let (a, b) = (kept[i] - kept[i - 1], kept[i + 1] - kept[i]); + a.dot(b) / (a.hypot() * b.hypot()).max(1e-12) < -0.2 // turn > ~100° + }; + (kept[i], corner) + }) + .collect(); + let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed); + if sp.anchors.len() < 3 { + return sp; + } + let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false }); + fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) +} + +/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points +/// joined by straight segments (`path.freehand` with `fit: false`). +fn stroke_subpath(pts: &[Point], tol: Option, closed: bool) -> SubPath { + match tol { + Some(tol) => fit_freehand(pts, tol, closed), + None => SubPath::new(pts.iter().map(|&p| Anchor::corner(p)).collect(), closed), + } +} + +/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of +/// `pd` to another, each within `within` of it, replaces the section between them (the shorter +/// way round a closed subpath) with the stroke fitted at `tol` (with no `tol`, unfitted). The +/// subpath's index and its new shape; None when the stroke doesn't start and end on the same +/// subpath, or meets it once. +fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) -> Option<(usize, SubPath)> { + use kurbo::ParamCurveArclen; + let (first, last) = (*pts.first()?, *pts.last()?); + let (si, _, _, _, d_a) = pd.nearest(first)?; + let (sj, _, _, _, d_b) = pd.nearest(last)?; + if si != sj || d_a > within || d_b > within || pts.len() < 2 { + return None; + } + let mut sp = pd.subpaths.get(si)?.clone(); + if sp.anchors.len() < 2 { + return None; + } + // The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each + // point is looked up on the subpath as it is by then: splitting a straight segment makes two + // new ones, which a `t` along the old one doesn't measure. + let place = |sp: &mut SubPath, q: Point| { + let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?; + let n = sp.anchors.len(); + Some(if t <= 1e-6 { + (seg % n, false) + } else if t >= 1.0 - 1e-6 { + ((seg + 1) % n, false) + } else { + (sp.insert_anchor(seg, t), true) + }) + }; + let (mut from, _) = place(&mut sp, first)?; + let (to, added) = place(&mut sp, last)?; + if added && to <= from { + from += 1; + } + if from == to { + return None; + } + let n = sp.anchors.len(); + // The length of the run of segments from anchor i forward to anchor j. + let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::(); + // The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from + // `end` round to `start`, then the stroke's inner anchors follow. + let (start, end, reversed) = if sp.closed { + if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) } + } else if from < to { + (from, to, false) + } else { + (to, from, true) + }; + let mut stroke = pts.to_vec(); + if reversed { + stroke.reverse(); + } + let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None }; + *s0 = s1.p; + let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None }; + *e0 = e1.p; + let fit = stroke_subpath(&stroke, tol, false).anchors; + let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None }; + let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default(); + let (before, after): (Vec, Vec) = if sp.closed { + ((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![]) + } else { + (sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec()) + }; + let mut anchors = before; + if let Some(a) = anchors.last_mut() { + *a = Anchor::with_handles(a.p, a.h_in, f0.h_out); + } + anchors.extend_from_slice(inner); + let at_end = anchors.len(); + anchors.extend(after); + // The anchor the stroke ends on: the first after it, or round to the first of a closed one. + let end_at = if sp.closed { 0 } else { at_end }; + if let Some(a) = anchors.get_mut(end_at) { + *a = Anchor::with_handles(a.p, f1.h_in, a.h_out); + } + Some((si, SubPath::new(anchors, sp.closed))) +} + +pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.freehand"; + let samples = samples_param(p, "points", C)?; + if samples.iter().any(|(q, _)| !within_input_bound(*q)) { + return Err(bad(C, "x and y must be finite")); + } + let pressure = stroke_pressure(&samples); + let mut pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); + let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0); + // The tolerance the stroke is fitted at; with `fit: false` it is not fitted. + let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); + let closed = switch_or(p, "closed", false, C)?; + let brush = str_param(p, "style") == Some("brush"); + let keep_selected = switch_or(p, "keepSelected", true, C)?; + let filled = switch_or(p, "fill", false, C)?; + if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { + let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let at_start = str_param(ext, "end") == Some("start"); + let (pd, _) = path_of(&s.doc()?.doc, id)?; + let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; + let mut sp = pd.subpaths[si].clone(); + let old_len = subpath_len(&sp); + if at_start { + sp.reverse(); + } + let (Some(first), Some(end)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) else { + return Err(bad(C, "path has no open end")); + }; + let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; + *p0 = end; + if pts.len() < 2 { + return Ok(json!({ "id": id.0 })); + } + // A stroke that ends on an open end of another path connects the two: (that path, its last + // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). + let connect = match p.get("connect").filter(|v| !v.is_null()) { + Some(cn) => { + let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + if other == id { + return Err(bad(C, "connect needs a path other than the one extended")); + } + let (opd, _) = path_of(&s.doc()?.doc, other)?; + let osi = + opd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "connect path has no open end"))?; + let mut y = opd.subpaths[osi].clone(); + let at_end = match str_param(cn, "end") { + Some("start") => false, + Some("end") => true, + _ => return Err(bad(C, "connect end must be \"start\" or \"end\"")), + }; + if at_end { + y.reverse(); + } + Some((other, osi, y, at_end)) + } + None => None, + }; + let close_tol = (tol * 4.0).max(6.0); + let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); + if closing && let Some(l) = pts.last_mut() { + *l = first; + } + if let Some((_, _, y, _)) = &connect + && let (Some(l), Some(y0)) = (pts.last_mut(), y.anchors.first()) + { + *l = y0.p; + } + let fit = stroke_subpath(&pts, fit_tol, false); + let mut add = fit.anchors; + if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) { + last.h_out = a0.h_out; + } + if !add.is_empty() { + add.remove(0); + } + sp.anchors.extend(add); + if closing + && sp.anchors.len() > 2 + && let Some(l) = sp.anchors.pop() + && let Some(a0) = sp.anchors.first_mut() + { + a0.h_in = l.h_in; + sp.closed = true; + } + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + let drawn_len = subpath_len(&sp); + // The other path's anchors follow on from the one the stroke ends on, which takes its + // outgoing handle. + let mut other_len = 0.0; + if let Some((_, _, y, _)) = &connect + && let Some((y0, rest)) = y.anchors.split_first() + { + if let Some(last) = sp.anchors.last_mut() { + *last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out); + } + sp.anchors.extend_from_slice(rest); + other_len = subpath_len(y); + } + // The path's pressure (in the direction it is continued in) followed by the stroke's, and + // by the pressure of the path it connects to. + let pressure_of = |n: NodeId| s.doc().ok()?.doc.node(n).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone()); + let old_pressure = pressure_of(id); + let other_pressure = connect.as_ref().and_then(|(o, _, _, at_end)| pressure_of(*o).map(|p| if *at_end { p.reversed() } else { p })); + let joined = (old_pressure.is_some() || pressure.is_some() || other_pressure.is_some()).then(|| { + let old = old_pressure.map(|p| if at_start { p.reversed() } else { p }); + let drawn = PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), drawn_len - old_len); + if connect.is_some() { PressureProfile::extended(drawn.as_ref(), drawn_len, other_pressure.as_ref(), other_len) } else { drawn } + }); + s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + let reverse = at_start && !sp.closed; + if reverse { + sp.reverse(); + } + path_mut(d, id)?.subpaths[si] = sp; + if let Some((other, osi, ..)) = &connect { + let o = path_mut(d, *other)?; + if *osi < o.subpaths.len() { + o.subpaths.remove(*osi); + } + if o.is_empty() { + d.remove(*other)?; + sel.remove(*other); + } + // Direct-selected anchors and segments of the subpaths after the one removed move + // down one; those of the one removed go, and a path left with no selected anchors + // is no longer selected. + let shift = |refs: &mut BTreeSet<(usize, usize)>| { + *refs = refs.iter().filter(|r| r.0 != *osi).map(|&(sub, i)| if sub > *osi { (sub - 1, i) } else { (sub, i) }).collect(); + }; + if let Some(refs) = sel.anchors.get_mut(other) { + shift(refs); + } + if let Some(refs) = sel.segments.get_mut(other) { + shift(refs); + } + if sel.anchors.get(other).is_some_and(BTreeSet::is_empty) { + sel.remove(*other); + } + } + if let Some(joined) = joined + && let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) + { + st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined }; + } + if keep_selected { + sel.set([id]); + } else { + sel.remove(id); + } + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { + let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let within = number_or(rd, "within", 12.0, C)?; + if !within.is_finite() || within < 0.0 { + return Err(bad(C, "redraw within must be a finite distance of 0 or more")); + } + let (pd, _) = path_of(&s.doc()?.doc, id)?; + // shortcut: a stroke pressure profile is left as it was, measured along the old path; fit + // it to the new length if Calligraphic pressure strokes get redrawn. + if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { + s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; + // A member path of a compound path is redrawn in it: the compound path is what is + // selected. + let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); + if keep_selected { + sel.set([whole]); + } else { + sel.remove(whole); + } + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + } + if pts.len() < 2 { + return Err(bad(C, "need at least two points")); + } + let sp = stroke_subpath(&pts, fit_tol, closed); + let fill = if filled { s.paint.fill.clone() } else { Paint::None }; + // A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing + // gets black. A brush is its stroke. + let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() }; + let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1)); + if let Some(st) = look.appearance.stroke_mut() { + st.pressure = pressure; + } + insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected) +} + +// ---------- Blob Brush ---------- + +pub(super) fn blob(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.blob"; + let pts = points_param(p, "points", C)?; + let size = number_or(p, "size", 10.0, C)?; + if !(size.is_finite() && size > 0.0) { + return Err(bad(C, "size must be positive")); + } + let region = brush_region(&pts, size); + if region.is_empty() { + return Err(EngineError::Other("empty brush stroke".into())); + } + let paint = if !s.paint.stroke.is_none() { + s.paint.stroke.clone() + } else if !s.paint.fill.is_none() { + s.paint.fill.clone() + } else { + Paint::solid(Color::BLACK) + }; + let keep_selected = switch_or(p, "keepSelected", false, C)?; + let only_selection = switch_or(p, "mergeOnlySelection", false, C)?; + let merge = switch_or(p, "merge", true, C)?; + let st = s.doc()?; + let mut merge_ids = vec![]; + if merge && !(only_selection && st.selection.is_empty()) { + let all = Selection::default(); + let from = if only_selection { &st.selection } else { &all }; + for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { + let Some(n) = st.doc.node(id) else { continue }; + if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { + continue; + } + let (pd, rule) = path_of(&st.doc, id)?; + if pd.is_closed() && po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) > 1e-6 { + merge_ids.push((id, pd, rule)); + } + } + } + let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); + if merge_ids.is_empty() { + return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); + } + let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; + inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); + let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?; + let Some(((keep, _, _), rest)) = merge_ids.split_last() else { + return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); + }; + let keep = *keep; + let others: Vec = rest.iter().map(|m| m.0).collect(); + s.edit("Blob Brush", |d, sel| { + *path_mut(d, keep)? = merged; + remove_each(d, &others)?; + if keep_selected { + sel.set([keep]); + } else { + sel.remove(keep); + } + Ok(()) + })?; + Ok(json!({ "id": keep.0 })) +} diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs new file mode 100644 index 000000000..e20665447 --- /dev/null +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -0,0 +1,284 @@ +use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, target_paths}; +use super::*; + +// ---------- Smooth ---------- + +/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. +fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { + let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); + let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; + let (f, l) = (anchors[0], anchors[anchors.len() - 1]); + let last = out.len() - 1; + out[0].p = f.p; + out[0].h_in = f.h_in; + out[last].p = l.p; + out[last].h_out = l.h_out; + for a in &mut out { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + out +} + +fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { + let n = sp.anchors.len(); + let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); + if n < 3 || !marked.iter().any(|m| *m) { + return None; + } + if sp.closed { + let Some(k) = marked.iter().position(|m| !m) else { + // Everything is in range: smooth the whole closed path. + let f = + po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + return f.subpaths.into_iter().next(); + }; + // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. + let mut open = sp.clone(); + open.anchors.rotate_left(k); + let mut m = marked.clone(); + m.rotate_left(k); + open.anchors.push(open.anchors[0]); + m.push(false); + open.closed = false; + let mut res = smooth_open(&open.anchors, &m, near, tol); + let l = res.pop()?; + if let Some(r0) = res.first_mut() { + r0.h_in = l.h_in; + } + return Some(SubPath::new(res, true)); + } + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) +} + +/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it +/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver +/// is outside. +fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> { + const STEPS: u32 = 32; + let step = 1.0 / f64::from(STEPS); + // Back from the end in reach to the first sample outside, then halving to the crossing. + let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?; + let (mut lo, mut hi) = (outside, outside + step); + for _ in 0..20 { + let mid = (lo + hi) / 2.0; + if near(c.eval(mid)) { + hi = mid; + } else { + lo = mid; + } + } + // A straight segment's parts stay straight, their handles on their ends. + let straight = c.p1 == c.p0 && c.p2 == c.p3; + let part = |range| { + let s = c.subsegment(range); + if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s } + }; + (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) +} + +/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments +/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under +/// it, and the rest of a long segment next to it stays as it was (#1512). +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { + let n = anchors.len(); + let mut runs = vec![]; + let mut i = 0; + while i < n { + if marked[i] { + let a = i; + while i + 1 < n && marked[i + 1] { + i += 1; + } + runs.push((a, i)); + } + i += 1; + } + let mut out = anchors.to_vec(); + for (a, b) in runs.into_iter().rev() { + let lo = a.saturating_sub(1); + let hi = (b + 1).min(n - 1); + if hi - lo < 2 { + continue; + } + let mut piece = out[lo..=hi].to_vec(); + // The anchors outside the reach, kept with the part of their segment that is outside. + let (mut head, mut tail) = (None, None); + if hi > b + && let [.., inner, outer] = piece.as_mut_slice() + && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near) + { + inner.h_out = reach.p2; + tail = Some(Anchor { h_in: far.p1, ..*outer }); + *outer = Anchor::with_handles(far.p3, reach.p1, far.p2); + } + if lo < a + && let [outer, inner, ..] = piece.as_mut_slice() + && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near) + { + inner.h_in = reach.p2; + head = Some(Anchor { h_out: far.p1, ..*outer }); + *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); + } + let new = refit_run(&piece, tol); + out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); + } + out +} + +pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.smoothRegion"; + let pts = points_param(p, "points", C)?; + let radius = number_or(p, "radius", 12.0, C)?.max(0.0); + let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let st = s.doc()?; + let near = |q: Point| dist_to_polyline(&pts, q) <= radius; + let mut plan = vec![]; + let (mut before, mut after) = (0, 0); + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { + let (pd, _) = path_of(&st.doc, id)?; + let mut changed = false; + let subs: Vec = pd + .subpaths + .iter() + .map(|sp| match smooth_subpath(sp, &near, tol) { + Some(n) if n != *sp && n.anchors.len() >= 2 => { + changed = true; + n + } + _ => sp.clone(), + }) + .collect(); + if changed { + let np = PathData::new(subs); + before += pd.anchor_count(); + after += np.anchor_count(); + plan.push((id, np)); + } + } + if plan.is_empty() { + return Ok(json!({ "ids": [], "before": 0, "after": 0 })); + } + let ids: Vec = plan.iter().map(|(id, _)| *id).collect(); + s.edit("Smooth", |d, _| { + for (id, np) in plan { + *path_mut(d, id)? = np; + } + Ok(()) + })?; + let mut v = ids_json(&ids); + v["before"] = json!(before); + v["after"] = json!(after); + Ok(v) +} + +// ---------- Join tool ---------- + +pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.joinScrub"; + let pts = points_param(p, "points", C)?; + let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0); + let st = s.doc()?; + // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). + let poly = { + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + for q in &pts[1..] { + bp.line_to(*q); + } + bp + }; + let order = |q: Point| -> f64 { + poly.segments() + .enumerate() + .map(|(i, sg)| (i as f64 + sg.nearest(q, 1e-6).t, sg.nearest(q, 1e-6).distance_sq)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map_or(0.0, |x| x.0) + }; + let (first_pt, last_pt) = (pts.first().copied(), pts.last().copied()); + let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { + let (pd, _) = path_of(&st.doc, id)?; + for (si, sp) in pd.subpaths.iter().enumerate() { + if sp.closed || sp.anchors.len() < 2 { + continue; + } + for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] { + let on_scrub = dist_to_polyline(&pts, a) <= tol; + let near_start = first_pt.is_some_and(|p| a.distance(p) <= tol); + let near_end = last_pt.is_some_and(|p| a.distance(p) <= tol); + if !on_scrub && !near_start && !near_end { + continue; + } + let ord = if on_scrub { + order(a) + } else if near_start && (!near_end || first_pt.is_some_and(|p| a.distance(p) <= a.distance(last_pt.unwrap_or(p)))) { + -1.0 + } else { + 1e30 + }; + ends.push((ord, id, si, start, a)); + } + } + } + ends.sort_by(|a, b| a.0.total_cmp(&b.0)); + if ends.len() < 2 { + return Ok(json!({ "id": null })); + } + let (_, ida, sa, starta, pa) = ends[0]; + let (_, idb, sb, startb, pb) = ends[1]; + let mid = pa.midpoint(pb); + let merge = pa.distance(pb) <= tol; + s.edit("Join", |d, sel| { + if ida == idb && sa == sb { + let path = path_mut(d, ida)?; + let sp = &mut path.subpaths[sa]; + if merge + && sp.anchors.len() > 2 + && let Some(l) = sp.anchors.pop() + { + let d0 = mid - sp.anchors[0].p; + sp.anchors[0].translate(d0); + sp.anchors[0].h_in = l.h_in + (mid - l.p); + } + sp.closed = true; + sel.set([ida]); + return Ok(()); + } + let (pdb, _) = path_of(d, idb)?; + let mut x = path_of(d, ida)?.0.subpaths[sa].clone(); + let mut y = pdb.subpaths[sb].clone(); + if starta { + x.reverse(); + } + if !startb { + y.reverse(); + } + if merge + && !y.anchors.is_empty() + && let Some(last) = x.anchors.last_mut() + { + let yf = y.anchors.remove(0); + let lp = last.p; + last.translate(mid - lp); + last.h_out = yf.h_out + (mid - yf.p); + *last = Anchor::with_handles(last.p, last.h_in, last.h_out); + } + x.anchors.extend(y.anchors); + if ida == idb { + let path = path_mut(d, ida)?; + path.subpaths[sa] = x; + path.subpaths.remove(sb); + } else { + path_mut(d, ida)?.subpaths[sa] = x; + let pbm = path_mut(d, idb)?; + pbm.subpaths.remove(sb); + if pbm.is_empty() { + d.remove(idb)?; + } + } + sel.set([ida]); + Ok(()) + })?; + Ok(json!({ "id": ida.0 })) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index aadd456ef..fa4878d2c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { ), cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close), cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 33b73b2e5..e91a621c4 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; +use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_target, item_target_at, shared_stack}; use super::expand::has_seeded; use super::rasterfx; use super::*; @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Remove Effect", [], None, - "{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}", + "{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", 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)} → {ids}", has_doc, remove ), @@ -51,7 +51,7 @@ pub fn specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", 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)} → {ids}", has_doc, set_params ), @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, 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)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}", has_doc, lighting_preset ), @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "Duplicate Effect", [], None, - "{index: int, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} insert a copy of the effect right after it → {ids}", + "{index: int, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", 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)} insert a copy of the effect right after it → {ids}", has_doc, duplicate ), @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Move Effect", [], None, - "{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\"} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + "{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", has_doc, move_effect ), @@ -101,6 +101,35 @@ fn ids_json(ids: &[NodeId]) -> Value { /// One undo step (`label`) running `f` on the effect list `item` addresses on each target (an /// appearance item's own effects, or the object's). Errors with `none` when `f` changed no object; /// returns the changed ids. +/// Each target of an effect edit aimed at `item` and effect `index`: the object, the item whose +/// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see +/// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target +/// edits its equal item, or its equal object-level effect; a target without them is left out. +pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { + let roots = appearance_targets(s, p)?; + let d = &s.doc()?.doc; + let per = item.per_target(d, &roots); + let shared = if aligned(p) { shared_stack(d, &roots) } else { None }; + let mut out = Vec::with_capacity(roots.len()); + for (k, (id, item)) in roots.iter().zip(per).enumerate() { + let (Some(item), Some(n)) = (item, d.node(*id)) else { continue }; + let at = item.effects_item(&n.appearance, cmd)?; + // An object-level effect the targets share is, in each, where it is there. + let first = roots.first().and_then(|f| d.node(*f)); + let first_level = first.map(|f| item.effects_item(&f.appearance, cmd)).transpose()?.flatten(); + let index = match (&shared, at, first_level) { + (Some(sh), None, None) if sh.has_effect(index) => match sh.effect_in(index, k) { + Some(i) => i, + None => continue, + }, + _ => index, + }; + out.push((*id, at, index)); + } + Ok(out) +} + +#[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, p: &Value, @@ -108,17 +137,17 @@ fn edit_effects( cmd: &str, label: &str, none: &str, - mut f: impl FnMut(&mut Vec) -> bool, + index: usize, + mut f: impl FnMut(&mut Vec, usize) -> bool, ) -> Result> { - let roots = appearance_targets(s, p)?; + let targets = effect_targets(s, p, item, index, cmd)?; s.edit(label, |d, _| { let mut done = vec![]; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - let index = item.effects_item(&n.appearance, cmd)?; - if n.appearance.effects_mut(index).is_some_and(&mut f) { + for &(id, at, index) in &targets { + let Some(n) = d.node_mut(id) else { continue }; + if n.appearance.effects_mut(at).is_some_and(|fx| f(fx, index)) { check_revolve_count(n, cmd)?; - done.push(*id); + done.push(id); } } if done.is_empty() { @@ -185,7 +214,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } } let mut index = 0; - let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| { fx.push(effect.clone()); index = fx.len() - 1; true @@ -241,7 +270,7 @@ fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.remove"; let index = index_param(p, "index", C)?; let item = item_target(s, p, C)?; - let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), |fx| { + let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), index, |fx, index| { (index < fx.len()).then(|| fx.remove(index)).is_some() })?; Ok(json!({ "ids": ids_json(&ids) })) @@ -251,12 +280,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.duplicate"; let index = index_param(p, "index", C)?; let item = item_target(s, p, C)?; - let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), |fx| match fx.get(index).cloned() { - Some(e) => { - fx.insert(index + 1, e); - true + let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), index, |fx, index| { + match fx.get(index).cloned() { + Some(e) => { + fx.insert(index + 1, e); + true + } + None => false, } - None => false, })?; Ok(json!({ "ids": ids_json(&ids) })) } @@ -269,6 +300,10 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); let roots = appearance_targets(s, p)?; + // Where an aligned effect would go in each of several different stacks isn't one place. + if aligned(p) && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { + return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); + } // Where the effect landed in the first object. let mut landed = None; let ids = s.edit(if copy { "Duplicate Effect" } else { "Move Effect" }, |d, _| { @@ -298,14 +333,12 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { /// (none when `p` doesn't say which: running it reports why). pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { const C: &str = "effect.setParams"; - let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else { + let (Ok(index), Ok(item), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), s.doc()) else { return vec![]; }; - let effect = |id: &NodeId| { - let ap = &st.doc.node(*id)?.appearance; - Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone()) - }; - ids.iter().filter_map(effect).collect() + let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; + let effect = |&(id, at, index): &(NodeId, Option, usize)| Some(st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id.clone()); + targets.iter().filter_map(effect).collect() } fn lighting_preset(s: &mut Session, p: &Value) -> Result { @@ -317,9 +350,8 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { let item = item_target(s, p, C)?; // Validate the whole selection before changing any object. An unrelated effect or // a missing index must not leave just some objects with a new lighting rig. - for id in appearance_targets(s, p)? { + for (id, slot, index) in effect_targets(s, p, item, index, C)? { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; - let slot = item.effects_item(&n.appearance, C)?; let effect = n.appearance.effects_at(slot).and_then(|fx| fx.get(index)); if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); @@ -359,9 +391,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if edited.iter().any(|e| texture_load_effect(e)) { load_texture(&mut params, C)?; } - for id in appearance_targets(s, p)? { + for (id, target, index) in effect_targets(s, p, item, index, C)? { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; - let target = item.effects_item(&n.appearance, C)?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) { return Err(bad(C, "warpStyle can only edit an applied Warp effect")); @@ -377,7 +408,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?; } } - let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { + let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| { let Some(e) = fx.get_mut(index) else { return false }; if let Some(id) = &warp_id { e.id.clone_from(id); @@ -713,466 +744,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -#[path = "tests_lighting_presets.rs"] mod lighting_presets_tests; #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_doc::NodeKind; - use vectorcraft_render::effects; - - use crate::{NodeId, Session}; - - fn session_with_rect() -> (Session, NodeId) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); - (s, NodeId(r["id"].as_u64().unwrap())) - } - - fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { - s.doc().unwrap().doc.node(id).cloned().unwrap() - } - - #[test] - fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let source = node(&s, id).path_data().unwrap().clone(); - s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); - let before = effects_for_test(&s, id); - assert_eq!(node(&s, id).path_data(), Some(&source)); - let doc = &s.doc().unwrap().doc; - let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); - assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); - let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); - let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); - assert!(bytes.starts_with(b"%PDF")); - assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); - assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); - s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); - assert_ne!(effects_for_test(&s, id), before); - assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); - assert!(!effects::has_revolve(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id).path_data(), Some(&source)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(effects_for_test(&s, id), before); - } - - /// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, - /// exported, expanded and undone; one 3D effect per object. - #[test] - fn solid_effects_apply_save_edit_export_expand_and_undo() { - for effect in effects::SOLIDS { - let (mut s, id) = session_with_rect(); - let source = node(&s, id).path_data().unwrap().clone(); - s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap(); - let art = effects::solid_art(&node(&s, id)).unwrap(); - assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable"); - let doc = &s.doc().unwrap().doc; - let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); - assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same"); - // One 3D effect per object, whichever kind. - for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] { - assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}"); - } - s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap(); - assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art"); - assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); - let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); - let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); - assert!(svg.matches("= 2, "{effect}: exported as vector faces"); - s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); - assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}"); - assert!(!effects::has_solid(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle"); - } - // Type has no outline to extrude. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap(); - let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err(); - assert!(err.to_string().contains("requires a path"), "{err}"); - } - - /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is - /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. - #[test] - fn an_earlier_perspective_strength_is_set_as_its_angle() { - let (mut s, id) = session_with_rect(); - s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap(); - let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); - let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap(); - let angle = angle_of(60.0); - assert!((angle - 30.12).abs() < 0.01, "{angle}"); - assert!( - params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, - "{}", - params(&s) - ); - s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap(); - assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12); - // A file of then: the strength stored; setting an angle replaces it. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = - json!({"depth": 50, "perspective": 60}); - s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120})); - } - - /// Scripts from before Rotation and Height give the light by `lightAzimuth` and - /// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation. - #[test] - fn an_earlier_light_direction_is_set_as_rotation_and_height() { - let (mut s, id) = session_with_rect(); - s.execute( - "effect.apply", - &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}), - ) - .unwrap(); - let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); - // Straight from behind the object, level with it. - assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s)); - assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), ¶ms(&s)["lightBehind"]), (90.0, &json!(true))); - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = - json!({"lightAzimuth": 90, "lightElevation": 0}); - s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false})); - } - - /// Scripts from before Scribble's separate variations give one `variation`, which is stored as the - /// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°. - #[test] - fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() { - let (mut s, id) = session_with_rect(); - let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}}); - s.execute("effect.apply", &p).unwrap(); - let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); - assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s)); - assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2))); - s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s)["angle"].as_f64(), Some(160.0)); - // A file of then stores its variation; setting one of the two keeps it for the other. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3}); - s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1})); - } - - fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { - effects::revolve_art(&node(s, id)).unwrap() - } - - #[test] - fn smooth_revolve_commands_validate_save_expand_and_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap(); - let initial = node(&s, id); - let source = initial.path_data().unwrap().clone(); - assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); - assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap()); - let mut gradients = 0; - effects_for_test(&s, id).walk(&mut |face| { - gradients += face.appearance.items.iter().filter(|item| { - matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) - }).count(); - }); - assert!(gradients > 0, "commands use smooth vector shading by default"); - s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap(); - let smooth = node(&s, id); - assert_eq!(smooth.path_data(), Some(&source)); - assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap()); - let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id))); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert!(!effects::has_revolve(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), smooth); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), initial); - } - - #[test] - fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let source = node(&s, id).path_data().unwrap().clone(); - s.execute( - "effect.apply", - &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, - "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}), - ) - .unwrap(); - let uncapped = node(&s, id); - assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false)); - let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); - let open_count = count(&effects_for_test(&s, id)); - s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap(); - let capped = node(&s, id); - let art = effects_for_test(&s, id); - assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); - assert_eq!(capped.path_data(), Some(&source)); - assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err()); - assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); - let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true)); - assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); - let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); - assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert_eq!(count(&node(&s, id)), count(&art)); - assert!(!effects::has_revolve(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), capped); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), uncapped); - } - - #[test] - fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); - let live = node(&s, id); - let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); - let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - let visible = count(&node(&s, id)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), live); - s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert!(count(&node(&s, id)) > visible); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), live); - // The shared command still expands ordinary multiple-fill artwork as before. - let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); - let ordinary = NodeId(r["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); - s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); - assert!(node(&s, ordinary).appearance.effects.is_empty()); - } - - #[test] - fn apply_uses_catalog_defaults_and_undoes() { - let (mut s, id) = session_with_rect(); - let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap(); - assert_eq!(r["index"], json!(0)); - let n = node(&s, id); - assert_eq!(n.appearance.effects.len(), 1); - assert_eq!(n.appearance.effects[0].params["x"], json!(3)); - assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(node(&s, id).appearance.effects.is_empty()); - assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err()); - } - - #[test] - fn warp_options_style_change_preserves_slot_and_visibility() { - let (mut s, id) = session_with_rect(); - for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] { - s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap(); - } - s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap(); - s.doc_mut().unwrap().mark_saved(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}})); - assert!(result.is_ok(), "an applied warp can change style in place: {result:?}"); - let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap(); - let n = node(&s, id); - let new = n.appearance.effects_at(Some(1)).unwrap(); - assert_eq!(new.len(), 3); - assert_eq!(new[0], old[0]); - assert_eq!(new[2], old[2]); - assert_eq!(new[1].id, "warp.flag"); - assert_eq!(new[1].params["bend"], 25.0); - assert!(!new[1].visible); - assert!(n.appearance.effects.is_empty()); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - let after = s.doc().unwrap().doc.clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - assert!(!s.doc().unwrap().is_dirty()); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - } - - #[test] - fn warp_options_non_warp_target_rejection_is_atomic() { - let (mut s, first) = session_with_rect(); - s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); - let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap(); - s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap(); - s.doc_mut().unwrap().mark_saved(); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let undo = s.doc().unwrap().history.undo.len(); - assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err()); - let state = s.doc().unwrap(); - assert_eq!(state.doc, before); - assert_eq!(state.selection, selection); - assert_eq!(state.history.undo.len(), undo); - assert!(!state.is_dirty()); - } - - #[test] - fn list_remove_and_set_params() { - let (mut s, id) = session_with_rect(); - s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap(); - s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); - let l = s.execute("effect.list", &json!({})).unwrap(); - assert!(l["catalog"].as_array().unwrap().len() >= 34); - assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2); - s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap(); - let e = &node(&s, id).appearance.effects[0]; - assert_eq!(e.params["angle"], json!(45)); - assert!(!e.visible); - s.execute("effect.remove", &json!({"index": 0})).unwrap(); - let n = node(&s, id); - assert_eq!(n.appearance.effects.len(), 1); - assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow"); - assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); - } - - #[test] - fn expand_appearance_bakes_geometry() { - let (mut s, id) = session_with_rect(); - s.execute("paint.setStroke", &json!({"none": true})).unwrap(); - s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap(); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - // One fill: the path itself, reshaped. - let n = node(&s, id); - let b = n.geometric_bounds().unwrap(); - assert!((b.width() - 120.0).abs() < 0.1, "{b:?}"); - assert!(n.appearance.effects.is_empty()); - assert!(matches!(n.kind, NodeKind::Path { live: None, .. })); - // Nothing left to expand. - assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); - } - - #[test] - fn expand_transform_copies_makes_compound() { - let (mut s, id) = session_with_rect(); - s.execute("paint.setStroke", &json!({"none": true})).unwrap(); - s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let n = node(&s, id); - assert!(matches!(n.kind, NodeKind::Compound { .. })); - let b = n.geometric_bounds().unwrap(); - assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); - } - - #[test] - fn pathfinder_effect_on_loose_objects_groups_them_first() { - let (mut s, a) = session_with_rect(); - let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); - s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); - let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); - assert_eq!(r["grouped"], true); - let g = NodeId(r["ids"][0].as_u64().unwrap()); - let gn = node(&s, g); - assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::>(), [a, b], "stacking order kept"); - assert!(vectorcraft_render::effects::has_pathfinder(&gn)); - assert_eq!(s.doc().unwrap().selection.objects, [g]); - // Live: one united shape, so expanding leaves a single path. - s.execute("effect.expandAppearance", &json!({})).unwrap(); - assert_eq!(node(&s, g).children().unwrap().len(), 1); - // Grouping and applying are one undo step. - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - assert!(d.node(g).is_none()); - assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b)); - // A selection holding a group applies to each object as before; so does a single object. - s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); - assert!(r.get("grouped").is_none()); - assert_eq!(node(&s, a).appearance.effects.len(), 1); - } - - #[test] - fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() { - let (mut s, a) = session_with_rect(); - let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap()); - let glyphs = effects::outline_text(&node(&s, t)).unwrap(); - let b = glyphs.geometric_bounds().unwrap(); - assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); - let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); - let glyph_area = area(&glyphs); - s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); - let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); - // Live: the renderer leaves the glyph's place in the square empty. - let doc = s.doc().unwrap().doc.clone(); - let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); - let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count(); - assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}"); - // Expanded: the group holds the square minus the glyph. - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let left = area(&node(&s, g)); - assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}"); - } - - #[test] - fn pathfinder_effect_on_a_group_renders_live_and_expands() { - let (mut s, a) = session_with_rect(); - let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); - s.execute("select.set", &json!({"ids": [a.0, b["id"]]})).unwrap(); - let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); - // Live: the renderer shows only the back square minus the front one. - let doc = s.doc().unwrap().doc.clone(); - let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); - assert!(img.pixel(125, 150)[3] > 0); - assert_eq!(img.pixel(200, 150)[3], 0); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let n = node(&s, g); - assert!(n.appearance.effects.is_empty()); - let ch = n.children().unwrap(); - assert_eq!(ch.len(), 1); - assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); - } - #[test] - fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { - use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; - let (mut s, id) = session_with_rect(); - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); - s.edit("gradient", |d, _| { - let n = d.node_mut(id).unwrap(); - n.appearance.set_fill(ramp); - Ok(()) - }) - .unwrap(); - s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); - s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - let mut boxes = vec![]; - node(&s, id).walk(&mut |n| { - if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { - boxes.push(n.geometric_bounds().unwrap().width()); - } - }); - assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/tests_lighting_presets.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs similarity index 100% rename from crates/engine/src/cmd/tests_lighting_presets.rs rename to crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs new file mode 100644 index 000000000..c23c578d0 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -0,0 +1,450 @@ +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_render::effects; + +use crate::{NodeId, Session}; + +fn session_with_rect() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +#[test] +fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let before = effects_for_test(&s, id); + assert_eq!(node(&s, id).path_data(), Some(&source)); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); + assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); + s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); + assert_ne!(effects_for_test(&s, id), before); + assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(effects_for_test(&s, id), before); +} + +/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, +/// exported, expanded and undone; one 3D effect per object. +#[test] +fn solid_effects_apply_save_edit_export_expand_and_undo() { + for effect in effects::SOLIDS { + let (mut s, id) = session_with_rect(); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap(); + let art = effects::solid_art(&node(&s, id)).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable"); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same"); + // One 3D effect per object, whichever kind. + for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] { + assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}"); + } + s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap(); + assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art"); + assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(svg.matches("= 2, "{effect}: exported as vector faces"); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}"); + assert!(!effects::has_solid(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle"); + } + // Type has no outline to extrude. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err(); + assert!(err.to_string().contains("requires a path"), "{err}"); +} + +/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is +/// stored as the angle that shows the same view, and a later angle replaces an earlier strength. +#[test] +fn an_earlier_perspective_strength_is_set_as_its_angle() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap(); + let angle = angle_of(60.0); + assert!((angle - 30.12).abs() < 0.01, "{angle}"); + assert!(params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{}", params(&s)); + s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap(); + assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12); + // A file of then: the strength stored; setting an angle replaces it. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = + json!({"depth": 50, "perspective": 60}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120})); +} + +/// Scripts from before Rotation and Height give the light by `lightAzimuth` and +/// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation. +#[test] +fn an_earlier_light_direction_is_set_as_rotation_and_height() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}})) + .unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + // Straight from behind the object, level with it. + assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s)); + assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), ¶ms(&s)["lightBehind"]), (90.0, &json!(true))); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = + json!({"lightAzimuth": 90, "lightElevation": 0}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false})); +} + +/// Scripts from before Scribble's separate variations give one `variation`, which is stored as the +/// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°. +#[test] +fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() { + let (mut s, id) = session_with_rect(); + let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}}); + s.execute("effect.apply", &p).unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s)); + assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2))); + s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s)["angle"].as_f64(), Some(160.0)); + // A file of then stores its variation; setting one of the two keeps it for the other. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1})); +} + +fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + effects::revolve_art(&node(s, id)).unwrap() +} + +#[test] +fn smooth_revolve_commands_validate_save_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap(); + let initial = node(&s, id); + let source = initial.path_data().unwrap().clone(); + assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); + assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap()); + let mut gradients = 0; + effects_for_test(&s, id).walk(&mut |face| { + gradients += face.appearance.items.iter().filter(|item| { + matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) + }).count(); + }); + assert!(gradients > 0, "commands use smooth vector shading by default"); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap(); + let smooth = node(&s, id); + assert_eq!(smooth.path_data(), Some(&source)); + assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap()); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id))); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), smooth); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), initial); +} + +#[test] +fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute( + "effect.apply", + &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, + "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}), + ) + .unwrap(); + let uncapped = node(&s, id); + assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + let open_count = count(&effects_for_test(&s, id)); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap(); + let capped = node(&s, id); + let art = effects_for_test(&s, id); + assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); + assert_eq!(capped.path_data(), Some(&source)); + assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err()); + assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert_eq!(count(&node(&s, id)), count(&art)); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), capped); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), uncapped); +} + +#[test] +fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let live = node(&s, id); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let visible = count(&node(&s, id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(count(&node(&s, id)) > visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + // The shared command still expands ordinary multiple-fill artwork as before. + let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); + let ordinary = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); + assert!(node(&s, ordinary).appearance.effects.is_empty()); +} + +#[test] +fn apply_uses_catalog_defaults_and_undoes() { + let (mut s, id) = session_with_rect(); + let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap(); + assert_eq!(r["index"], json!(0)); + let n = node(&s, id); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].params["x"], json!(3)); + assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); + assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err()); +} + +#[test] +fn warp_options_style_change_preserves_slot_and_visibility() { + let (mut s, id) = session_with_rect(); + for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] { + s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap(); + } + s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}})); + assert!(result.is_ok(), "an applied warp can change style in place: {result:?}"); + let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap(); + let n = node(&s, id); + let new = n.appearance.effects_at(Some(1)).unwrap(); + assert_eq!(new.len(), 3); + assert_eq!(new[0], old[0]); + assert_eq!(new[2], old[2]); + assert_eq!(new[1].id, "warp.flag"); + assert_eq!(new[1].params["bend"], 25.0); + assert!(!new[1].visible); + assert!(n.appearance.effects.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); +} + +#[test] +fn warp_options_non_warp_target_rejection_is_atomic() { + let (mut s, first) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); + let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap(); + s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err()); + let state = s.doc().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.selection, selection); + assert_eq!(state.history.undo.len(), undo); + assert!(!state.is_dirty()); +} + +#[test] +fn list_remove_and_set_params() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); + let l = s.execute("effect.list", &json!({})).unwrap(); + assert!(l["catalog"].as_array().unwrap().len() >= 34); + assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2); + s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap(); + let e = &node(&s, id).appearance.effects[0]; + assert_eq!(e.params["angle"], json!(45)); + assert!(!e.visible); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + let n = node(&s, id); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow"); + assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); +} + +#[test] +fn expand_appearance_bakes_geometry() { + let (mut s, id) = session_with_rect(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + // One fill: the path itself, reshaped. + let n = node(&s, id); + let b = n.geometric_bounds().unwrap(); + assert!((b.width() - 120.0).abs() < 0.1, "{b:?}"); + assert!(n.appearance.effects.is_empty()); + assert!(matches!(n.kind, NodeKind::Path { live: None, .. })); + // Nothing left to expand. + assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); +} + +#[test] +fn expand_transform_copies_makes_compound() { + let (mut s, id) = session_with_rect(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Compound { .. })); + let b = n.geometric_bounds().unwrap(); + assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn pathfinder_effect_on_loose_objects_groups_them_first() { + let (mut s, a) = session_with_rect(); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); + assert_eq!(r["grouped"], true); + let g = NodeId(r["ids"][0].as_u64().unwrap()); + let gn = node(&s, g); + assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::>(), [a, b], "stacking order kept"); + assert!(vectorcraft_render::effects::has_pathfinder(&gn)); + assert_eq!(s.doc().unwrap().selection.objects, [g]); + // Live: one united shape, so expanding leaves a single path. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 1); + // Grouping and applying are one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none()); + assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b)); + // A selection holding a group applies to each object as before; so does a single object. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); + assert!(r.get("grouped").is_none()); + assert_eq!(node(&s, a).appearance.effects.len(), 1); +} + +#[test] +fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() { + let (mut s, a) = session_with_rect(); + let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap()); + let glyphs = effects::outline_text(&node(&s, t)).unwrap(); + let b = glyphs.geometric_bounds().unwrap(); + assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); + let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); + let glyph_area = area(&glyphs); + s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); + // Live: the renderer leaves the glyph's place in the square empty. + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); + let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count(); + assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}"); + // Expanded: the group holds the square minus the glyph. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let left = area(&node(&s, g)); + assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}"); +} + +#[test] +fn pathfinder_effect_on_a_group_renders_live_and_expands() { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b["id"]]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); + // Live: the renderer shows only the back square minus the front one. + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); + assert!(img.pixel(125, 150)[3] > 0); + assert_eq!(img.pixel(200, 150)[3], 0); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, g); + assert!(n.appearance.effects.is_empty()); + let ch = n.children().unwrap(); + assert_eq!(ch.len(), 1); + assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); +} +#[test] +fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + let (mut s, id) = session_with_rect(); + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("gradient", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.set_fill(ramp); + Ok(()) + }) + .unwrap(); + s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let mut boxes = vec![]; + node(&s, id).walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { + boxes.push(n.geometric_bounds().unwrap().width()); + } + }); + assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); +} diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs new file mode 100644 index 000000000..6e5c9402e --- /dev/null +++ b/crates/engine/src/cmd/fileio.rs @@ -0,0 +1,208 @@ +//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open, +//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and +//! `action.batch` (the Actions panel menu's Batch…). +//! +//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images). +//! - `action_batch`: play an action on every file in a folder or every data set. +//! - `encode`: one encoder per writable format, with typed options parsed from the params. +//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). +//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. +//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip` +//! packs its files as one download, and reads the files of Word documents placed as type. +//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the +//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. +//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. +//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. +//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). +//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. +//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. +//! - `metafile`: EMF and WMF export, open and place. +//! +//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], +//! agents query `document.formats`. + +mod action_batch; +mod affinity; +mod batch; +pub mod dxf; +pub mod dxfimport; +mod encode; +pub mod eps; +mod export; +pub(crate) mod imagemap; +mod legacy_ai; +mod load; +pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod metafile; +mod naming; +mod native; +pub mod pdf; +mod pdfimport; +pub mod pngtext; +pub mod ppi; +pub mod progress; +pub mod psdread; +mod save; +mod screens; +mod svg; +mod text; +mod tgaread; +pub mod zip; + +use serde_json::{Value, json}; + +pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; +pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; +pub(crate) use encode::{anti_alias, background, with_single_artboard}; +pub use export::export_source; +pub(crate) use export::{export_crop, isolated}; +pub(crate) use load::check_not_lossy_overwrite; +use load::err; +pub(crate) use load::import_svg; +pub(crate) use load::is_placeholder; +pub(crate) use load::native_file; +pub(crate) use load::page_or_editing_data; +pub(crate) use load::source; +pub use load::{ + Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image, + read_for_open, texture_image, +}; +pub use load::{losses_summary, overwrite_losses}; +pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; +pub(crate) use native::preview_png; +pub use native::with_compression_pref; +pub use pdfimport::{LoadOptions, page_document}; +pub(crate) use save::job_for; +pub use save::{ + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, + remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, + templates_folder, +}; +pub use screens::{ + PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize, + builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows, +}; +pub(crate) use screens::{ + SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name, + store_settings as store_screen_settings, +}; +pub use svg::options_map as svg_options; +/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the +/// file it replaces), for the apps' own writers too. +#[cfg(not(target_arch = "wasm32"))] +pub use vectorcraft_format::write_atomic; + +use super::*; +use crate::EngineError; + +mod access; +mod commands; +mod export_names; +mod formats; + +#[cfg(not(target_arch = "wasm32"))] +use access::list_dir; +pub use access::write_file_with; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use access::{Placed, copy_new, file_metadata}; +pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; +#[cfg(all(test, windows))] +pub(crate) use access::{document_path_on, document_path_with}; +#[cfg(test)] +pub(crate) use access::{drive_share_of, share_of}; +#[cfg(test)] +pub(crate) use access::{drive_share_path, share_path, touched}; +#[cfg(not(target_arch = "wasm32"))] +use access::{may_read, may_write}; +pub use commands::specs; +pub use export_names::artboard_file_names; +pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; +use export_names::{distinct_names, write_encoded}; +use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable}; +pub use formats::{ + DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, file_stem, + format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, writable_format, +}; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_affinity; +#[cfg(test)] +mod tests_pdf; +#[cfg(test)] +mod tests_pdfcolor; +#[cfg(test)] +mod tests_pdfedit; +#[cfg(test)] +mod tests_pdffidelity; +#[cfg(test)] +mod tests_pdfimport; +#[cfg(test)] +mod tests_svg; +#[cfg(test)] +mod tests_svgedit; + +#[cfg(test)] +mod tests_raster; + +#[cfg(test)] +mod tests_exportas; + +#[cfg(test)] +mod tests_jpeg; + +#[cfg(test)] +mod tests_palette; + +#[cfg(test)] +mod tests_batch; +#[cfg(test)] +mod tests_screens; +#[cfg(test)] +mod tests_text; + +#[cfg(test)] +mod tests_dxf; + +#[cfg(test)] +mod tests_pdfsecurity; +#[cfg(test)] +mod tests_svgenc; +#[cfg(test)] +mod tests_svgimport; + +#[cfg(test)] +mod tests_eps; + +#[cfg(test)] +mod tests_dxfimport; +#[cfg(test)] +mod tests_metafile; + +#[cfg(test)] +mod tests_pdfoutput; +#[cfg(test)] +mod tests_tiffbmp; + +#[cfg(test)] +mod tests_pdfx; +#[cfg(test)] +mod tests_psd; +#[cfg(test)] +mod tests_psdread; + +#[cfg(test)] +mod tests_epsimport; + +#[cfg(test)] +mod tests_aiimport; + +#[cfg(test)] +mod tests_pdflayers; + +#[cfg(test)] +mod tests_pdfwebview; + +#[cfg(test)] +mod tests_pdf13; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs new file mode 100644 index 000000000..8dfbead2d --- /dev/null +++ b/crates/engine/src/cmd/fileio/access.rs @@ -0,0 +1,418 @@ +use super::*; + +// ---------- the file system (none on the web, where commands take and return bytes) ---------- +// +// Every path a command reads or writes goes through these, which check it against the automation +// roots in force ([`crate::file_access`]): a path outside them is an error (`file_stamp` and +// `file_created`: no file), as is any path once roots are in force without its kind of access. + +/// `path` checked for reading against the automation roots in force. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn may_read(path: &str) -> Result<()> { + #[cfg(test)] + touch(path); + crate::file_access::check_read(path).map_err(EngineError::Other) +} + +/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own). +#[cfg(test)] +static TOUCHED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[cfg(test)] +fn touch(path: &str) { + if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 { + TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string()); + } +} + +/// Run `f` and list the paths read through this module meanwhile (by any test running then). +#[cfg(test)] +pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) { + use std::sync::atomic::Ordering::SeqCst; + let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len(); + RECORDING.fetch_add(1, SeqCst); + let out = f(); + RECORDING.fetch_sub(1, SeqCst); + let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec(); + (out, seen) +} + +/// `path` checked for writing against the automation roots in force. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn may_write(path: &str) -> Result<()> { + crate::file_access::check_write(path).map_err(EngineError::Other) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn read_file(path: &str) -> Result> { + may_read(path)?; + // A device or pipe may never end: only a regular file is read to its end. + if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { + return Err(EngineError::Other(format!("{path}: not a file"))); + } + std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +/// The first `max` bytes of the file at `path` (all of it when it is shorter). Anything but a +/// regular file (a folder, a pipe, a device) is refused, also through a link. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn read_file_head(path: &str, max: usize) -> Result> { + use std::io::Read; + may_read(path)?; + if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { + return Err(EngineError::Other(format!("{path}: not a file"))); + } + let mut out = vec![]; + std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(out) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + // Work that stops early for a cancel can leave a truncated result: none of it is written. + vectorcraft_doc::cancel::check()?; + may_write(path)?; + // Never a half-written file: see [`write_atomic`]. + write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +/// [`write_file`] with what `fill` writes into the file (a save streamed into it). +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; + may_write(path)?; + vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn create_dir(path: &str) -> Result<()> { + may_write(path)?; + std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never +/// replaced. When the write fails part way, the file it started is removed. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> { + use std::io::Write as _; + may_write(path)?; + let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?; + let written = f.write_all(bytes).and_then(|()| vectorcraft_format::sync_file(&f)); + drop(f); + written.map_err(|e| { + // Best effort: the partly written file is all there is to clean up. + let _ = std::fs::remove_file(path); + err(e) + }) +} + +/// The highest number [`copy_new`] gives a copy's name (`Name 99.png`). +#[cfg(not(target_arch = "wasm32"))] +const MAX_COPY_NUMBER: u32 = 99; + +/// Where [`copy_new`] put a file. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) enum Placed { + /// Copied to this new file. + Copied(std::path::PathBuf), + /// Kept as this file, which holds the same contents. + Kept(std::path::PathBuf), +} + +/// Put a copy of the file `from` into the folder `dir`, never replacing a file there. It tries +/// `Name.ext`, then `Name 2.ext` up to `Name 99.ext`, and stops at the first name that is free, +/// where `create` makes the copy, or that is a file holding the same contents (`same`, given that +/// file and its metadata), which is kept. When `create` fails with `AlreadyExists` (the name was +/// taken meanwhile), it tries the next name. `None` when no name is free or holds the same +/// contents; a name whose metadata can't be read counts as taken. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn copy_new( + from: &std::path::Path, + dir: &std::path::Path, + create: impl Fn(&std::path::Path) -> std::io::Result<()>, + same: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool, +) -> std::io::Result> { + use std::io::ErrorKind; + let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(ErrorKind::InvalidInput.into()) }; + let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); + for n in 1..=MAX_COPY_NUMBER { + let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) }; + match std::fs::symlink_metadata(&to) { + Err(e) if e.kind() == ErrorKind::NotFound => match create(&to) { + Ok(()) => return Ok(Some(Placed::Copied(to))), + // Taken meanwhile: the next name. + Err(e) if e.kind() == ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + }, + Ok(m) if m.is_file() && same(&to, &m) => return Ok(Some(Placed::Kept(to))), + _ => {} + } + } + Ok(None) +} + +/// The most entries `file.list` returns. +const LIST_MAX: usize = 5000; + +pub(super) fn file_list(_: &mut Session, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad("file.list", "missing `path`"))?; + let (entries, truncated) = list_dir(path, LIST_MAX)?; + Ok(json!({ "path": absolute_path(path), "entries": entries, "truncated": truncated })) +} + +/// The entries of folder `path`, sorted by name: at most `max`, and whether there were more. A +/// link isn't followed: its target may lie outside the automation roots. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn list_dir(path: &str, max: usize) -> Result<(Vec, bool)> { + may_read(path)?; + let dir = std::fs::read_dir(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut entries: Vec<(String, Value)> = vec![]; + let mut truncated = false; + for e in dir.flatten() { + if entries.len() == max { + truncated = true; + break; + } + let name = e.file_name().to_string_lossy().into_owned(); + let Ok(t) = e.file_type() else { continue }; + let entry = if t.is_symlink() { + json!({ "name": name, "kind": "link" }) + } else if t.is_dir() { + json!({ "name": name, "kind": "folder" }) + } else { + json!({ "name": name, "kind": "file", "size": e.metadata().map(|m| m.len()).ok() }) + }; + entries.push((name, entry)); + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + Ok((entries.into_iter().map(|(_, v)| v).collect(), truncated)) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn list_dir(_: &str, _: usize) -> Result<(Vec, bool)> { + Err(EngineError::Other("no file system here".into())) +} + +/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system +/// keeps one); `None` when there is no file at `path` (or automation may not read it). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option)> { + let m = file_metadata(path).filter(std::fs::Metadata::is_file)?; + let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); + Some((m.len(), modified)) +} + +/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it +/// (and automation may read it). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn file_created(path: &str) -> Option { + let t = file_metadata(path)?.created().ok()?; + t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64) +} + +/// The file system's metadata of `path`, following links (when automation may read it). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn file_metadata(path: &str) -> Option { + may_read(path).ok()?; + std::fs::metadata(path).ok() +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn file_created(_: &str) -> Option { + None +} + +/// `path` made absolute against the working directory (as given when absolute already, or when +/// that fails). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn absolute_path(path: &str) -> String { + if std::path::Path::new(path).is_absolute() { + return path.to_string(); + } + std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) +} + +/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an +/// SVG image's href), or why it is not followed. `document` is the path of the document it is +/// stored in, when it has one: a relative path is taken from its folder. Only files on this +/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a +/// path that is still relative (no document) is refused, as the working directory isn't the +/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive +/// ([`share_path`]): a path refused as on another computer is checked again against the +/// document's path on that share. A path on a drive mapped to a network share other than the +/// document's ([`drive_share_path`]) is refused as on another computer. Every stored path +/// reaches the file system through this. +pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result { + document_path_on(stored, document, share_path, drive_share_path) +} + +/// [`document_path`], with `share` giving a document's path on the network share behind its drive. +pub(crate) fn document_path_with( + stored: &str, + document: Option<&str>, + share: impl FnOnce(&str) -> Option, +) -> std::result::Result { + use craft_docpath::Refused; + let document = document.map(absolute_path); + let resolve = |document: Option<&str>| craft_docpath::resolve_absolute(stored, document.map(std::path::Path::new)); + let path = match resolve(document.as_deref()) { + Err(Refused::Network) => { + let share = document.as_deref().and_then(share).ok_or(Refused::Network)?; + resolve(Some(share.as_str()))? + } + path => path?, + }; + path.into_os_string().into_string().map_err(|_| Refused::Invalid) +} + +/// [`document_path_with`], also refusing a path on a drive that is mapped to a network share other +/// than the document's (or, for a document on this computer or without a path, to any share) as +/// [`craft_docpath::Refused::Network`]. `drive_share` gives a path's path on the share behind its +/// drive (`Y:\art\x.png` on `\\server\other\art\x.png`), or `None` for a path on this computer. A +/// path on the document's own drive is not looked up. A document written `\\?\Z:\…` is looked up +/// on its share as `Z:\…` is. +pub(crate) fn document_path_on( + stored: &str, + document: Option<&str>, + share: impl Fn(&str) -> Option, + drive_share: impl FnOnce(&str) -> Option, +) -> std::result::Result { + let share = |d: &str| share(d.strip_prefix(r"\\?\").filter(|d| drive_letter(d).is_some()).unwrap_or(d)); + let path = document_path_with(stored, document, share)?; + let document = document.map(absolute_path); + let Some(drive) = drive_letter(&path) else { return Ok(path) }; + if document.as_deref().and_then(drive_letter).is_some_and(|d| d.eq_ignore_ascii_case(&drive)) { + return Ok(path); + } + let Some(on_share) = drive_share(&path) else { return Ok(path) }; + let home = document.map(|d| share(&d).unwrap_or(d)); + craft_docpath::resolve(&on_share, home.as_deref().map(std::path::Path::new))?; + Ok(path) +} + +/// The drive letter of an absolute path on a drive (`C:\…`, `C:/…` or `\\?\C:\…`). +fn drive_letter(path: &str) -> Option { + let mut chars = path.strip_prefix(r"\\?\").unwrap_or(path).chars(); + match (chars.next(), chars.next(), chars.next()) { + (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic() => Some(l), + _ => None, + } +} + +/// The path of `path` (on a drive, `Y:\art\x.png`) on the network share the drive is mapped to +/// (`\\server\other\art\x.png`), when the drive's root folder is on one and automation may read +/// `path`. The root folder's final path ([`std::fs::canonicalize`]) names the share. +#[cfg(windows)] +pub(crate) fn drive_share_path(path: &str) -> Option { + may_read(path).ok()?; + drive_share_of(path, |root| std::fs::canonicalize(root).ok()) +} + +#[cfg(not(windows))] +pub(crate) fn drive_share_path(_: &str) -> Option { + None +} + +/// The path of `path` on the network share its drive is mapped to, given `final_path`, which +/// returns the final path of the drive's root folder (`Y:\`): as [`share_of`] gives it for a +/// document, also for a path written `\\?\Y:\…`. +#[cfg(any(windows, test))] +pub(crate) fn drive_share_of(path: &str, final_path: impl FnOnce(&str) -> Option) -> Option { + let path = path.strip_prefix(r"\\?\").unwrap_or(path); + share_of(path, |folder| { + let (root, rest) = (folder.get(..3)?, folder.get(3..)?); + let root = final_path(root)?; + let root = root.to_str()?.trim_end_matches('\\'); + Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() }) + }) +} + +/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is +/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may +/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as +/// `\\?\UNC\server\share\…`. +#[cfg(windows)] +pub(crate) fn share_path(document: &str) -> Option { + share_of(document, |folder| { + may_read(folder).ok()?; + std::fs::canonicalize(folder).ok() + }) +} + +#[cfg(not(windows))] +pub(crate) fn share_path(_: &str) -> Option { + None +} + +/// The path of `document` on the network share its drive is mapped to, given `final_path`, which +/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`) +/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume. +#[cfg(any(windows, test))] +pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option) -> Option { + let mut chars = document.chars(); + if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) { + return None; + } + // The drive's root folder keeps its separator (`Z:\`). + let split = document.rfind(['\\', '/'])?; + let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?); + if name.is_empty() { + return None; + } + let target = final_path(folder)?; + let target = target.to_str()?; + let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\'); + if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 { + return None; + } + Some(format!(r"\\{unc}\{name}")) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option)> { + None +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn absolute_path(path: &str) -> String { + path.to_string() +} + +#[cfg(target_arch = "wasm32")] +fn no_fs(path: &str) -> EngineError { + EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)")) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn read_file(path: &str) -> Result> { + Err(no_fs(path)) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn read_file_head(path: &str, _: usize) -> Result> { + Err(no_fs(path)) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> { + Err(no_fs(path)) +} + +#[cfg(target_arch = "wasm32")] +pub fn write_file_with(path: &str, _: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + Err(no_fs(path)) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn create_dir(path: &str) -> Result<()> { + Err(no_fs(path)) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { + Err(no_fs(path)) +} diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index ddb6905e9..671bc04b6 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -20,52 +20,16 @@ use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKin use super::load::{err, raster_doc}; use crate::Result; -/// What an Affinity file opened as. -pub(super) struct Imported { - pub doc: Document, - pub warnings: Vec, - /// Only the embedded preview: never placed, used as a template or mined for styles. - pub preview_only: bool, -} +mod document; +mod import; +mod layout; +mod master_copies; +mod nodes; +mod paint; +mod text; -pub(super) fn import(name: &str, bytes: &[u8]) -> Result { - match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { - Ok(d) => { - let mut b = Builder::new(72.0 / d.dpi); - b.document(&d)?; - b.doc.limit_depth_noting(&mut b.warnings); - let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); - warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); - Ok(Imported { doc: b.doc, warnings, preview_only: false }) - } - Err(native) => { - let (doc, w) = preview(name, bytes, &native)?; - Ok(Imported { doc, warnings: vec![w], preview_only: true }) - } - } -} - -/// The embedded preview, for files whose native document can't be read. -fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> { - let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?; - // `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this - // untrusted preview's complete pixels first, so corrupt data cannot create a blank image. - let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(128 << 20); - reader.limits(limits); - let _pixels = reader.decode().map_err(err)?; - let d = raster_doc(name, p.png)?; - Ok(( - d, - format!( - "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.", - p.width, p.height - ), - )) -} +pub(super) use import::import; +use master_copies::{CopyCost, MasterPart}; struct Builder { doc: Document, @@ -92,1417 +56,3 @@ struct Builder { /// Each master spread's objects as its pages show them, made the first time one does. master_parts: std::collections::HashMap>, } - -/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. -#[derive(Clone, Copy, Debug, Default)] -struct CopyCost { - work: u64, - pixels: u64, -} - -impl CopyCost { - fn plus(self, o: CopyCost) -> CopyCost { - CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } - } - fn minus(self, o: CopyCost) -> Option { - Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) - } -} - -/// Master page copies of one import together: as many objects, characters and pixels as a -/// document may hold, a few times over. A master shown on thousands of pages, or a huge one -/// on many, would otherwise multiply them. -const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; - -/// One object a master spread shows on the pages that show it: a top-level node of the spread -/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. -#[derive(Clone, Copy, Debug)] -struct MasterPart { - node: usize, - child: Option, - bounds: Option, - cost: CopyCost, -} - -fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { - if depth > MAX_DEPTH { - return CopyCost::default(); - } - let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); - let own = match &n.kind { - Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, - Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, - Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, - _ => CopyCost { work: 1, pixels: 0 }, - }; - n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) -} - -/// Deepest node tree mapped (the reader bounds trees well below this). -const MAX_DEPTH: usize = 160; - -fn rect_of(r: af::Rect) -> Rect { - Rect::new(r.x0, r.y0, r.x1, r.y1) -} - -fn affine(a: af::Affine) -> Affine { - Affine::new(a.0) -} - -/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node -/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. -struct Placement { - /// Node space to the frame's space, before `place`. - to_frame: Affine, - place: Affine, - /// Points per node unit of type size. - size_scale: f64, - /// Horizontal scale of the type relative to `size_scale`. - h_scale: f64, -} - -impl Builder { - fn new(k: f64) -> Self { - Self { - doc: Document::new(0.0, 0.0), - warnings: Vec::new(), - k, - layer: 0, - embedded: 0, - masters: Vec::new(), - cmyk_profile: None, - cmyk_lut: None, - working_cmyk: None, - overlay_work: vectorcraft_affinity::overlay::MAX_WORK, - copy_left: MAX_MASTER_COPIES, - master_parts: std::collections::HashMap::new(), - } - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - fn document(&mut self, d: &af::Document) -> Result<()> { - self.cmyk_profile = d.cmyk_profile.clone(); - // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. - let mut layers = Vec::new(); - let mut artboards = Vec::new(); - let mut x = 0.0; - for (i, spread) in d.spreads.iter().enumerate() { - let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k }; - let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k); - let before = artboards.len(); - let mut loose: Vec> = Vec::new(); - for n in &spread.nodes { - let top = match &n.kind { - Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), - Kind::Artboard { .. } => { - self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) - } - _ => { - if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { - loose.push(art); - } - continue; - } - }; - if !loose.is_empty() { - layers.push(self.layer_with(None, std::mem::take(&mut loose))); - } - layers.extend(top); - } - if !loose.is_empty() { - layers.push(self.layer_with(None, loose)); - } - self.master_pages(d, spread, to_doc); - // Without artboards, each page (or the spread) becomes one. - if artboards.len() == before { - let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; - for r in rects { - let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1); - artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); - } - } - // (Earlier spreads' artboards are all left of `x` already.) - x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; - if spread.transparent && i == 0 { - self.warn("transparent page backgrounds show as white artboards"); - } - } - self.doc.artboards = artboards - .into_iter() - .enumerate() - .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) - .map(|(i, (name, rect))| Artboard { - id: i as u32 + 1, - name, - rect, - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }) - .collect(); - if self.doc.artboards.is_empty() { - return Err(err("the Affinity document has no page with a size")); - } - // Master pages are behind everything on the pages that show them. - let mut masters = std::mem::take(&mut self.masters); - masters.sort_by_key(|(m, _)| *m); - let mut bottom = Vec::new(); - for (m, art) in masters { - let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); - l.locked = true; - bottom.push(Arc::new(l)); - } - // Bottom to top, as Affinity lists them. - bottom.extend(layers); - self.doc.layers = bottom; - if self.doc.layers.is_empty() { - let empty = self.layer_with(None, Vec::new()); - self.doc.layers.push(empty); - } - Ok(()) - } - - /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). - /// Each master page shows only the objects on it, moved from its page to the one showing it. - fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { - let instances = master_instances(&spread.nodes); - if instances.is_empty() { - return; - } - let owners = page_masters(&instances, spread.pages.len()); - for (pi, page) in spread.pages.iter().enumerate() { - let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; - let Some(m) = d.masters.get(master) else { continue }; - let index = master_page.saturating_add(pi.saturating_sub(first)); - let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); - let parts = self.master_parts(master, m); - let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); - // Looking through the master's objects takes one each, copying them what they hold. - let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; - let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); - let Some(left) = self.copy_left.minus(cost) else { - self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); - return; - }; - self.copy_left = left; - let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); - let mut art = Vec::new(); - let mut boards = Vec::new(); - self.embedded += 1; - let mut i = 0; - while let Some(p) = parts.get(i).copied() { - let Some(n) = m.nodes.get(p.node) else { - i += 1; - continue; - }; - if p.child.is_none() { - if on_page(&p.bounds) { - art.extend(self.node(n, place, &mut boards, false, 0)); - } - i += 1; - continue; - } - // A layer of the master spread: just its objects on this page. - let mut kids: Vec> = Vec::new(); - while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { - if on_page(&q.bounds) - && let Some(c) = q.child.and_then(|c| n.children.get(c)) - { - kids.extend(self.node(c, place, &mut boards, false, 1)); - } - i += 1; - } - if !kids.is_empty() { - let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); - g.isolate = n.blend != Blend::PassThrough; - art.push(Arc::new(g)); - } - } - self.embedded -= 1; - if art.is_empty() { - continue; - } - let mut g = Node::group(self.doc.alloc_id(), art); - g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); - match self.masters.iter_mut().find(|(i, _)| *i == master) { - Some((_, v)) => v.push(Arc::new(g)), - None => self.masters.push((master, vec![Arc::new(g)])), - } - } - } - - /// The objects of master spread `m` (number `index`) as its pages show them: its layers' - /// objects one by one and its other top-level objects, with their bounds and costs. - fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { - if let Some(parts) = self.master_parts.get(&index) { - return parts.clone(); - } - if !master_instances(&m.nodes).is_empty() { - self.warn("master pages based on other master pages (only their own objects are used)"); - } - let part = |node: usize, child: Option, n: &af::Node| MasterPart { - node, - child, - bounds: af::nodes_bounds(std::slice::from_ref(n)), - cost: copy_cost(n, 0), - }; - let mut parts = Vec::new(); - for (i, n) in m.nodes.iter().enumerate() { - if matches!(n.kind, Kind::Layer) { - parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); - } else { - parts.push(part(i, None, n)); - } - } - let parts: Arc<[MasterPart]> = parts.into(); - self.master_parts.insert(index, parts.clone()); - parts - } - - fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { - let id = self.doc.alloc_id(); - self.layer = self.layer.wrapping_add(1); - let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer)); - let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9)); - if let Some(c) = n.children_mut() { - *c = children; - } - Arc::new(n) - } - - /// An artboard at the top level becomes a layer of its name holding its clip group. - fn layer_of(&mut self, name: &str, art: Arc, visible: bool, locked: bool) -> Arc { - let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone(); - l.visible = visible; - l.locked = locked; - Arc::new(l) - } - - fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node { - let mut out = Node::new(self.doc.alloc_id(), kind); - out.name = (!n.name.is_empty()).then(|| n.name.clone()); - out.visible = n.visible; - out.locked = n.locked; - out.opacity = n.opacity as f32; - out.blend = self.blend(n.blend); - out - } - - fn blend(&mut self, b: Blend) -> BlendMode { - match b { - Blend::Normal | Blend::PassThrough => BlendMode::Normal, - Blend::Darken => BlendMode::Darken, - Blend::Multiply => BlendMode::Multiply, - Blend::ColorBurn => BlendMode::ColorBurn, - Blend::Lighten => BlendMode::Lighten, - Blend::Screen => BlendMode::Screen, - Blend::ColorDodge => BlendMode::ColorDodge, - Blend::Overlay => BlendMode::Overlay, - Blend::SoftLight => BlendMode::SoftLight, - Blend::HardLight => BlendMode::HardLight, - Blend::Difference => BlendMode::Difference, - Blend::Exclusion => BlendMode::Exclusion, - Blend::Hue => BlendMode::Hue, - Blend::Saturation => BlendMode::Saturation, - Blend::Color => BlendMode::Color, - Blend::Luminosity => BlendMode::Luminosity, - _ => { - self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal"); - BlendMode::Normal - } - } - } - - /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has - /// no overlay effect). - fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { - // Each kind of message once, however often it is heard. - let mut warnings: Vec = Vec::new(); - let mut work = self.overlay_work; - let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { - if !warnings.iter().any(|x| x == w) { - warnings.push(w.to_string()); - } - }); - self.overlay_work = work; - for w in warnings { - self.warn(&w); - } - out - } - - /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to - /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. - fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool, depth: usize) -> Option> { - if depth > MAX_DEPTH { - self.warn("objects nested too deeply (left out)"); - return None; - } - // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely - // on it): objects reaching beyond it are left out. - if let Some(b) = af::kind_bounds(&n.kind) { - let r = to_doc.transform_rect_bbox(rect_of(b)); - if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { - self.warn("objects beyond the document range VectorCraft supports (left out)"); - return None; - } - } - let n = &*self.overlaid(n); - // Register before descending so nested artboards keep their document order. Inside an - // embedded document an artboard is just its content's frame. - if let Kind::Artboard { rect, .. } = &n.kind - && self.embedded == 0 - { - boards.push(( - if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, - to_doc.transform_rect_bbox(rect_of(*rect)), - )); - } - let child_level = matches!(n.kind, Kind::Layer) && layer_level; - let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); - self.embedded += usize::from(embeds); - let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); - self.embedded -= usize::from(embeds); - let mut out = match &n.kind { - Kind::Layer if layer_level => { - let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); - l.visible = n.visible; - l.locked = n.locked; - l.opacity = n.opacity as f32; - l.blend = self.blend(n.blend); - l - } - Kind::Layer | Kind::Group => { - let mut g = self.base(n, NodeKind::Group { children, clip: false }); - // Affinity groups isolate their blending unless they pass through. - g.isolate = n.blend != Blend::PassThrough; - g - } - Kind::Shape { path, fills, strokes, even_odd } => { - let data = self.path(path, to_doc); - let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; - let mut appearance = self.appearance(fills, strokes, to_doc); - // An outline effect on a shape is one more stroke. - self.outline_stroke(&n.effects, &mut appearance); - if children.is_empty() { - let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); - p.appearance = appearance.into(); - p - } else { - // A shape clips its children: VectorCraft's clipping path keeps its paint, its - // fill behind the clipped art and its stroke over it, as Affinity draws it. - let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false }); - clip.appearance = appearance.into(); - let mut kids = vec![Arc::new(clip)]; - kids.extend(children); - self.base(n, NodeKind::Group { children: kids, clip: true }) - } - } - Kind::Artboard { path, even_odd, background, strokes, .. } => { - let mut clip = Node::new( - self.doc.alloc_id(), - NodeKind::Path { - path: self.path(path, to_doc), - rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, - live: None, - clipping: true, - guide: false, - }, - ); - clip.appearance = self.appearance(background, strokes, to_doc).into(); - let mut kids = vec![Arc::new(clip)]; - kids.extend(children); - let mut g = self.base(n, NodeKind::Group { children: kids, clip: true }); - g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() }); - g - } - Kind::Text(t) => { - let Some((mut text, alpha)) = self.text(t, to_doc) else { - // The text is left out (warned); what it holds still comes in. - if children.is_empty() { - return None; - } - let g = self.base(n, NodeKind::Group { children, clip: false }); - return Some(Arc::new(g)); - }; - self.outline_text(&n.effects, &mut text); - let mut node = self.base(n, NodeKind::Text(Box::new(text))); - node.opacity *= alpha; - if !children.is_empty() { - let mut kids = vec![Arc::new(node)]; - kids.extend(children); - node = Node::group(self.doc.alloc_id(), kids); - } - node - } - Kind::Table(t) => { - let mut kids = self.table(t, to_doc); - if kids.is_empty() && children.is_empty() { - return None; - } - kids.extend(children); - let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); - g.name.get_or_insert_with(|| "Table".into()); - g - } - Kind::Image(img) => match self.image(img, to_doc) { - Some(kind) => self.base(n, kind), - None if children.is_empty() => return None, - None => self.base(n, NodeKind::Group { children, clip: false }), - }, - // Shown on the pages through the master's layer (see `master_pages`). - Kind::MasterInstance { .. } => return None, - Kind::Unsupported if children.is_empty() => return None, - Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), - }; - if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { - self.warn("outline effects on groups, images and tables (left out)"); - } - if let Some(img) = &n.pixel_mask { - // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing - // shows outside it, as an opacity mask that clips. - if let Some(kind) = self.image(img, to_doc) { - let art = Node::new(self.doc.alloc_id(), kind); - out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); - } - } - if let Some(mask) = &n.mask { - // A vector mask: the node shows only inside it. - let clip = Node::new( - self.doc.alloc_id(), - NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, - ); - let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone()); - out.opacity = 1.0; - out.blend = BlendMode::Normal; - let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true }); - (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); - out = g; - } - // Shadows and glows apply to what the node shows, mask and all. - let fx = self.effects(&n.effects); - if !fx.is_empty() { - out.appearance.effects.extend(fx); - } - Some(Arc::new(out)) - } - - /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's - /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle - /// is the direction the shadow goes in. - fn effects(&mut self, effects: &[af::Effect]) -> Vec { - let k = self.k; - let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; - let num = |v: f64| (v * 1000.0).round() / 1000.0; - let mut out = Vec::new(); - for e in effects { - let (id, params) = match e { - af::Effect::DropShadow { color, opacity, radius, distance, angle } => { - let (color, alpha) = Self::color(*color); - let d = len(*distance); - let a = if angle.is_finite() { *angle } else { 0.0 }; - let p = json!({ - "mode": "normal", - "opacity": num(opacity * f64::from(alpha) * 100.0), - "x": num(d * a.cos()), - "y": num(d * a.sin()), - "blur": num(len(*radius)), - "color": color, - }); - ("stylize.dropShadow", p) - } - af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { - self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); - let (color, alpha) = Self::color(*color); - let p = json!({ - "mode": "normal", - "opacity": num(opacity * f64::from(alpha) * 100.0), - "blur": num(len(*radius) + len(*distance)), - "color": color, - "source": "edge", - }); - ("stylize.innerGlow", p) - } - af::Effect::OuterGlow { color, opacity, radius } => { - let (color, alpha) = Self::color(*color); - let p = - json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); - ("stylize.outerGlow", p) - } - af::Effect::Bevel { .. } => { - self.warn("bevel and emboss effects (left out)"); - continue; - } - // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked - // into the paint (`overlaid`). - af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, - }; - out.push(Effect { id: id.into(), params, visible: true }); - } - out - } - - /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own - /// strokes included: an outline outside goes under them (from the path out to as far past them as - /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into - /// the shape as out of it here, which only differs by the width of the shape's own stroke. - fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { - // How far the shape's own strokes reach outside its path. - let reach = appearance - .items - .iter() - .filter_map(|i| match i { - AppearanceItem::Stroke(s) => Some(match s.align { - StrokeAlign::Center => s.width / 2.0, - StrokeAlign::Outside => s.width, - StrokeAlign::Inside => 0.0, - }), - _ => None, - }) - .filter(|w| w.is_finite()) - .fold(0.0, f64::max); - for e in effects { - let af::Effect::Outline { color, opacity, width, align } = e else { continue }; - let w = width * self.k; - if !(w.is_finite() && w > 0.0) { - continue; - } - let (c, alpha) = Self::color(*color); - let (align, w) = match align { - 1 => (StrokeAlign::Inside, w), - 2 => (StrokeAlign::Center, w + 2.0 * reach), - _ => (StrokeAlign::Outside, w + reach), - }; - let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); - l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; - l.join = LineJoin::Round; - l.align = align; - let item = AppearanceItem::Stroke(l); - if align == StrokeAlign::Outside { - // Under the shape's strokes, over its fills. - let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); - appearance.items.insert(at, item); - } else { - appearance.items.push(item); - } - } - } - - /// An outline effect on text: the characters' stroke, where they have none of their own. - fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { - for e in effects { - let af::Effect::Outline { color, opacity, width, align } = e else { continue }; - let w = width * self.k; - if !(w.is_finite() && w > 0.0) { - continue; - } - let (c, alpha) = Self::color(*color); - if opacity * f64::from(alpha) < 0.999 { - self.warn("semi-transparent outline effects on text (imported opaque)"); - } - if *align != 2 { - self.warn("outline effects on text inside or outside the characters (centred on them)"); - } - for r in &mut text.runs { - if r.style.stroke == Paint::None { - r.style.stroke = Paint::solid(c); - // Centred on the outline, so twice as wide to show as far outside. - r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); - } - } - } - } - - fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { - let mut bp = BezPath::new(); - for s in &p.subpaths { - let pt = |q: af::Point| Point::new(q.x, q.y); - bp.move_to(pt(s.start)); - for [c1, c2, e] in &s.segments { - if c1 == c2 && c2 == e { - bp.line_to(pt(*e)); - } else { - bp.curve_to(pt(*c1), pt(*c2), pt(*e)); - } - } - if s.closed { - bp.close_path(); - } - } - PathData::from_bezpath(&(to_doc * bp)) - } - - fn color(c: ap::Color) -> (Color, f32) { - let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; - match c { - ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), - ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), - ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)), - ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)), - } - } - - /// A paint and the opacity its colour's alpha becomes. - fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> { - match p { - ap::Paint::None => None, - ap::Paint::Solid(c) => { - let (color, a) = Self::color(*c); - Some((Paint::solid(color), a)) - } - ap::Paint::Gradient(g) => { - let stops = g - .stops - .iter() - .map(|s| { - let (color, opacity) = Self::color(s.color); - let mut stop = GradientStop::new(s.offset as f32, color); - stop.opacity = opacity; - stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32; - stop - }) - .collect(); - let m = to_doc * affine(g.transform); - let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0)); - let kind = match g.kind { - AfKind::Linear => GradientKind::Linear, - AfKind::Radial => GradientKind::Radial, - AfKind::Conical => { - self.warn("conical gradients import as radial gradients"); - GradientKind::Radial - } - }; - let (u, v): (Vec2, Vec2) = (x - o, y - o); - if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { - self.warn("skewed elliptical gradients import as upright ellipses"); - } - let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; - let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; - let mut gp = GradientPaint::new(Gradient::new(kind, stops)); - gp.angle = (-(u.y)).atan2(u.x).to_degrees(); - gp.geom = Some(geom); - Some((Paint::Gradient(Box::new(gp)), 1.0)) - } - } - } - - fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { - let (paint, opacity) = self.paint(&s.paint, to_doc)?; - let mut l = StrokeLayer::new(paint, s.width * self.k); - l.opacity = opacity; - l.cap = match s.cap { - Cap::Butt => LineCap::Butt, - Cap::Round => LineCap::Round, - Cap::Square => LineCap::Square, - }; - l.join = match s.join { - Join::Miter => LineJoin::Miter, - Join::Round => LineJoin::Round, - Join::Bevel => LineJoin::Bevel, - }; - l.miter_limit = s.miter_limit; - l.align = match s.align { - ap::Align::Center => StrokeAlign::Center, - ap::Align::Inside => StrokeAlign::Inside, - ap::Align::Outside => StrokeAlign::Outside, - }; - l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { - pattern: pattern.iter().map(|v| v * self.k).collect(), - offset: phase * self.k, - align_corners: false, - }); - Some(l) - } - - fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { - let mut items = Vec::new(); - let mut above = Vec::new(); - for f in fills { - if let Some((paint, opacity)) = self.paint(f, to_doc) { - let mut l = FillLayer::new(paint); - l.opacity = opacity; - items.push(AppearanceItem::Fill(l)); - } - } - for s in strokes { - let Some(l) = self.stroke_layer(s, to_doc) else { continue }; - if s.behind { - items.insert(0, AppearanceItem::Stroke(l)); - } else { - above.push(AppearanceItem::Stroke(l)); - } - } - items.extend(above); - Appearance { items, ..Appearance::default() } - } - - /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. - fn placement(&mut self, m: Affine, type_m: Affine) -> Option { - let [a, b, c, d, e, f] = m.as_coeffs(); - let sx = a.hypot(b); - let det = a * d - b * c; - if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { - self.warn("text with an invalid transform (left out)"); - return None; - } - let sy = det / sx; - let shear = (a * c + b * d) / (sx * sx); - if shear.abs() > 1e-3 || sy < 0.0 { - self.warn("skewed or flipped text (imported without the skew or flip)"); - } - // The type's vertical scale gives the font size, its horizontal scale relative to that the - // characters' width. - let [ta, tb, tc, td, ..] = type_m.as_coeffs(); - let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); - let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { - let tsy = (tdet / tsx).abs(); - (tsy, tsx / tsy) - } else { - self.warn("text with an invalid text scale (imported unscaled)"); - (det.abs().sqrt(), 1.0) - }; - Some(Placement { - to_frame: Affine::scale_non_uniform(sx, sy.abs()), - place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), - size_scale, - h_scale, - }) - } - - /// Text → its object and the opacity its characters' shared transparency gives the node. - fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { - let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; - let alpha = self.text_alpha(&t.runs); - let mut obj = self.type_object(&t.runs, &p)?; - obj.para.justify = justify(t.align); - if let Some(first) = obj.paras.first_mut() { - first.justify = obj.para.justify; - } - match t.frame { - Some(f) => { - let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); - let r = Rect::from_points(a, b); - obj.xf = p.place * Affine::translate(r.origin().to_vec2()); - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; - // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. - obj.area.first_baseline = FirstBaseline::CapHeight; - if fit_lines(&mut obj, r.width(), r.height()) { - self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); - } - // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. - if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { - self.warn("text frames too short for their first line (made taller so the line shows)"); - } - } - None => { - align_lines(&mut obj); - obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); - } - } - Some((obj, alpha)) - } - - /// The opacity text takes from its characters' colour: VectorCraft's characters have none of - /// their own, so text whose characters share one becomes an object with that opacity. - fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { - let alphas: Vec = - runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); - let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); - if alphas.is_empty() || lo >= 1.0 { - return 1.0; - } - if hi - lo > 0.01 { - self.warn("text with characters of different transparency (imported opaque)"); - return 1.0; - } - lo.clamp(0.0, 1.0) as f32 - } - - /// The characters and paragraphs of `runs` as a text object at the origin of text space. - fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { - let (v, h) = (p.size_scale, p.size_scale * p.h_scale); - let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); - let mut formats: Vec = Vec::new(); - let mut out: Vec = Vec::new(); - let mut paragraph = 0usize; - // Whether each paragraph here continues the one before after a line break (U+2028): - // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. - let mut continues = vec![false]; - for r in runs { - for c in r.text.chars() { - match c { - '\u{2028}' => continues.push(true), - '\u{2029}' | '\r' | '\n' => continues.push(false), - _ => {} - } - } - // Affinity separates paragraphs with U+2029 and lines with U+2028; other control - // characters don't show. - let text: String = r - .text - .chars() - .filter_map(|c| match c { - '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), - '\t' => Some('\t'), - c if c.is_control() => None, - c => Some(c), - }) - .collect(); - if text.is_empty() { - continue; - } - for c in text.chars() { - if formats.len() <= paragraph { - formats.push(r.paragraph); - } - if c == '\n' { - paragraph += 1; - } - } - let mut style = self.char_style(r, (v, p.h_scale)); - if style.leading.is_none() { - style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); - } - out.push(TextRun::new(text, style)); - } - // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. - if let Some(last) = out.last_mut() - && last.text.ends_with('\n') - { - last.text.pop(); - if last.text.is_empty() { - out.pop(); - } - } - obj.runs = out; - let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; - let mut paras = Vec::with_capacity(count); - let mut previous_after = 0.0; - // The run of each paragraph's first character. - let mut first_runs = Vec::with_capacity(count); - { - let mut at_start = true; - for (i, r) in obj.runs.iter().enumerate() { - for c in r.text.chars() { - if at_start { - first_runs.push(i); - at_start = false; - } - if c == '\n' { - at_start = true; - } - } - } - } - // Line gaps by font, each looked up once. - let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); - for i in 0..count { - let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); - let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; - let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; - // Affinity places the first line and the others from the left edge; VectorCraft's - // first-line indent is relative to its left indent. - ps.left_indent = pt(f.left, h); - ps.first_line_indent = pt(f.first - f.left, h); - ps.right_indent = pt(f.right, h); - // Between two paragraphs Affinity keeps the larger of the space after the first and - // before the second; VectorCraft adds them. - let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); - // The lines of one Affinity paragraph: no space between them, and the first-line - // indent only on its first. - if continues.get(i).copied().unwrap_or(false) { - before = 0.0; - ps.first_line_indent = 0.0; - } - if continues.get(i + 1).copied().unwrap_or(false) { - after = 0.0; - } - ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; - ps.space_after = after; - previous_after = after; - // Automatic leading: the font size plus the font's line gap, times the paragraph's - // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas - // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, - // and the public Arial frame fixture fits this better than the font's line height. - let (family, style) = - first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); - let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); - let ratio = (1.0 + gap) * f.auto_leading; - // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft - // takes 0–500 %. - if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { - ps.justification.auto_leading = ratio * 100.0; - } - paras.push(ps); - } - if let Some(first) = paras.first() { - obj.para = first.clone(); - } - obj.paras = paras; - obj.normalize_paras(); - Some(obj) - } - - /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and - /// its horizontal scale relative to that. - fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { - let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; - let mut style = CharStyle::default(); - if !r.family.is_empty() { - style.font_family = r.family.clone(); - } else if !r.postscript.is_empty() { - style.font_family = r.postscript.clone(); - } else { - self.warn("text without a font name uses the default font"); - } - // A family/weight pair cannot identify width variants such as Arial Narrow. - // Resolve a known PostScript identity to the exact installed family and style. - if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { - style.font_family = family; - style.font_style = face_style; - } else { - style.font_style = font_style(r.weight, r.italic); - } - style.features = r.features.clone(); - if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { - self.warn("unsupported text OpenType features (retained but not rendered)"); - } - style.all_caps = r.all_caps; - style.underline = r.underline; - style.strikethrough = r.strikethrough; - style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); - style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); - style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); - // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of - // its own. - let hs = h_scale * r.h_scale; - if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { - style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); - } - // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter - // for a solid colour, and gradients on type are laid out in text space. - style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { - Some((p, _)) => p, - None => Paint::None, - }; - if let Some(st) = &r.stroke { - let w = st.width * size_scale; - if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) - && w.is_finite() - && w > 0.0 - { - style.stroke = paint; - style.stroke_width = w.min(1000.0); - } - } - style - } - - /// A table: its cells as area type and the lines on their edges, as VectorCraft has no - /// tables. - fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { - let mut out = Vec::new(); - let (ncols, nrows) = (t.ncols(), t.nrows()); - if ncols == 0 || nrows == 0 { - return out; - } - self.warn("tables (imported as text frames and lines)"); - let m = to_doc * affine(t.transform); - let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; - let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); - let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); - for (i, cell) in t.cells.iter().enumerate() { - if cell.merged_left || cell.runs.is_empty() { - continue; - } - let (r, c) = (i / ncols, i % ncols); - // Cells to the right merged into this one widen it. - let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); - let [l, tp, rt, bt] = cell.inset; - let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; - let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); - let rect = Rect::from_points(a, b); - if !(rect.width() > 0.0 && rect.height() > 0.0) { - continue; - } - let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; - obj.area.vertical_align = match cell.valign { - af::VAlign::Top => VerticalAlign::Top, - af::VAlign::Center => VerticalAlign::Center, - af::VAlign::Bottom => VerticalAlign::Bottom, - }; - obj.area.first_baseline = FirstBaseline::CapHeight; - let dy = place_cell(&mut obj, rect.width(), rect.height()); - obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); - // A cell's text never vanishes for want of a line's height. - vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); - let alpha = self.text_alpha(&cell.runs); - let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); - n.opacity = alpha; - out.push(Arc::new(n)); - } - // Lines over the cells: the vertical edges row by row (none inside merged cells), then - // the horizontal ones. - let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { - let Some(s) = s else { return }; - let Some(layer) = b.stroke_layer(s, to_doc) else { return }; - let mut bp = BezPath::new(); - bp.move_to(m * Point::new(from.0, from.1)); - bp.line_to(m * Point::new(to.0, to.1)); - let mut n = Node::new( - b.doc.alloc_id(), - NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, - ); - n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); - out.push(Arc::new(n)); - }; - for r in 0..nrows { - for k in 0..=ncols { - let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); - if merged { - continue; - } - if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { - line(self, s, (col(k), row(r)), (col(k), row(r + 1))); - } - } - } - for j in 0..=nrows { - for k in 0..ncols { - if let Some(s) = t.horizontal.get(j * ncols + k) { - line(self, s, (col(k), row(j)), (col(k + 1), row(j))); - } - } - } - out - } - - /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it - /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its - /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); - /// with transparency, RGBA through the working CMYK profile. - fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { - let n = (width as usize).checked_mul(height as usize)?; - let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; - if let Some(lut) = self.document_cmyk() { - let rgba = px - .iter() - .flat_map(|p| { - let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); - [r, g, b, p[4]] - }) - .collect(); - return png(width, height, rgba); - } - if px.iter().all(|p| p[4] == 255) { - let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); - if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { - return Some(Arc::unwrap_or_clone(blob.bytes)); - } - } - self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); - let lut = self - .working_cmyk - .get_or_insert_with(|| { - Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { - let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); - [r, g, b, 0.0] - })) - }) - .clone(); - let rgba = px - .iter() - .flat_map(|p| { - let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); - [r, g, b, p[4]] - }) - .collect(); - png(width, height, rgba) - } - - /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, - /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when - /// the document embeds none, or not one that converts. - fn document_cmyk(&mut self) -> Option> { - use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; - if let Some(lut) = &self.cmyk_lut { - return lut.clone(); - } - let embedded = self.cmyk_profile.is_some(); - let lut = self.cmyk_profile.take().and_then(|bytes| { - let profile = IccProfile::from_bytes(None, &bytes).ok()?; - let srgb = cms::icc::builtin_rgb(cms::SRGB)?; - profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; - Some(Arc::new(CmykLut::build(|inks| { - let [r, g, b] = profile - .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) - .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); - [r, g, b, 0.0] - }))) - }); - if embedded && lut.is_none() { - self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); - } - self.cmyk_lut = Some(lut.clone()); - lut - } - - fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { - let bytes = match &img.pixels { - Pixels::Encoded(b) => b.clone(), - Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, - Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, - }; - let r = match super::load::raster_image(&bytes) { - Ok(r) => r, - Err(_) => { - self.warn("an embedded image that could not be decoded was left out"); - return None; - } - }; - // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. - let sx = img.width as f64 / f64::from(r.width.max(1)); - let sy = img.height as f64 / f64::from(r.height.max(1)); - let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); - self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(Box::new(ImageObject { - key: r.key, - width: r.width, - height: r.height, - xf, - link: None, - placement: Default::default(), - pixels: None, - }))) - } -} - -/// The master page instances among `nodes` (also inside layers and groups): (master, first page, -/// first master page, page count). -fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { - fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { - if depth > MAX_DEPTH || !n.visible { - return; - } - match n.kind { - Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), - Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), - _ => {} - } - } - let mut out = Vec::new(); - nodes.iter().for_each(|n| visit(n, 0, &mut out)); - out -} - -/// Which master page instance each of `pages` pages shows: (master, first page, first master -/// page) of the first instance in `instances` that covers it. Each page is settled once (the -/// next page still open is found by a path-halving jump), so this is about linear in pages and -/// instances however much their ranges overlap. -fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { - fn open_from(next: &mut [usize], mut i: usize) -> usize { - while let Some(&n) = next.get(i) { - if n == i { - break; - } - let skip = next.get(n).copied().unwrap_or(n); - if let Some(slot) = next.get_mut(i) { - *slot = skip; - } - i = n; - } - i - } - let mut owner = vec![None; pages]; - // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). - let mut next: Vec = (0..=pages).collect(); - for &(master, first, master_page, count) in instances { - let end = first.saturating_add(count).min(pages); - let mut p = open_from(&mut next, first.min(pages)); - while p < end { - if let Some(o) = owner.get_mut(p) { - *o = Some((master, first, master_page)); - } - if let Some(n) = next.get_mut(p) { - *n = p + 1; - } - p = open_from(&mut next, p + 1); - } - } - owner -} - -/// Master names: A, B … Z, then A1, B1 …. -fn master_prefix(index: usize) -> String { - let letter = char::from(b'A' + (index % 26) as u8); - match index / 26 { - 0 => letter.to_string(), - n => format!("{letter}{n}"), - } -} - -/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the -/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even -/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell -/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last -/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down -/// the cell) and grows where the lines need more room. -fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { - let share = match obj.area.vertical_align { - VerticalAlign::Center => 0.5, - VerticalAlign::Bottom => 1.0, - _ => 0.0, - }; - let mut tall = obj.clone(); - tall.area.vertical_align = VerticalAlign::Top; - tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; - let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; - let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; - let (baseline, descent) = (last.baseline, last.descent); - if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { - return 0.0; - } - // (A little more than the lines need, for the rounding of the layout.) - let frame = h.max(baseline + descent + 0.05); - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; - // Where Affinity has the block's top, less where VectorCraft puts it in the frame. - (h - baseline) * share - (frame - baseline - descent) * share -} - -/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose -/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves -/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. -fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { - let db = vectorcraft_text::FontDb::global(); - if !(w > 0.0 && h > 0.0 && h.is_finite()) { - return false; - } - let first = vectorcraft_text::layout(db, obj); - if !first.overflow { - return false; - } - // Where the lines go in a frame with room for all of them. - let mut tall = obj.clone(); - tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; - let need = vectorcraft_text::layout(db, &tall) - .lines - .iter() - .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) - .map(|l| l.baseline + l.descent) - .filter(|b| b.is_finite()) - .fold(0.0, f64::max); - if need <= h { - return false; - } - let before = first.lines.len(); - // (A little more than they need, for the rounding of the layout.) - let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); - // Something else keeps the lines out: as the source has it. - if vectorcraft_text::layout(db, obj).lines.len() <= before { - obj.kind = short; - return false; - } - true -} - -/// RGBA pixels as a PNG file. -fn png(width: u32, height: u32, rgba: Vec) -> Option> { - let buf = image::RgbaImage::from_raw(width, height, rgba)?; - let mut png = Vec::new(); - buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some(png) -} - -/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). -fn share(j: Justify) -> f64 { - match j { - Justify::Center | Justify::JustifyCenter => 0.5, - Justify::Right | Justify::JustifyRight => 1.0, - _ => 0.0, - } -} - -/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the -/// width of the longest, and places that block at the anchor by the text's own alignment (its -/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the -/// lines to where Affinity has them and keep their alignment: a centred line centres between its -/// indents and a right-aligned one ends at its right indent, whatever its width. -fn align_lines(obj: &mut TextObject) { - let block = share(obj.para.justify); - if obj.paras.iter().all(|p| share(p.justify) == block) { - return; - } - let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; - let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); - if !(w > 0.0 && w < 1e6) { - return; - } - // The block's left edge, from the anchor. - let left = -w * block; - for p in &mut obj.paras { - let at = share(p.justify); - let shift = left + w * at; - // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right - // indent, and a centred one centres on half their difference. - if at < 0.25 { - p.left_indent += shift; - } else if at > 0.75 { - p.right_indent -= shift; - } else { - p.left_indent += shift; - p.right_indent -= shift; - } - } - if let Some(first) = obj.paras.first() { - obj.para = first.clone(); - } -} - -fn justify(a: AfAlign) -> Justify { - match a { - AfAlign::Left => Justify::Left, - AfAlign::Center => Justify::Center, - AfAlign::Right => Justify::Right, - AfAlign::Justify => Justify::JustifyLeft, - } -} - -/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for -/// a missing one); 0 when no font is loaded. -fn line_gap(family: &str, style: &str) -> f64 { - let defaults = CharStyle::default(); - let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; - let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; - let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; - let upem = face.units_per_em(); - let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; - if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } -} - -fn font_style(weight: i64, italic: bool) -> String { - let w = match weight { - ..=150 => "Thin", - 151..=250 => "ExtraLight", - 251..=325 => "Light", - 326..=450 => "Regular", - 451..=550 => "Medium", - 551..=650 => "SemiBold", - 651..=750 => "Bold", - 751..=850 => "ExtraBold", - _ => "Black", - }; - match (w, italic) { - ("Regular", true) => "Italic".into(), - (w, true) => format!("{w} Italic"), - (w, false) => w.into(), - } -} diff --git a/crates/engine/src/cmd/fileio/affinity/document.rs b/crates/engine/src/cmd/fileio/affinity/document.rs new file mode 100644 index 000000000..0d74d1d81 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/document.rs @@ -0,0 +1,211 @@ +use super::layout::{master_instances, master_prefix, page_masters}; +use super::master_copies::{CopyCost, MAX_MASTER_COPIES, MasterPart, copy_cost}; +use super::nodes::rect_of; +use super::*; + +impl Builder { + pub(super) fn new(k: f64) -> Self { + Self { + doc: Document::new(0.0, 0.0), + warnings: Vec::new(), + k, + layer: 0, + embedded: 0, + masters: Vec::new(), + cmyk_profile: None, + cmyk_lut: None, + working_cmyk: None, + overlay_work: vectorcraft_affinity::overlay::MAX_WORK, + copy_left: MAX_MASTER_COPIES, + master_parts: std::collections::HashMap::new(), + } + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + pub(super) fn document(&mut self, d: &af::Document) -> Result<()> { + self.cmyk_profile = d.cmyk_profile.clone(); + // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. + let mut layers = Vec::new(); + let mut artboards = Vec::new(); + let mut x = 0.0; + for (i, spread) in d.spreads.iter().enumerate() { + let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k }; + let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k); + let before = artboards.len(); + let mut loose: Vec> = Vec::new(); + for n in &spread.nodes { + let top = match &n.kind { + Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), + Kind::Artboard { .. } => { + self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) + } + _ => { + if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { + loose.push(art); + } + continue; + } + }; + if !loose.is_empty() { + layers.push(self.layer_with(None, std::mem::take(&mut loose))); + } + layers.extend(top); + } + if !loose.is_empty() { + layers.push(self.layer_with(None, loose)); + } + self.master_pages(d, spread, to_doc); + // Without artboards, each page (or the spread) becomes one. + if artboards.len() == before { + let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; + for r in rects { + let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1); + artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); + } + } + // (Earlier spreads' artboards are all left of `x` already.) + x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; + if spread.transparent && i == 0 { + self.warn("transparent page backgrounds show as white artboards"); + } + } + self.doc.artboards = artboards + .into_iter() + .enumerate() + .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) + .map(|(i, (name, rect))| Artboard { + id: i as u32 + 1, + name, + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }) + .collect(); + if self.doc.artboards.is_empty() { + return Err(err("the Affinity document has no page with a size")); + } + // Master pages are behind everything on the pages that show them. + let mut masters = std::mem::take(&mut self.masters); + masters.sort_by_key(|(m, _)| *m); + let mut bottom = Vec::new(); + for (m, art) in masters { + let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); + l.locked = true; + bottom.push(Arc::new(l)); + } + // Bottom to top, as Affinity lists them. + bottom.extend(layers); + self.doc.layers = bottom; + if self.doc.layers.is_empty() { + let empty = self.layer_with(None, Vec::new()); + self.doc.layers.push(empty); + } + Ok(()) + } + + /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). + /// Each master page shows only the objects on it, moved from its page to the one showing it. + fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { + let instances = master_instances(&spread.nodes); + if instances.is_empty() { + return; + } + let owners = page_masters(&instances, spread.pages.len()); + for (pi, page) in spread.pages.iter().enumerate() { + let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; + let Some(m) = d.masters.get(master) else { continue }; + let index = master_page.saturating_add(pi.saturating_sub(first)); + let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); + let parts = self.master_parts(master, m); + let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); + // Looking through the master's objects takes one each, copying them what they hold. + let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; + let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); + let Some(left) = self.copy_left.minus(cost) else { + self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); + return; + }; + self.copy_left = left; + let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); + let mut art = Vec::new(); + let mut boards = Vec::new(); + self.embedded += 1; + let mut i = 0; + while let Some(p) = parts.get(i).copied() { + let Some(n) = m.nodes.get(p.node) else { + i += 1; + continue; + }; + if p.child.is_none() { + if on_page(&p.bounds) { + art.extend(self.node(n, place, &mut boards, false, 0)); + } + i += 1; + continue; + } + // A layer of the master spread: just its objects on this page. + let mut kids: Vec> = Vec::new(); + while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { + if on_page(&q.bounds) + && let Some(c) = q.child.and_then(|c| n.children.get(c)) + { + kids.extend(self.node(c, place, &mut boards, false, 1)); + } + i += 1; + } + if !kids.is_empty() { + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.isolate = n.blend != Blend::PassThrough; + art.push(Arc::new(g)); + } + } + self.embedded -= 1; + if art.is_empty() { + continue; + } + let mut g = Node::group(self.doc.alloc_id(), art); + g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); + match self.masters.iter_mut().find(|(i, _)| *i == master) { + Some((_, v)) => v.push(Arc::new(g)), + None => self.masters.push((master, vec![Arc::new(g)])), + } + } + } + + /// The objects of master spread `m` (number `index`) as its pages show them: its layers' + /// objects one by one and its other top-level objects, with their bounds and costs. + fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { + if let Some(parts) = self.master_parts.get(&index) { + return parts.clone(); + } + if !master_instances(&m.nodes).is_empty() { + self.warn("master pages based on other master pages (only their own objects are used)"); + } + let part = |node: usize, child: Option, n: &af::Node| MasterPart { + node, + child, + bounds: af::nodes_bounds(std::slice::from_ref(n)), + cost: copy_cost(n, 0), + }; + let mut parts = Vec::new(); + for (i, n) in m.nodes.iter().enumerate() { + if matches!(n.kind, Kind::Layer) { + parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); + } else { + parts.push(part(i, None, n)); + } + } + let parts: Arc<[MasterPart]> = parts.into(); + self.master_parts.insert(index, parts.clone()); + parts + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/import.rs b/crates/engine/src/cmd/fileio/affinity/import.rs new file mode 100644 index 000000000..625df1e3b --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/import.rs @@ -0,0 +1,48 @@ +use super::*; + +/// What an Affinity file opened as. +pub(in super::super) struct Imported { + pub doc: Document, + pub warnings: Vec, + /// Only the embedded preview: never placed, used as a template or mined for styles. + pub preview_only: bool, +} + +pub(in super::super) fn import(name: &str, bytes: &[u8]) -> Result { + match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { + Ok(d) => { + let mut b = Builder::new(72.0 / d.dpi); + b.document(&d)?; + b.doc.limit_depth_noting(&mut b.warnings); + let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); + warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); + Ok(Imported { doc: b.doc, warnings, preview_only: false }) + } + Err(native) => { + let (doc, w) = preview(name, bytes, &native)?; + Ok(Imported { doc, warnings: vec![w], preview_only: true }) + } + } +} + +/// The embedded preview, for files whose native document can't be read. +fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> { + let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?; + // `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this + // untrusted preview's complete pixels first, so corrupt data cannot create a blank image. + let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(128 << 20); + reader.limits(limits); + let _pixels = reader.decode().map_err(err)?; + let d = raster_doc(name, p.png)?; + Ok(( + d, + format!( + "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.", + p.width, p.height + ), + )) +} diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs new file mode 100644 index 000000000..157a38836 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -0,0 +1,223 @@ +use super::nodes::MAX_DEPTH; +use super::*; + +/// The master page instances among `nodes` (also inside layers and groups): (master, first page, +/// first master page, page count). +pub(super) fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { + fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { + if depth > MAX_DEPTH || !n.visible { + return; + } + match n.kind { + Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), + Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), + _ => {} + } + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| visit(n, 0, &mut out)); + out +} + +/// Which master page instance each of `pages` pages shows: (master, first page, first master +/// page) of the first instance in `instances` that covers it. Each page is settled once (the +/// next page still open is found by a path-halving jump), so this is about linear in pages and +/// instances however much their ranges overlap. +pub(super) fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { + fn open_from(next: &mut [usize], mut i: usize) -> usize { + while let Some(&n) = next.get(i) { + if n == i { + break; + } + let skip = next.get(n).copied().unwrap_or(n); + if let Some(slot) = next.get_mut(i) { + *slot = skip; + } + i = n; + } + i + } + let mut owner = vec![None; pages]; + // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). + let mut next: Vec = (0..=pages).collect(); + for &(master, first, master_page, count) in instances { + let end = first.saturating_add(count).min(pages); + let mut p = open_from(&mut next, first.min(pages)); + while p < end { + if let Some(o) = owner.get_mut(p) { + *o = Some((master, first, master_page)); + } + if let Some(n) = next.get_mut(p) { + *n = p + 1; + } + p = open_from(&mut next, p + 1); + } + } + owner +} + +/// Master names: A, B … Z, then A1, B1 …. +pub(super) fn master_prefix(index: usize) -> String { + let letter = char::from(b'A' + (index % 26) as u8); + match index / 26 { + 0 => letter.to_string(), + n => format!("{letter}{n}"), + } +} + +/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the +/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even +/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell +/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last +/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down +/// the cell) and grows where the lines need more room. +pub(super) fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { + let share = match obj.area.vertical_align { + VerticalAlign::Center => 0.5, + VerticalAlign::Bottom => 1.0, + _ => 0.0, + }; + let mut tall = obj.clone(); + tall.area.vertical_align = VerticalAlign::Top; + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; + let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; + let (baseline, descent) = (last.baseline, last.descent); + if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { + return 0.0; + } + // (A little more than the lines need, for the rounding of the layout.) + let frame = h.max(baseline + descent + 0.05); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; + // Where Affinity has the block's top, less where VectorCraft puts it in the frame. + (h - baseline) * share - (frame - baseline - descent) * share +} + +/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose +/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves +/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. +pub(super) fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { + let db = vectorcraft_text::FontDb::global(); + if !(w > 0.0 && h > 0.0 && h.is_finite()) { + return false; + } + let first = vectorcraft_text::layout(db, obj); + if !first.overflow { + return false; + } + // Where the lines go in a frame with room for all of them. + let mut tall = obj.clone(); + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let need = vectorcraft_text::layout(db, &tall) + .lines + .iter() + .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) + .map(|l| l.baseline + l.descent) + .filter(|b| b.is_finite()) + .fold(0.0, f64::max); + if need <= h { + return false; + } + let before = first.lines.len(); + // (A little more than they need, for the rounding of the layout.) + let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); + // Something else keeps the lines out: as the source has it. + if vectorcraft_text::layout(db, obj).lines.len() <= before { + obj.kind = short; + return false; + } + true +} + +/// RGBA pixels as a PNG file. +pub(super) fn png(width: u32, height: u32, rgba: Vec) -> Option> { + let buf = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). +fn share(j: Justify) -> f64 { + match j { + Justify::Center | Justify::JustifyCenter => 0.5, + Justify::Right | Justify::JustifyRight => 1.0, + _ => 0.0, + } +} + +/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the +/// width of the longest, and places that block at the anchor by the text's own alignment (its +/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the +/// lines to where Affinity has them and keep their alignment: a centred line centres between its +/// indents and a right-aligned one ends at its right indent, whatever its width. +pub(super) fn align_lines(obj: &mut TextObject) { + let block = share(obj.para.justify); + if obj.paras.iter().all(|p| share(p.justify) == block) { + return; + } + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; + let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + if !(w > 0.0 && w < 1e6) { + return; + } + // The block's left edge, from the anchor. + let left = -w * block; + for p in &mut obj.paras { + let at = share(p.justify); + let shift = left + w * at; + // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right + // indent, and a centred one centres on half their difference. + if at < 0.25 { + p.left_indent += shift; + } else if at > 0.75 { + p.right_indent -= shift; + } else { + p.left_indent += shift; + p.right_indent -= shift; + } + } + if let Some(first) = obj.paras.first() { + obj.para = first.clone(); + } +} + +pub(super) fn justify(a: AfAlign) -> Justify { + match a { + AfAlign::Left => Justify::Left, + AfAlign::Center => Justify::Center, + AfAlign::Right => Justify::Right, + AfAlign::Justify => Justify::JustifyLeft, + } +} + +/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for +/// a missing one); 0 when no font is loaded. +pub(super) fn line_gap(family: &str, style: &str) -> f64 { + let defaults = CharStyle::default(); + let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; + let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; + let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; + let upem = face.units_per_em(); + let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; + if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } +} + +pub(super) fn font_style(weight: i64, italic: bool) -> String { + let w = match weight { + ..=150 => "Thin", + 151..=250 => "ExtraLight", + 251..=325 => "Light", + 326..=450 => "Regular", + 451..=550 => "Medium", + 551..=650 => "SemiBold", + 651..=750 => "Bold", + 751..=850 => "ExtraBold", + _ => "Black", + }; + match (w, italic) { + ("Regular", true) => "Italic".into(), + (w, true) => format!("{w} Italic"), + (w, false) => w.into(), + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs new file mode 100644 index 000000000..bc55dbc25 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -0,0 +1,47 @@ +use super::nodes::MAX_DEPTH; +use super::*; + +/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct CopyCost { + pub(super) work: u64, + pub(super) pixels: u64, +} + +impl CopyCost { + pub(super) fn plus(self, o: CopyCost) -> CopyCost { + CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } + } + pub(super) fn minus(self, o: CopyCost) -> Option { + Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) + } +} + +/// Master page copies of one import together: as many objects, characters and pixels as a +/// document may hold, a few times over. A master shown on thousands of pages, or a huge one +/// on many, would otherwise multiply them. +pub(super) const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; + +/// One object a master spread shows on the pages that show it: a top-level node of the spread +/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. +#[derive(Clone, Copy, Debug)] +pub(super) struct MasterPart { + pub(super) node: usize, + pub(super) child: Option, + pub(super) bounds: Option, + pub(super) cost: CopyCost, +} + +pub(super) fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { + if depth > MAX_DEPTH { + return CopyCost::default(); + } + let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); + let own = match &n.kind { + Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, + Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, + Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, + _ => CopyCost { work: 1, pixels: 0 }, + }; + n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) +} diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs new file mode 100644 index 000000000..a3867d0bd --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -0,0 +1,249 @@ +use super::*; + +/// Deepest node tree mapped (the reader bounds trees well below this). +pub(super) const MAX_DEPTH: usize = 160; + +pub(super) fn rect_of(r: af::Rect) -> Rect { + Rect::new(r.x0, r.y0, r.x1, r.y1) +} + +pub(super) fn affine(a: af::Affine) -> Affine { + Affine::new(a.0) +} + +impl Builder { + pub(super) fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { + let id = self.doc.alloc_id(); + self.layer = self.layer.wrapping_add(1); + let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer)); + let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9)); + if let Some(c) = n.children_mut() { + *c = children; + } + Arc::new(n) + } + + /// An artboard at the top level becomes a layer of its name holding its clip group. + pub(super) fn layer_of(&mut self, name: &str, art: Arc, visible: bool, locked: bool) -> Arc { + let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone(); + l.visible = visible; + l.locked = locked; + Arc::new(l) + } + + pub(super) fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node { + let mut out = Node::new(self.doc.alloc_id(), kind); + out.name = (!n.name.is_empty()).then(|| n.name.clone()); + out.visible = n.visible; + out.locked = n.locked; + out.opacity = n.opacity as f32; + out.blend = self.blend(n.blend); + out + } + + fn blend(&mut self, b: Blend) -> BlendMode { + match b { + Blend::Normal | Blend::PassThrough => BlendMode::Normal, + Blend::Darken => BlendMode::Darken, + Blend::Multiply => BlendMode::Multiply, + Blend::ColorBurn => BlendMode::ColorBurn, + Blend::Lighten => BlendMode::Lighten, + Blend::Screen => BlendMode::Screen, + Blend::ColorDodge => BlendMode::ColorDodge, + Blend::Overlay => BlendMode::Overlay, + Blend::SoftLight => BlendMode::SoftLight, + Blend::HardLight => BlendMode::HardLight, + Blend::Difference => BlendMode::Difference, + Blend::Exclusion => BlendMode::Exclusion, + Blend::Hue => BlendMode::Hue, + Blend::Saturation => BlendMode::Saturation, + Blend::Color => BlendMode::Color, + Blend::Luminosity => BlendMode::Luminosity, + _ => { + self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal"); + BlendMode::Normal + } + } + } + + /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has + /// no overlay effect). + fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { + // Each kind of message once, however often it is heard. + let mut warnings: Vec = Vec::new(); + let mut work = self.overlay_work; + let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } + }); + self.overlay_work = work; + for w in warnings { + self.warn(&w); + } + out + } + + /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to + /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. + pub(super) fn node( + &mut self, + n: &af::Node, + to_doc: Affine, + boards: &mut Vec<(String, Rect)>, + layer_level: bool, + depth: usize, + ) -> Option> { + if depth > MAX_DEPTH { + self.warn("objects nested too deeply (left out)"); + return None; + } + // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely + // on it): objects reaching beyond it are left out. + if let Some(b) = af::kind_bounds(&n.kind) { + let r = to_doc.transform_rect_bbox(rect_of(b)); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { + self.warn("objects beyond the document range VectorCraft supports (left out)"); + return None; + } + } + let n = &*self.overlaid(n); + // Register before descending so nested artboards keep their document order. Inside an + // embedded document an artboard is just its content's frame. + if let Kind::Artboard { rect, .. } = &n.kind + && self.embedded == 0 + { + boards.push(( + if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, + to_doc.transform_rect_bbox(rect_of(*rect)), + )); + } + let child_level = matches!(n.kind, Kind::Layer) && layer_level; + let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); + self.embedded += usize::from(embeds); + let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); + self.embedded -= usize::from(embeds); + let mut out = match &n.kind { + Kind::Layer if layer_level => { + let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); + l.visible = n.visible; + l.locked = n.locked; + l.opacity = n.opacity as f32; + l.blend = self.blend(n.blend); + l + } + Kind::Layer | Kind::Group => { + let mut g = self.base(n, NodeKind::Group { children, clip: false }); + // Affinity groups isolate their blending unless they pass through. + g.isolate = n.blend != Blend::PassThrough; + g + } + Kind::Shape { path, fills, strokes, even_odd } => { + let data = self.path(path, to_doc); + let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + let mut appearance = self.appearance(fills, strokes, to_doc); + // An outline effect on a shape is one more stroke. + self.outline_stroke(&n.effects, &mut appearance); + if children.is_empty() { + let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); + p.appearance = appearance.into(); + p + } else { + // A shape clips its children: VectorCraft's clipping path keeps its paint, its + // fill behind the clipped art and its stroke over it, as Affinity draws it. + let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false }); + clip.appearance = appearance.into(); + let mut kids = vec![Arc::new(clip)]; + kids.extend(children); + self.base(n, NodeKind::Group { children: kids, clip: true }) + } + } + Kind::Artboard { path, even_odd, background, strokes, .. } => { + let mut clip = Node::new( + self.doc.alloc_id(), + NodeKind::Path { + path: self.path(path, to_doc), + rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, + live: None, + clipping: true, + guide: false, + }, + ); + clip.appearance = self.appearance(background, strokes, to_doc).into(); + let mut kids = vec![Arc::new(clip)]; + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: true }); + g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() }); + g + } + Kind::Text(t) => { + let Some((mut text, alpha)) = self.text(t, to_doc) else { + // The text is left out (warned); what it holds still comes in. + if children.is_empty() { + return None; + } + let g = self.base(n, NodeKind::Group { children, clip: false }); + return Some(Arc::new(g)); + }; + self.outline_text(&n.effects, &mut text); + let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; + if !children.is_empty() { + let mut kids = vec![Arc::new(node)]; + kids.extend(children); + node = Node::group(self.doc.alloc_id(), kids); + } + node + } + Kind::Table(t) => { + let mut kids = self.table(t, to_doc); + if kids.is_empty() && children.is_empty() { + return None; + } + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.name.get_or_insert_with(|| "Table".into()); + g + } + Kind::Image(img) => match self.image(img, to_doc) { + Some(kind) => self.base(n, kind), + None if children.is_empty() => return None, + None => self.base(n, NodeKind::Group { children, clip: false }), + }, + // Shown on the pages through the master's layer (see `master_pages`). + Kind::MasterInstance { .. } => return None, + Kind::Unsupported if children.is_empty() => return None, + Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), + }; + if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { + self.warn("outline effects on groups, images and tables (left out)"); + } + if let Some(img) = &n.pixel_mask { + // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing + // shows outside it, as an opacity mask that clips. + if let Some(kind) = self.image(img, to_doc) { + let art = Node::new(self.doc.alloc_id(), kind); + out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + } + } + if let Some(mask) = &n.mask { + // A vector mask: the node shows only inside it. + let clip = Node::new( + self.doc.alloc_id(), + NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, + ); + let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone()); + out.opacity = 1.0; + out.blend = BlendMode::Normal; + let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true }); + (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); + out = g; + } + // Shadows and glows apply to what the node shows, mask and all. + let fx = self.effects(&n.effects); + if !fx.is_empty() { + out.appearance.effects.extend(fx); + } + Some(Arc::new(out)) + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs new file mode 100644 index 000000000..a60911e0d --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -0,0 +1,387 @@ +use super::layout::png; +use super::nodes::affine; +use super::text::Placement; +use super::*; + +impl Builder { + /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's + /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle + /// is the direction the shadow goes in. + pub(super) fn effects(&mut self, effects: &[af::Effect]) -> Vec { + let k = self.k; + let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = Vec::new(); + for e in effects { + let (id, params) = match e { + af::Effect::DropShadow { color, opacity, radius, distance, angle } => { + let (color, alpha) = Self::color(*color); + let d = len(*distance); + let a = if angle.is_finite() { *angle } else { 0.0 }; + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "x": num(d * a.cos()), + "y": num(d * a.sin()), + "blur": num(len(*radius)), + "color": color, + }); + ("stylize.dropShadow", p) + } + af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { + self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); + let (color, alpha) = Self::color(*color); + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "blur": num(len(*radius) + len(*distance)), + "color": color, + "source": "edge", + }); + ("stylize.innerGlow", p) + } + af::Effect::OuterGlow { color, opacity, radius } => { + let (color, alpha) = Self::color(*color); + let p = + json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); + ("stylize.outerGlow", p) + } + af::Effect::Bevel { .. } => { + self.warn("bevel and emboss effects (left out)"); + continue; + } + // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked + // into the paint (`overlaid`). + af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + out + } + + /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own + /// strokes included: an outline outside goes under them (from the path out to as far past them as + /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into + /// the shape as out of it here, which only differs by the width of the shape's own stroke. + pub(super) fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { + // How far the shape's own strokes reach outside its path. + let reach = appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Stroke(s) => Some(match s.align { + StrokeAlign::Center => s.width / 2.0, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + }), + _ => None, + }) + .filter(|w| w.is_finite()) + .fold(0.0, f64::max); + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + let (align, w) = match align { + 1 => (StrokeAlign::Inside, w), + 2 => (StrokeAlign::Center, w + 2.0 * reach), + _ => (StrokeAlign::Outside, w + reach), + }; + let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); + l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; + l.join = LineJoin::Round; + l.align = align; + let item = AppearanceItem::Stroke(l); + if align == StrokeAlign::Outside { + // Under the shape's strokes, over its fills. + let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); + appearance.items.insert(at, item); + } else { + appearance.items.push(item); + } + } + } + + /// An outline effect on text: the characters' stroke, where they have none of their own. + pub(super) fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + if opacity * f64::from(alpha) < 0.999 { + self.warn("semi-transparent outline effects on text (imported opaque)"); + } + if *align != 2 { + self.warn("outline effects on text inside or outside the characters (centred on them)"); + } + for r in &mut text.runs { + if r.style.stroke == Paint::None { + r.style.stroke = Paint::solid(c); + // Centred on the outline, so twice as wide to show as far outside. + r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); + } + } + } + } + + pub(super) fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { + let mut bp = BezPath::new(); + for s in &p.subpaths { + let pt = |q: af::Point| Point::new(q.x, q.y); + bp.move_to(pt(s.start)); + for [c1, c2, e] in &s.segments { + if c1 == c2 && c2 == e { + bp.line_to(pt(*e)); + } else { + bp.curve_to(pt(*c1), pt(*c2), pt(*e)); + } + } + if s.closed { + bp.close_path(); + } + } + PathData::from_bezpath(&(to_doc * bp)) + } + + fn color(c: ap::Color) -> (Color, f32) { + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; + match c { + ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), + ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), + ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)), + ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)), + } + } + + /// A paint and the opacity its colour's alpha becomes. + pub(super) fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> { + match p { + ap::Paint::None => None, + ap::Paint::Solid(c) => { + let (color, a) = Self::color(*c); + Some((Paint::solid(color), a)) + } + ap::Paint::Gradient(g) => { + let stops = g + .stops + .iter() + .map(|s| { + let (color, opacity) = Self::color(s.color); + let mut stop = GradientStop::new(s.offset as f32, color); + stop.opacity = opacity; + stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32; + stop + }) + .collect(); + let m = to_doc * affine(g.transform); + let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0)); + let kind = match g.kind { + AfKind::Linear => GradientKind::Linear, + AfKind::Radial => GradientKind::Radial, + AfKind::Conical => { + self.warn("conical gradients import as radial gradients"); + GradientKind::Radial + } + }; + let (u, v): (Vec2, Vec2) = (x - o, y - o); + if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { + self.warn("skewed elliptical gradients import as upright ellipses"); + } + let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; + let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; + let mut gp = GradientPaint::new(Gradient::new(kind, stops)); + gp.angle = (-(u.y)).atan2(u.x).to_degrees(); + gp.geom = Some(geom); + Some((Paint::Gradient(Box::new(gp)), 1.0)) + } + } + } + + pub(super) fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { + let (paint, opacity) = self.paint(&s.paint, to_doc)?; + let mut l = StrokeLayer::new(paint, s.width * self.k); + l.opacity = opacity; + l.cap = match s.cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }; + l.join = match s.join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }; + l.miter_limit = s.miter_limit; + l.align = match s.align { + ap::Align::Center => StrokeAlign::Center, + ap::Align::Inside => StrokeAlign::Inside, + ap::Align::Outside => StrokeAlign::Outside, + }; + l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { + pattern: pattern.iter().map(|v| v * self.k).collect(), + offset: phase * self.k, + align_corners: false, + }); + Some(l) + } + + pub(super) fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { + let mut items = Vec::new(); + let mut above = Vec::new(); + for f in fills { + if let Some((paint, opacity)) = self.paint(f, to_doc) { + let mut l = FillLayer::new(paint); + l.opacity = opacity; + items.push(AppearanceItem::Fill(l)); + } + } + for s in strokes { + let Some(l) = self.stroke_layer(s, to_doc) else { continue }; + if s.behind { + items.insert(0, AppearanceItem::Stroke(l)); + } else { + above.push(AppearanceItem::Stroke(l)); + } + } + items.extend(above); + Appearance { items, ..Appearance::default() } + } + + /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. + pub(super) fn placement(&mut self, m: Affine, type_m: Affine) -> Option { + let [a, b, c, d, e, f] = m.as_coeffs(); + let sx = a.hypot(b); + let det = a * d - b * c; + if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { + self.warn("text with an invalid transform (left out)"); + return None; + } + let sy = det / sx; + let shear = (a * c + b * d) / (sx * sx); + if shear.abs() > 1e-3 || sy < 0.0 { + self.warn("skewed or flipped text (imported without the skew or flip)"); + } + // The type's vertical scale gives the font size, its horizontal scale relative to that the + // characters' width. + let [ta, tb, tc, td, ..] = type_m.as_coeffs(); + let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); + let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { + let tsy = (tdet / tsx).abs(); + (tsy, tsx / tsy) + } else { + self.warn("text with an invalid text scale (imported unscaled)"); + (det.abs().sqrt(), 1.0) + }; + Some(Placement { + to_frame: Affine::scale_non_uniform(sx, sy.abs()), + place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), + size_scale, + h_scale, + }) + } + + /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it + /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its + /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); + /// with transparency, RGBA through the working CMYK profile. + fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { + let n = (width as usize).checked_mul(height as usize)?; + let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; + if let Some(lut) = self.document_cmyk() { + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + return png(width, height, rgba); + } + if px.iter().all(|p| p[4] == 255) { + let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); + if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { + return Some(Arc::unwrap_or_clone(blob.bytes)); + } + } + self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); + let lut = self + .working_cmyk + .get_or_insert_with(|| { + Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { + let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); + [r, g, b, 0.0] + })) + }) + .clone(); + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + png(width, height, rgba) + } + + /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, + /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when + /// the document embeds none, or not one that converts. + fn document_cmyk(&mut self) -> Option> { + use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; + if let Some(lut) = &self.cmyk_lut { + return lut.clone(); + } + let embedded = self.cmyk_profile.is_some(); + let lut = self.cmyk_profile.take().and_then(|bytes| { + let profile = IccProfile::from_bytes(None, &bytes).ok()?; + let srgb = cms::icc::builtin_rgb(cms::SRGB)?; + profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; + Some(Arc::new(CmykLut::build(|inks| { + let [r, g, b] = profile + .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) + .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); + [r, g, b, 0.0] + }))) + }); + if embedded && lut.is_none() { + self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); + } + self.cmyk_lut = Some(lut.clone()); + lut + } + + pub(super) fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { + let bytes = match &img.pixels { + Pixels::Encoded(b) => b.clone(), + Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, + Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, + }; + let r = match crate::cmd::fileio::load::raster_image(&bytes) { + Ok(r) => r, + Err(_) => { + self.warn("an embedded image that could not be decoded was left out"); + return None; + } + }; + // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. + let sx = img.width as f64 / f64::from(r.width.max(1)); + let sy = img.height as f64 / f64::from(r.height.max(1)); + let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); + self.doc.images.entry(r.key.clone()).or_insert(r.blob); + Some(NodeKind::Image(Box::new(ImageObject { + key: r.key, + width: r.width, + height: r.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + }))) + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/text.rs b/crates/engine/src/cmd/fileio/affinity/text.rs new file mode 100644 index 000000000..cae76dc7b --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/text.rs @@ -0,0 +1,326 @@ +use super::layout::{align_lines, fit_lines, font_style, justify, line_gap, place_cell}; +use super::nodes::affine; +use super::*; + +/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node +/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. +pub(super) struct Placement { + /// Node space to the frame's space, before `place`. + pub(super) to_frame: Affine, + pub(super) place: Affine, + /// Points per node unit of type size. + pub(super) size_scale: f64, + /// Horizontal scale of the type relative to `size_scale`. + pub(super) h_scale: f64, +} + +impl Builder { + /// Text → its object and the opacity its characters' shared transparency gives the node. + pub(super) fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { + let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; + let alpha = self.text_alpha(&t.runs); + let mut obj = self.type_object(&t.runs, &p)?; + obj.para.justify = justify(t.align); + if let Some(first) = obj.paras.first_mut() { + first.justify = obj.para.justify; + } + match t.frame { + Some(f) => { + let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); + let r = Rect::from_points(a, b); + obj.xf = p.place * Affine::translate(r.origin().to_vec2()); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; + // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. + obj.area.first_baseline = FirstBaseline::CapHeight; + if fit_lines(&mut obj, r.width(), r.height()) { + self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); + } + // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. + if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { + self.warn("text frames too short for their first line (made taller so the line shows)"); + } + } + None => { + align_lines(&mut obj); + obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); + } + } + Some((obj, alpha)) + } + + /// The opacity text takes from its characters' colour: VectorCraft's characters have none of + /// their own, so text whose characters share one becomes an object with that opacity. + fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { + let alphas: Vec = + runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); + let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); + if alphas.is_empty() || lo >= 1.0 { + return 1.0; + } + if hi - lo > 0.01 { + self.warn("text with characters of different transparency (imported opaque)"); + return 1.0; + } + lo.clamp(0.0, 1.0) as f32 + } + + /// The characters and paragraphs of `runs` as a text object at the origin of text space. + fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { + let (v, h) = (p.size_scale, p.size_scale * p.h_scale); + let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); + let mut formats: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + let mut paragraph = 0usize; + // Whether each paragraph here continues the one before after a line break (U+2028): + // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. + let mut continues = vec![false]; + for r in runs { + for c in r.text.chars() { + match c { + '\u{2028}' => continues.push(true), + '\u{2029}' | '\r' | '\n' => continues.push(false), + _ => {} + } + } + // Affinity separates paragraphs with U+2029 and lines with U+2028; other control + // characters don't show. + let text: String = r + .text + .chars() + .filter_map(|c| match c { + '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), + '\t' => Some('\t'), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if text.is_empty() { + continue; + } + for c in text.chars() { + if formats.len() <= paragraph { + formats.push(r.paragraph); + } + if c == '\n' { + paragraph += 1; + } + } + let mut style = self.char_style(r, (v, p.h_scale)); + if style.leading.is_none() { + style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); + } + out.push(TextRun::new(text, style)); + } + // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. + if let Some(last) = out.last_mut() + && last.text.ends_with('\n') + { + last.text.pop(); + if last.text.is_empty() { + out.pop(); + } + } + obj.runs = out; + let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; + let mut paras = Vec::with_capacity(count); + let mut previous_after = 0.0; + // The run of each paragraph's first character. + let mut first_runs = Vec::with_capacity(count); + { + let mut at_start = true; + for (i, r) in obj.runs.iter().enumerate() { + for c in r.text.chars() { + if at_start { + first_runs.push(i); + at_start = false; + } + if c == '\n' { + at_start = true; + } + } + } + } + // Line gaps by font, each looked up once. + let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); + for i in 0..count { + let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); + let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; + let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; + // Affinity places the first line and the others from the left edge; VectorCraft's + // first-line indent is relative to its left indent. + ps.left_indent = pt(f.left, h); + ps.first_line_indent = pt(f.first - f.left, h); + ps.right_indent = pt(f.right, h); + // Between two paragraphs Affinity keeps the larger of the space after the first and + // before the second; VectorCraft adds them. + let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); + // The lines of one Affinity paragraph: no space between them, and the first-line + // indent only on its first. + if continues.get(i).copied().unwrap_or(false) { + before = 0.0; + ps.first_line_indent = 0.0; + } + if continues.get(i + 1).copied().unwrap_or(false) { + after = 0.0; + } + ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; + ps.space_after = after; + previous_after = after; + // Automatic leading: the font size plus the font's line gap, times the paragraph's + // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas + // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, + // and the public Arial frame fixture fits this better than the font's line height. + let (family, style) = + first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); + let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); + let ratio = (1.0 + gap) * f.auto_leading; + // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft + // takes 0–500 %. + if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { + ps.justification.auto_leading = ratio * 100.0; + } + paras.push(ps); + } + if let Some(first) = paras.first() { + obj.para = first.clone(); + } + obj.paras = paras; + obj.normalize_paras(); + Some(obj) + } + + /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and + /// its horizontal scale relative to that. + fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { + let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; + let mut style = CharStyle::default(); + if !r.family.is_empty() { + style.font_family = r.family.clone(); + } else if !r.postscript.is_empty() { + style.font_family = r.postscript.clone(); + } else { + self.warn("text without a font name uses the default font"); + } + // A family/weight pair cannot identify width variants such as Arial Narrow. + // Resolve a known PostScript identity to the exact installed family and style. + if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { + style.font_family = family; + style.font_style = face_style; + } else { + style.font_style = font_style(r.weight, r.italic); + } + style.features = r.features.clone(); + if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { + self.warn("unsupported text OpenType features (retained but not rendered)"); + } + style.all_caps = r.all_caps; + style.underline = r.underline; + style.strikethrough = r.strikethrough; + style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); + style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); + style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); + // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of + // its own. + let hs = h_scale * r.h_scale; + if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { + style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); + } + // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter + // for a solid colour, and gradients on type are laid out in text space. + style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { + Some((p, _)) => p, + None => Paint::None, + }; + if let Some(st) = &r.stroke { + let w = st.width * size_scale; + if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) + && w.is_finite() + && w > 0.0 + { + style.stroke = paint; + style.stroke_width = w.min(1000.0); + } + } + style + } + + /// A table: its cells as area type and the lines on their edges, as VectorCraft has no + /// tables. + pub(super) fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { + let mut out = Vec::new(); + let (ncols, nrows) = (t.ncols(), t.nrows()); + if ncols == 0 || nrows == 0 { + return out; + } + self.warn("tables (imported as text frames and lines)"); + let m = to_doc * affine(t.transform); + let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; + let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); + let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); + for (i, cell) in t.cells.iter().enumerate() { + if cell.merged_left || cell.runs.is_empty() { + continue; + } + let (r, c) = (i / ncols, i % ncols); + // Cells to the right merged into this one widen it. + let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); + let [l, tp, rt, bt] = cell.inset; + let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; + let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); + let rect = Rect::from_points(a, b); + if !(rect.width() > 0.0 && rect.height() > 0.0) { + continue; + } + let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; + obj.area.vertical_align = match cell.valign { + af::VAlign::Top => VerticalAlign::Top, + af::VAlign::Center => VerticalAlign::Center, + af::VAlign::Bottom => VerticalAlign::Bottom, + }; + obj.area.first_baseline = FirstBaseline::CapHeight; + let dy = place_cell(&mut obj, rect.width(), rect.height()); + obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); + // A cell's text never vanishes for want of a line's height. + vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); + let alpha = self.text_alpha(&cell.runs); + let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); + n.opacity = alpha; + out.push(Arc::new(n)); + } + // Lines over the cells: the vertical edges row by row (none inside merged cells), then + // the horizontal ones. + let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { + let Some(s) = s else { return }; + let Some(layer) = b.stroke_layer(s, to_doc) else { return }; + let mut bp = BezPath::new(); + bp.move_to(m * Point::new(from.0, from.1)); + bp.line_to(m * Point::new(to.0, to.1)); + let mut n = Node::new( + b.doc.alloc_id(), + NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, + ); + n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); + out.push(Arc::new(n)); + }; + for r in 0..nrows { + for k in 0..=ncols { + let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); + if merged { + continue; + } + if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { + line(self, s, (col(k), row(r)), (col(k), row(r + 1))); + } + } + } + for j in 0..=nrows { + for k in 0..ncols { + if let Some(s) = t.horizontal.get(j * ncols + k) { + line(self, s, (col(k), row(j)), (col(k + 1), row(j))); + } + } + } + out + } +} diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index cf760ddda..ff8b8aece 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -1,6 +1,9 @@ //! `command.batch`: several commands as one undo step (in each document they edit). +use std::sync::Arc; + use serde_json::{Value, json}; +use vectorcraft_doc::Document; use super::super::*; use crate::{DocState, EngineError}; @@ -23,10 +26,14 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let start = s.docs.clone(); let active = s.active; s.batch_stash = Some(vec![]); - let r = run_steps(s, &label, artboard, &mut cmds); + let mut grouped = Vec::with_capacity(cmds.len()); + let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped); let stash = s.batch_stash.take().unwrap_or_default(); let results = match r { - Ok(results) => results, + Ok(results) => { + s.batch_steps = Some(grouped); + results + } Err(e) => { roll_back(s, start, &stash); s.active = active; @@ -57,7 +64,14 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { /// `artboard`, as the Artboards panel shows it in the document the step runs in: past that /// document's last artboard, the last one. A step that resizes the active artboard /// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`. -fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Value]) -> Result> { +/// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them. +fn run_steps( + s: &mut Session, + label: &str, + artboard: Option, + cmds: &mut [Value], + grouped: &mut Vec<(Vec, Option)>, +) -> Result> { let mut results = Vec::with_capacity(cmds.len()); for c in cmds { let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string(); @@ -81,8 +95,12 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Va { o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last)))); } + // The documents with an undo group open, held so that the step's first edit in one copies it. + let start = s.active().map(|d| d.uid); + let held: Vec<(u64, Arc)> = s.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, st.doc.clone())).collect(); let (v, noted) = s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?; + grouped.push(step_groups(s, start, &held)); // The step's journal entry records what it took from the preferences or the clock (a // save's date, a new document's), as a command of its own would. if noted != params @@ -95,6 +113,30 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Va Ok(results) } +/// The undo groups a step belongs to, as [`Session::execute`] notes a command's journal entry in +/// them: the group of each document in `held` (the grouped documents before the step) that the step +/// changed, each group it opened and edited, and the group of the document with [`DocState::uid`] +/// `start` when it started there and ended there. With the index of the document it left active +/// when it changed the active document. +fn step_groups(s: &Session, start: Option, held: &[(u64, Arc)]) -> (Vec, Option) { + let end = s.active().map(|d| d.uid); + let uids = s + .docs + .iter() + .filter(|st| { + st.undo_group.as_ref().is_some_and(|g| { + let edited = match held.iter().find(|(uid, _)| *uid == st.uid) { + Some((_, d)) => !Arc::ptr_eq(d, &st.doc), + None => g.first.is_some(), + }; + edited || (start == Some(st.uid) && end == start) + }) + }) + .map(|st| st.uid) + .collect(); + (uids, if end == start { None } else { s.active }) +} + /// The documents as they were before the batch (`start`), whatever the steps did to them: opened /// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection, /// history, file, format and saved state again (#1697: undoing the batch's interaction alone left diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs new file mode 100644 index 000000000..0a0a8549f --- /dev/null +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -0,0 +1,146 @@ +use super::access::file_list; +use super::formats::formats; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "document.open", + "Open Document", + [], + None, + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + always, + load::open + ), + cmd!( + query "file.list", + "List Folder", + [], + None, + "{path} the files and folders in folder `path`, by name: what an agent may open there (held to the automation read root when one is in force) → {path, entries: [{name, kind: file|folder|link, size? (files, bytes)}], truncated (more than 5000 entries)} (#1163)", + always, + file_list + ), + cmd!( + query "document.serialize", + "Serialize Document", + [], + None, + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + has_doc, + export::serialize + ), + cmd!( + "document.export", + "Export Document", + [], + None, + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + has_doc, + export::export + ), + cmd!( + "document.exportSelection", + "Export Selection…", + ["File"], + None, + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export", + has_selection, + export::export_selection + ), + cmd!( + "document.exportForScreens", + "Export for Screens", + ["File", "Export"], + None, + "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,
, , and ; a trailing .ext is dropped, as in `COMPANY-Logo--####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)", - has_doc, - screens::export_for_screens - ), - cmd!( - query "document.exportPresets.list", - "Export Presets", - [], - None, - "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset", - always, - screens::presets_list - ), - cmd!( - query "document.exportPresets.save", - "Save Export Preset", - [], - None, - "{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}", - always, - screens::presets_save - ), - cmd!( - query "document.exportPresets.delete", - "Delete Export Preset", - [], - None, - "{name} delete a saved batch export preset (built-in ones stay) → {deleted}", - always, - screens::presets_delete - ), - cmd!( - query "command.batch", - "Batch", - [], - None, - "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", - has_doc, - batch::batch - ), - cmd!( - "action.batch", - "Batch…", - [], - None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", - always, - action_batch::action_batch - ), - cmd!( - query "document.formats", - "File Formats", - [], - None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", - always, - formats - ), - cmd!( - query "document.pdfInfo", - "PDF Info", - [], - None, - "{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)", - always, - pdfimport::pdf_info - ), - cmd!( - "document.exportForOffice", - "Save for Office Documents…", - ["File"], - None, - "{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)", - has_doc, - export::export_for_office - ), - cmd!( - query "document.exportSettings", - "Export for Screens Settings", - [], - None, - "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)", - has_doc, - screens::export_settings - ), - ] - .into_iter() - .chain(save::specs()) - .collect() -} - -/// One option a format's encoder reads from the export params. -#[derive(Clone, Copy, Debug)] -pub struct FormatOption { - pub name: &'static str, - /// JSON type: `number`, `integer`, `boolean`, `string`, `array` or `object`. - pub ty: &'static str, - /// The default as a JSON literal (`"1"`, `"false"`, `"null"`). - pub default: &'static str, - pub description: &'static str, -} - -/// One file format: how `document.open` recognises it and what `document.export` writes. -#[derive(Clone, Copy, Debug)] -pub struct Format { - /// The `format` param value (`svg`, `png`, …). - pub id: &'static str, - /// Open/save dialog filter name. - pub label: &'static str, - /// Lower-case extensions without the dot; the first is the one exports use. - pub extensions: &'static [&'static str], - pub mime: &'static str, - pub read: bool, - pub write: bool, - /// Pixels (export `scale` applies) rather than vectors. - pub raster: bool, - /// Options the encoder reads (writable formats). - pub options: &'static [FormatOption], -} - -impl Format { - pub fn to_json(&self) -> Value { - let options: serde_json::Map = self - .options - .iter() - .map(|o| { - let default: Value = serde_json::from_str(o.default).unwrap_or(Value::Null); - (o.name.to_string(), json!({"type": o.ty, "default": default, "description": o.description})) - }) - .collect(); - json!({ - "id": self.id, - "label": self.label, - "extensions": self.extensions, - "mime": self.mime, - "read": self.read, - "write": self.write, - "raster": self.raster, - "options": options, - }) - } -} - -const ARTBOARD: FormatOption = FormatOption { - name: "artboard", - ty: "integer", - default: "0", - description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)", -}; -const ARTBOARDS: FormatOption = FormatOption { - name: "artboards", - ty: "array", - default: "null", - description: "0-based artboards (PDF and AI: one page each, default all; SVG, and raster formats with useArtboards: one file each)", -}; -const RANGE: FormatOption = FormatOption { - name: "range", - ty: "string", - default: "null", - description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)", -}; -const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" }; -const QUALITY: FormatOption = - FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" }; -const USE_ARTBOARDS: FormatOption = FormatOption { - name: "useArtboards", - ty: "boolean", - default: "null", - description: "true: every chosen artboard (default all), one file each named -. (PDF and AI: one page each); false: the bounds of the visible art", -}; -const RECT: FormatOption = FormatOption { - name: "rect", - ty: "array", - default: "null", - description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards", -}; -const PPI: FormatOption = FormatOption { - name: "ppi", - ty: "number", - default: "72", - description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file; wins over scale", -}; -const BACKGROUND: FormatOption = FormatOption { - name: "background", - ty: "string", - default: "\"transparent\"", - description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)", -}; -const ANTI_ALIAS: FormatOption = FormatOption { - name: "antiAlias", - ty: "string", - default: "\"art\"", - description: "none (hard pixel edges), art (smooth edges) or type (smooth, text snapped to whole pixels)", -}; -const INTERLACED: FormatOption = - FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" }; -const COLOR_MODEL: FormatOption = FormatOption { - name: "colorModel", - ty: "string", - default: "\"rgb\"", - description: "rgb, cmyk (ink amounts in the working CMYK space: CMYK colours keep their inks) or gray", -}; -const METHOD: FormatOption = FormatOption { - name: "method", - ty: "string", - default: "\"baseline\"", - description: "baseline (standard), optimized (smaller Huffman tables) or progressive (builds up in scans)", -}; -const SCANS: FormatOption = FormatOption { name: "scans", ty: "integer", default: "3", description: "progressive scans, 3–5" }; -const EMBED_ICC: FormatOption = FormatOption { - name: "embedIcc", - ty: "boolean", - default: "true", - description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)", -}; -const IMAGE_MAP: FormatOption = FormatOption { - name: "imageMap", - ty: "string", - default: "\"none\"", - description: "none, client (an HTML page with , .html) or server (an NCSA .map): the areas of objects with a URL and an Image Map shape (attributes.set)", -}; - -const COLORS: FormatOption = - FormatOption { name: "colors", ty: "integer", default: "256", description: "most palette entries, 2–256 (the transparent one included)" }; -const REDUCTION: FormatOption = FormatOption { - name: "reduction", - ty: "string", - default: "\"selective\"", - description: "the palette: perceptual, selective (also keeps rarer colours, snaps near web colours), adaptive (most used), web (web-safe), blackWhite or gray; art with that many colours or fewer keeps them exactly", -}; -const DITHER: FormatOption = FormatOption { - name: "dither", - ty: "string", - default: "\"diffusion\"", - description: "none, diffusion (Floyd–Steinberg), pattern (8×8 ordered) or noise", -}; -const DITHER_AMOUNT: FormatOption = FormatOption { name: "ditherAmount", ty: "integer", default: "100", description: "dither strength 0–100" }; -const TRANSPARENCY: FormatOption = FormatOption { - name: "transparency", - ty: "boolean", - default: "true", - description: "pixels under half opacity become one transparent entry (false: everything is blended over the matte)", -}; -const MATTE: FormatOption = FormatOption { - name: "matte", - ty: "string", - default: "\"white\"", - description: "the colour partly transparent edges are blended over (\"#rrggbb\", white, black), or none (they keep their colour)", -}; -const LOSSLESS: FormatOption = FormatOption { - name: "lossless", - ty: "boolean", - default: "true", - description: "lossless WebP; false asks for lossy, which isn't available yet (written lossless, with a warning)", -}; -const WEBP_QUALITY: FormatOption = - FormatOption { name: "quality", ty: "integer", default: "90", description: "lossy quality 0–100 (for when lossy WebP is available)" }; -/// The options of the palette formats (PNG-8, GIF). -const PALETTE_OPTIONS: &[FormatOption] = &[ - ARTBOARD, - ARTBOARDS, - RANGE, - USE_ARTBOARDS, - RECT, - PPI, - SCALE, - BACKGROUND, - ANTI_ALIAS, - INTERLACED, - COLORS, - REDUCTION, - DITHER, - DITHER_AMOUNT, - TRANSPARENCY, - MATTE, -]; - -const LZW: FormatOption = FormatOption { name: "lzw", ty: "boolean", default: "true", description: "LZW compression (lossless, smaller files)" }; -const BYTE_ORDER: FormatOption = FormatOption { - name: "byteOrder", - ty: "string", - default: "\"little\"", - description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)", -}; -const TIFF_OPTIONS: &[FormatOption] = - &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; -const BMP_COLOR_MODEL: FormatOption = - FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" }; -const BMP_DEPTH: FormatOption = FormatOption { - name: "depth", - ty: "integer", - default: "24", - description: "bits per pixel: 1 (black and white), 4 or 8 (a palette by reduction and dither), 16, 24, or 32 (keeps transparency); the others are flattened on white", -}; -const FILE_FORMAT: FormatOption = FormatOption { - name: "fileFormat", - ty: "string", - default: "\"windows\"", - description: "windows, or os2 (1, 4, 8 or 24 bits, uncompressed, rows bottom-up)", -}; -const RLE: FormatOption = - FormatOption { name: "rle", ty: "boolean", default: "false", description: "run-length encode a 4- or 8-bit windows bitmap (RLE4, RLE8)" }; -const FLIP_ROWS: FormatOption = FormatOption { - name: "flipRows", - ty: "boolean", - default: "false", - description: "rows top-down (a negative height) instead of bottom-up; not with rle or os2", -}; -const BMP_OPTIONS: &[FormatOption] = &[ - ARTBOARD, - ARTBOARDS, - RANGE, - USE_ARTBOARDS, - RECT, - PPI, - SCALE, - BACKGROUND, - ANTI_ALIAS, - BMP_COLOR_MODEL, - BMP_DEPTH, - FILE_FORMAT, - RLE, - FLIP_ROWS, - REDUCTION, - DITHER, - DITHER_AMOUNT, -]; -const TGA_DEPTH: FormatOption = FormatOption { - name: "depth", - ty: "integer", - default: "24", - description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)", -}; -const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH]; -const PSD_LAYERS: FormatOption = FormatOption { - name: "layers", - ty: "boolean", - default: "true", - description: "write layers: each top-level layer a pixel layer with its opacity and blend mode (a background colour is a Background layer); false: one flat image, on white where transparent", -}; -const MAX_EDITABILITY: FormatOption = FormatOption { - name: "maxEditability", - ty: "boolean", - default: "false", - description: "with layers: layers and sublayers become groups and every object a layer of its own, named as the Layers panel names it (text by its text); clipping, masked or knockout layers stay one layer", -}; -const PSD_HIDDEN_LAYERS: FormatOption = FormatOption { - name: "hiddenLayers", - ty: "boolean", - default: "false", - description: "with layers: hidden layers (and hidden objects) are written as hidden layers instead of left out", -}; -const PSD_OPTIONS: &[FormatOption] = &[ - ARTBOARD, - ARTBOARDS, - RANGE, - USE_ARTBOARDS, - RECT, - PPI, - SCALE, - BACKGROUND, - ANTI_ALIAS, - COLOR_MODEL, - PSD_LAYERS, - MAX_EDITABILITY, - PSD_HIDDEN_LAYERS, - EMBED_ICC, -]; - -/// A format `document.open` reads but nothing writes yet. -const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format { - Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] } -} - -/// Every format VectorCraft reads or writes. Append-only: new formats go at the end. -pub const FORMATS: &[Format] = &[ - Format { - id: "vectorcraft", - label: "VectorCraft", - extensions: &[vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION], - mime: "application/json", - read: true, - write: true, - raster: false, - options: native::OPTIONS, - }, - Format { id: "svg", label: "SVG", extensions: &["svg"], mime: "image/svg+xml", read: true, write: true, raster: false, options: svg::OPTIONS }, - Format { - id: "svgz", - label: "SVG Compressed", - extensions: &["svgz"], - mime: "image/svg+xml", - read: true, - write: true, - raster: false, - options: svg::OPTIONS, - }, - Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS }, - Format { - id: "ai", - label: "PDF-compatible .ai", - extensions: &["ai"], - mime: "application/pdf", - read: true, - // Save As writes it; exports write the same file. - write: true, - raster: false, - options: native::AI_OPTIONS, - }, - Format { - id: "ait", - label: "PDF-compatible .ait template", - extensions: &["ait"], - mime: "application/pdf", - read: true, - // Save as Template writes it, with the .ai file's options; exports don't. - write: false, - raster: false, - options: native::AI_OPTIONS, - }, - Format { - id: "png", - label: "PNG", - extensions: &["png"], - mime: "image/png", - read: true, - write: true, - raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], - }, - Format { - id: "jpg", - label: "JPEG", - extensions: &["jpg", "jpeg"], - mime: "image/jpeg", - read: true, - write: true, - raster: true, - options: &[ - ARTBOARD, - ARTBOARDS, - RANGE, - USE_ARTBOARDS, - RECT, - PPI, - SCALE, - BACKGROUND, - ANTI_ALIAS, - QUALITY, - COLOR_MODEL, - METHOD, - SCANS, - EMBED_ICC, - IMAGE_MAP, - ], - }, - Format { id: "gif", label: "GIF", extensions: &["gif"], mime: "image/gif", read: true, write: true, raster: true, options: PALETTE_OPTIONS }, - Format { - id: "webp", - label: "WebP", - extensions: &["webp"], - mime: "image/webp", - read: true, - write: true, - raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], - }, - Format { - id: "tiff", - label: "TIFF", - extensions: &["tif", "tiff"], - mime: "image/tiff", - read: true, - write: true, - raster: true, - options: TIFF_OPTIONS, - }, - Format { id: "bmp", label: "BMP", extensions: &["bmp"], mime: "image/bmp", read: true, write: true, raster: true, options: BMP_OPTIONS }, - Format { - id: "template", - label: "VectorCraft Template", - extensions: &["vctemplate"], - mime: "application/json", - read: true, - write: true, - raster: false, - options: native::OPTIONS, - }, - Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS }, - Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS }, - Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: true, write: true, raster: false, options: dxf::OPTIONS }, - Format { - id: "eps", - label: "EPS", - extensions: &["eps", "epsf"], - mime: "application/postscript", - read: true, - write: true, - raster: false, - options: eps::OPTIONS, - }, - Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, - Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, - Format { - id: "tga", - label: "Targa", - extensions: &["tga", "vda", "icb", "vst"], - mime: "image/x-tga", - read: true, - write: true, - raster: true, - options: TGA_OPTIONS, - }, - Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS }, - // Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a - // vector app for a raster editor's documents. - reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false), - // Photoshop's large document format: read like a PSD (its merged image), never written. - reader("psb", "PSB", &["psb"], "image/x-psb", true), - // PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't. - reader("ps", "PostScript", &["ps"], "application/postscript", false), -]; - -/// Every extension `document.open` reads (the "All readable files" filter of open dialogs). -pub const OPEN_EXTS: &[&str] = &[ - "vectorcraft", - "drawcraft", - "svg", - "svgz", - "pdf", - "ai", - "ait", - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "tga", - "vda", - "icb", - "vst", - "psd", - "psb", - "vctemplate", - "dxf", - "emf", - "wmf", - "eps", - "epsf", - "ps", - "af", - "afdesign", - "afpub", -]; - -/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to -/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no -/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop -/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft -/// for it. -pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"]; - -/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares -/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. -pub fn export_extensions() -> Vec<&'static str> { - let mut v: Vec<&'static str> = Vec::new(); - let added = plugin_formats(); - for e in FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write).filter_map(|f| f.extensions.first()) { - if !v.contains(e) { - v.push(e); - } - } - v -} - -/// Text files: File → Place sets them as area type (Text Import Options). -pub const TEXT_EXTS: &[&str] = &["txt"]; - -/// RTF files: File → Place sets them as area type with their formatting. -pub const RTF_EXTS: &[&str] = &["rtf"]; - -/// Word documents: File → Place sets them as area type with their formatting. -pub const DOCX_EXTS: &[&str] = &["docx"]; - -/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and -/// [`DOCX_EXTS`]. -pub const PLACE_EXTS: &[&str] = &[ - "vectorcraft", - "drawcraft", - "svg", - "svgz", - "pdf", - "ai", - "ait", - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "tga", - "vda", - "icb", - "vst", - "psd", - "psb", - "vctemplate", - "dxf", - "emf", - "wmf", - "eps", - "epsf", - "ps", - "af", - "afdesign", - "afpub", - "txt", - "rtf", - "docx", -]; - -/// One dialog filter per readable format. -fn format_filters() -> impl Iterator { - FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)) -} - -/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch -/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print -/// presets (imported), scripts (checked without running them) and plug-ins (installed). -pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)) - .chain(format_filters()) - .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) - .chain(std::iter::once(("Libraries", super::library::LIBRARY_EXTS))) - .chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS))) - .chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS))) - .chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS))) - .chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS))) - .chain(std::iter::once(("Scripts", super::scripts::EXTS))) - .chain(std::iter::once(("Plug-ins", super::plugin::EXTS))) -} - -/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate -/// Library…. The library formats come first so Linux file portals (which grey out files that don't -/// match the default filter) still list `.ase` and the other library extensions; then the same -/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles. -pub fn library_open_filters( - library_label: &'static str, - library_exts: &'static [&'static str], -) -> impl Iterator { - std::iter::once((library_label, library_exts)).chain(open_filters()) -} - -/// File → Place dialog filters: "All placeable files", then one per placeable format, then text, -/// RTF and Word. -pub fn place_filters() -> impl Iterator { - std::iter::once(("All placeable files", PLACE_EXTS)) - .chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext)))) - .chain(std::iter::once(("Text", TEXT_EXTS))) - .chain(std::iter::once(("RTF", RTF_EXTS))) - .chain(std::iter::once(("Word", DOCX_EXTS))) -} - -/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in -/// one, else one an exporter plug-in adds ([`plugin_formats`]). A built-in format keeps its -/// extension, so an exporter that lists it too is found by its format id. -pub fn format(id_or_ext: &str) -> Option<&'static Format> { - let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase(); - FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| { - let added = plugin_formats(); - added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied() - }) -} - -/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in -/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for -/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues -/// that hold them. -pub fn plugin_formats() -> Vec<&'static Format> { - use vectorcraft_plugins::{export, registry}; - static MADE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); - export::installed() - .iter() - .map(|p| { - if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) { - return *f; - } - let m = p.manifest(); - let extensions: Vec<&'static str> = m.extensions.iter().map(|e| registry::intern(e)).collect(); - let f: &'static Format = Box::leak(Box::new(Format { - id: registry::intern(&export::format_id(&m.id)), - label: registry::intern(&m.name), - extensions: Box::leak(extensions.into_boxed_slice()), - mime: "application/octet-stream", - read: false, - write: true, - raster: false, - options: &[], - })); - made.push((p.serial(), f)); - f - }) - .collect() -} - -/// The lower-case extension of a file name or path (empty when it has none). -pub fn extension(name: &str) -> String { - std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() -} - -/// A file name or path without its folder and extension (`/a/Poster.svg` → `Poster`). -pub fn file_stem(name: &str) -> String { - std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()) -} - -/// What a `format` export of `doc`, whose file is `path`, writes when it isn't `doc` itself: a -/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is -/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep -/// the stored title. -pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) -} - -pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option { - let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; - let mut d = doc.clone(); - d.title = file_stem(path); - Some(d) -} - -/// The format a file name's extension names. -pub fn format_for_name(name: &str) -> Option<&'static Format> { - Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e)) -} - -/// The format to write: `format` (an id or extension), else the path's extension, else PNG. For a -/// `.ait` template it returns an error that names `file.saveAsTemplate`, which writes it. -pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { - // Formats that can't be written say what to use instead. - let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string()); - let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) { - (Some(f), _) => format(f).ok_or_else(|| unknown(format!("unknown format `{f}` (see document.formats)"), f))?, - (None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?, - (None, None) => format("png").ok_or("no PNG encoder")?, - }; - if f.write { - Ok(f) - } else if TEMPLATE_FORMATS.contains(&f.id) { - Err(format!("{} files are written by file.saveAsTemplate", f.label)) - } else { - Err(format!("{} files can be opened but not written (see document.formats)", f.label)) - } -} - -/// [`writable_format`] for command `cmd`. -fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result<&'static Format> { - writable_format(format_param, path).map_err(|e| bad(cmd, e)) -} - -fn formats(_: &mut Session, _: &Value) -> Result { - let added = plugin_formats(); - let all = || FORMATS.iter().chain(added.iter().copied()); - let ids = |pick: fn(&Format) -> bool| all().filter(|f| pick(f)).map(|f| f.id).collect::>(); - let listed = |f: &Format| { - let mut v = f.to_json(); - if let Some(id) = vectorcraft_plugins::export::plugin_id(f.id) { - v["plugin"] = json!(id); - } - v - }; - Ok(json!({ - "formats": all().map(listed).collect::>(), - "readable": ids(|f| f.read), - "writable": ids(|f| f.write), - "openExtensions": OPEN_EXTS, - "unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::>(), - })) -} - -// ---------- the file system (none on the web, where commands take and return bytes) ---------- -// -// Every path a command reads or writes goes through these, which check it against the automation -// roots in force ([`crate::file_access`]): a path outside them is an error (`file_stamp` and -// `file_created`: no file), as is any path once roots are in force without its kind of access. - -/// `path` checked for reading against the automation roots in force. -#[cfg(not(target_arch = "wasm32"))] -fn may_read(path: &str) -> Result<()> { - #[cfg(test)] - touch(path); - crate::file_access::check_read(path).map_err(EngineError::Other) -} - -/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own). -#[cfg(test)] -static TOUCHED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); -#[cfg(test)] -static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - -#[cfg(test)] -fn touch(path: &str) { - if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 { - TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string()); - } -} - -/// Run `f` and list the paths read through this module meanwhile (by any test running then). -#[cfg(test)] -pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) { - use std::sync::atomic::Ordering::SeqCst; - let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len(); - RECORDING.fetch_add(1, SeqCst); - let out = f(); - RECORDING.fetch_sub(1, SeqCst); - let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec(); - (out, seen) -} - -/// `path` checked for writing against the automation roots in force. -#[cfg(not(target_arch = "wasm32"))] -fn may_write(path: &str) -> Result<()> { - crate::file_access::check_write(path).map_err(EngineError::Other) -} - -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn read_file(path: &str) -> Result> { - may_read(path)?; - // A device or pipe may never end: only a regular file is read to its end. - if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { - return Err(EngineError::Other(format!("{path}: not a file"))); - } - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) -} - -/// The first `max` bytes of the file at `path` (all of it when it is shorter). Anything but a -/// regular file (a folder, a pipe, a device) is refused, also through a link. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn read_file_head(path: &str, max: usize) -> Result> { - use std::io::Read; - may_read(path)?; - if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { - return Err(EngineError::Other(format!("{path}: not a file"))); - } - let mut out = vec![]; - std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - Ok(out) -} - -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - // Work that stops early for a cancel can leave a truncated result: none of it is written. - vectorcraft_doc::cancel::check()?; - may_write(path)?; - // Never a half-written file: see [`write_atomic`]. - write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) -} - -/// [`write_file`] with what `fill` writes into the file (a save streamed into it). -#[cfg(not(target_arch = "wasm32"))] -pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { - vectorcraft_doc::cancel::check()?; - may_write(path)?; - vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) -} - -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn create_dir(path: &str) -> Result<()> { - may_write(path)?; - std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) -} - -/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never -/// replaced. When the write fails part way, the file it started is removed. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> { - use std::io::Write as _; - may_write(path)?; - let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?; - let written = f.write_all(bytes).and_then(|()| vectorcraft_format::sync_file(&f)); - drop(f); - written.map_err(|e| { - // Best effort: the partly written file is all there is to clean up. - let _ = std::fs::remove_file(path); - err(e) - }) -} - -/// The highest number [`copy_new`] gives a copy's name (`Name 99.png`). -#[cfg(not(target_arch = "wasm32"))] -const MAX_COPY_NUMBER: u32 = 99; - -/// Where [`copy_new`] put a file. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) enum Placed { - /// Copied to this new file. - Copied(std::path::PathBuf), - /// Kept as this file, which holds the same contents. - Kept(std::path::PathBuf), -} - -/// Put a copy of the file `from` into the folder `dir`, never replacing a file there. It tries -/// `Name.ext`, then `Name 2.ext` up to `Name 99.ext`, and stops at the first name that is free, -/// where `create` makes the copy, or that is a file holding the same contents (`same`, given that -/// file and its metadata), which is kept. When `create` fails with `AlreadyExists` (the name was -/// taken meanwhile), it tries the next name. `None` when no name is free or holds the same -/// contents; a name whose metadata can't be read counts as taken. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn copy_new( - from: &std::path::Path, - dir: &std::path::Path, - create: impl Fn(&std::path::Path) -> std::io::Result<()>, - same: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool, -) -> std::io::Result> { - use std::io::ErrorKind; - let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(ErrorKind::InvalidInput.into()) }; - let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); - for n in 1..=MAX_COPY_NUMBER { - let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) }; - match std::fs::symlink_metadata(&to) { - Err(e) if e.kind() == ErrorKind::NotFound => match create(&to) { - Ok(()) => return Ok(Some(Placed::Copied(to))), - // Taken meanwhile: the next name. - Err(e) if e.kind() == ErrorKind::AlreadyExists => {} - Err(e) => return Err(e), - }, - Ok(m) if m.is_file() && same(&to, &m) => return Ok(Some(Placed::Kept(to))), - _ => {} - } - } - Ok(None) -} - -/// The most entries `file.list` returns. -const LIST_MAX: usize = 5000; - -fn file_list(_: &mut Session, p: &Value) -> Result { - let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad("file.list", "missing `path`"))?; - let (entries, truncated) = list_dir(path, LIST_MAX)?; - Ok(json!({ "path": absolute_path(path), "entries": entries, "truncated": truncated })) -} - -/// The entries of folder `path`, sorted by name: at most `max`, and whether there were more. A -/// link isn't followed: its target may lie outside the automation roots. -#[cfg(not(target_arch = "wasm32"))] -fn list_dir(path: &str, max: usize) -> Result<(Vec, bool)> { - may_read(path)?; - let dir = std::fs::read_dir(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let mut entries: Vec<(String, Value)> = vec![]; - let mut truncated = false; - for e in dir.flatten() { - if entries.len() == max { - truncated = true; - break; - } - let name = e.file_name().to_string_lossy().into_owned(); - let Ok(t) = e.file_type() else { continue }; - let entry = if t.is_symlink() { - json!({ "name": name, "kind": "link" }) - } else if t.is_dir() { - json!({ "name": name, "kind": "folder" }) - } else { - json!({ "name": name, "kind": "file", "size": e.metadata().map(|m| m.len()).ok() }) - }; - entries.push((name, entry)); - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - Ok((entries.into_iter().map(|(_, v)| v).collect(), truncated)) -} - -#[cfg(target_arch = "wasm32")] -fn list_dir(_: &str, _: usize) -> Result<(Vec, bool)> { - Err(EngineError::Other("no file system here".into())) -} - -/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system -/// keeps one); `None` when there is no file at `path` (or automation may not read it). -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option)> { - let m = file_metadata(path).filter(std::fs::Metadata::is_file)?; - let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); - Some((m.len(), modified)) -} - -/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it -/// (and automation may read it). -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn file_created(path: &str) -> Option { - let t = file_metadata(path)?.created().ok()?; - t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64) -} - -/// The file system's metadata of `path`, following links (when automation may read it). -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn file_metadata(path: &str) -> Option { - may_read(path).ok()?; - std::fs::metadata(path).ok() -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn file_created(_: &str) -> Option { - None -} - -/// `path` made absolute against the working directory (as given when absolute already, or when -/// that fails). -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn absolute_path(path: &str) -> String { - if std::path::Path::new(path).is_absolute() { - return path.to_string(); - } - std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) -} - -/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an -/// SVG image's href), or why it is not followed. `document` is the path of the document it is -/// stored in, when it has one: a relative path is taken from its folder. Only files on this -/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a -/// path that is still relative (no document) is refused, as the working directory isn't the -/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive -/// ([`share_path`]): a path refused as on another computer is checked again against the -/// document's path on that share. A path on a drive mapped to a network share other than the -/// document's ([`drive_share_path`]) is refused as on another computer. Every stored path -/// reaches the file system through this. -pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result { - document_path_on(stored, document, share_path, drive_share_path) -} - -/// [`document_path`], with `share` giving a document's path on the network share behind its drive. -pub(crate) fn document_path_with( - stored: &str, - document: Option<&str>, - share: impl FnOnce(&str) -> Option, -) -> std::result::Result { - use craft_docpath::Refused; - let document = document.map(absolute_path); - let resolve = |document: Option<&str>| craft_docpath::resolve_absolute(stored, document.map(std::path::Path::new)); - let path = match resolve(document.as_deref()) { - Err(Refused::Network) => { - let share = document.as_deref().and_then(share).ok_or(Refused::Network)?; - resolve(Some(share.as_str()))? - } - path => path?, - }; - path.into_os_string().into_string().map_err(|_| Refused::Invalid) -} - -/// [`document_path_with`], also refusing a path on a drive that is mapped to a network share other -/// than the document's (or, for a document on this computer or without a path, to any share) as -/// [`craft_docpath::Refused::Network`]. `drive_share` gives a path's path on the share behind its -/// drive (`Y:\art\x.png` on `\\server\other\art\x.png`), or `None` for a path on this computer. A -/// path on the document's own drive is not looked up. A document written `\\?\Z:\…` is looked up -/// on its share as `Z:\…` is. -pub(crate) fn document_path_on( - stored: &str, - document: Option<&str>, - share: impl Fn(&str) -> Option, - drive_share: impl FnOnce(&str) -> Option, -) -> std::result::Result { - let share = |d: &str| share(d.strip_prefix(r"\\?\").filter(|d| drive_letter(d).is_some()).unwrap_or(d)); - let path = document_path_with(stored, document, share)?; - let document = document.map(absolute_path); - let Some(drive) = drive_letter(&path) else { return Ok(path) }; - if document.as_deref().and_then(drive_letter).is_some_and(|d| d.eq_ignore_ascii_case(&drive)) { - return Ok(path); - } - let Some(on_share) = drive_share(&path) else { return Ok(path) }; - let home = document.map(|d| share(&d).unwrap_or(d)); - craft_docpath::resolve(&on_share, home.as_deref().map(std::path::Path::new))?; - Ok(path) -} - -/// The drive letter of an absolute path on a drive (`C:\…`, `C:/…` or `\\?\C:\…`). -fn drive_letter(path: &str) -> Option { - let mut chars = path.strip_prefix(r"\\?\").unwrap_or(path).chars(); - match (chars.next(), chars.next(), chars.next()) { - (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic() => Some(l), - _ => None, - } -} - -/// The path of `path` (on a drive, `Y:\art\x.png`) on the network share the drive is mapped to -/// (`\\server\other\art\x.png`), when the drive's root folder is on one and automation may read -/// `path`. The root folder's final path ([`std::fs::canonicalize`]) names the share. -#[cfg(windows)] -pub(crate) fn drive_share_path(path: &str) -> Option { - may_read(path).ok()?; - drive_share_of(path, |root| std::fs::canonicalize(root).ok()) -} - -#[cfg(not(windows))] -pub(crate) fn drive_share_path(_: &str) -> Option { - None -} - -/// The path of `path` on the network share its drive is mapped to, given `final_path`, which -/// returns the final path of the drive's root folder (`Y:\`): as [`share_of`] gives it for a -/// document, also for a path written `\\?\Y:\…`. -#[cfg(any(windows, test))] -pub(crate) fn drive_share_of(path: &str, final_path: impl FnOnce(&str) -> Option) -> Option { - let path = path.strip_prefix(r"\\?\").unwrap_or(path); - share_of(path, |folder| { - let (root, rest) = (folder.get(..3)?, folder.get(3..)?); - let root = final_path(root)?; - let root = root.to_str()?.trim_end_matches('\\'); - Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() }) - }) -} - -/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is -/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may -/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as -/// `\\?\UNC\server\share\…`. -#[cfg(windows)] -pub(crate) fn share_path(document: &str) -> Option { - share_of(document, |folder| { - may_read(folder).ok()?; - std::fs::canonicalize(folder).ok() - }) -} - -#[cfg(not(windows))] -pub(crate) fn share_path(_: &str) -> Option { - None -} - -/// The path of `document` on the network share its drive is mapped to, given `final_path`, which -/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`) -/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume. -#[cfg(any(windows, test))] -pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option) -> Option { - let mut chars = document.chars(); - if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) { - return None; - } - // The drive's root folder keeps its separator (`Z:\`). - let split = document.rfind(['\\', '/'])?; - let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?); - if name.is_empty() { - return None; - } - let target = final_path(folder)?; - let target = target.to_str()?; - let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\'); - if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 { - return None; - } - Some(format!(r"\\{unc}\{name}")) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option)> { - None -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn absolute_path(path: &str) -> String { - path.to_string() -} - -#[cfg(target_arch = "wasm32")] -fn no_fs(path: &str) -> EngineError { - EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)")) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn read_file(path: &str) -> Result> { - Err(no_fs(path)) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn read_file_head(path: &str, _: usize) -> Result> { - Err(no_fs(path)) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> { - Err(no_fs(path)) -} - -#[cfg(target_arch = "wasm32")] -pub fn write_file_with(path: &str, _: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { - Err(no_fs(path)) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn create_dir(path: &str) -> Result<()> { - Err(no_fs(path)) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { - Err(no_fs(path)) -} - -/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as -/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without -/// case: `Icon` and `icon` are one file on most desktop file systems). -pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { - unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) -} - -/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe -/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used -/// (compared without case). -pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { - distinct_names(names.into_iter().map(|(name, fallback)| { - let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); - if base.is_empty() { fallback } else { base } - })) -} - -/// `names` with `-2`, `-3`… after a name already used (compared without case). -fn distinct_names(names: impl IntoIterator) -> Vec { - let mut taken = std::collections::HashSet::new(); - // The next number to try for each base: a name repeated many times doesn't search the numbers - // taken before it. - let mut next: std::collections::HashMap = std::collections::HashMap::new(); - names - .into_iter() - .map(|base| { - let mut name = base.clone(); - while !taken.insert(name.to_lowercase()) { - let n = next.entry(base.to_lowercase()).or_insert(1); - *n += 1; - name = format!("{base}-{n}"); - } - name - }) - .collect() -} - -/// Write an export's files for the destination `path` → `{path, bytes, files?, linked?, …extra}` -/// (`files`: every file when there are several; `linked`: the images it links to); with no path -/// → the same with `dataBase64` and `{name, dataBase64}` rows, named after `name`. -fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document, enc: &Encoded, extra: Value) -> Result { - let files = enc.named(doc, path.unwrap_or(name)); - let (main, linked) = files.split_at(enc.files.len()); - let row = |(name, bytes): &(String, std::borrow::Cow<[u8]>)| match path { - Some(_) => json!(name), - None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }), - }; - if path.is_some() { - for (p, bytes) in &files { - write_file(p, bytes)?; - } - } - let Some((first, bytes)) = main.first() else { return Err(EngineError::Other("the export wrote no file".into())) }; - let mut out = match path { - Some(_) => json!({ "path": first, "bytes": bytes.len() }), - None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }), - }; - if main.len() > 1 { - out["files"] = main.iter().map(row).collect(); - } - if !linked.is_empty() { - out["linked"] = linked.iter().map(row).collect(); - } - Ok(merge(out, extra)) -} - -/// `a` with the fields of `b`. -pub(crate) fn merge(mut a: Value, b: Value) -> Value { - if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) { - a.extend(b); - } - a -} - -/// Appearance of Black › Printing / Exporting (#394): Output All Blacks as Rich Black makes RGB -/// raster exports draw pure CMYK black as RGB black (`richBlack` on the encoder params). -pub(crate) fn with_black_output(s: &Session, p: &Value) -> Value { - if s.prefs.black_output == "rich" { merge(p.clone(), json!({ "richBlack": true })) } else { p.clone() } -} - -/// A file name for bytes handed back without a path: the document's title with `ext`. -pub(crate) fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String { - let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty()); - format!("{}.{ext}", stem.as_deref().unwrap_or("Untitled")) -} - -/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`. -pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result { - let out = match path { - Some(path) => { - write_file(path, bytes)?; - json!({ "path": path, "bytes": bytes.len() }) - } - None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }), - }; - Ok(merge(out, extra)) -} - -#[cfg(test)] -mod tests; -#[cfg(test)] -mod tests_affinity; -#[cfg(test)] -mod tests_pdf; -#[cfg(test)] -mod tests_pdfcolor; -#[cfg(test)] -mod tests_pdfedit; -#[cfg(test)] -mod tests_pdffidelity; -#[cfg(test)] -mod tests_pdfimport; -#[cfg(test)] -mod tests_svg; -#[cfg(test)] -mod tests_svgedit; - -#[cfg(test)] -mod tests_raster; - -#[cfg(test)] -mod tests_exportas; - -#[cfg(test)] -mod tests_jpeg; - -#[cfg(test)] -mod tests_palette; - -#[cfg(test)] -mod tests_batch; -#[cfg(test)] -mod tests_screens; -#[cfg(test)] -mod tests_text; - -#[cfg(test)] -mod tests_dxf; - -#[cfg(test)] -mod tests_pdfsecurity; -#[cfg(test)] -mod tests_svgenc; -#[cfg(test)] -mod tests_svgimport; - -#[cfg(test)] -mod tests_eps; - -#[cfg(test)] -mod tests_dxfimport; -#[cfg(test)] -mod tests_metafile; - -#[cfg(test)] -mod tests_pdfoutput; -#[cfg(test)] -mod tests_tiffbmp; - -#[cfg(test)] -mod tests_pdfx; -#[cfg(test)] -mod tests_psd; -#[cfg(test)] -mod tests_psdread; - -#[cfg(test)] -mod tests_epsimport; - -#[cfg(test)] -mod tests_aiimport; - -#[cfg(test)] -mod tests_pdflayers; - -#[cfg(test)] -mod tests_pdfwebview; - -#[cfg(test)] -mod tests_pdf13; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index adce6a6ac..172e6f522 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -185,6 +185,14 @@ fn parent_folder(path: &str) -> Option { std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } +/// A file dialog's starting folder for a path on disk (#4122): the path's parent when it is a +/// folder, else the nearest ancestor directory that exists. +pub fn dialog_folder(path: &str) -> Option { + let folder = parent_folder(path)?; + let path = std::path::PathBuf::from(folder); + path.ancestors().find(|a| a.is_dir()).map(|a| a.to_string_lossy().into_owned()) +} + /// A save format by id or extension, or why it isn't one. fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> { match format(f) { @@ -300,7 +308,7 @@ fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result Vec { - vec![ - cmd!( - "object.flattenTransparency", - "Flatten Transparency…", - ["Object"], - None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", - has_doc, - flatten - ), - cmd!( - query "flattener.presets.list", - "Transparency Flattener Presets", - [], - None, - "{} → {presets: [{name, builtIn, options}]} the built-in presets (High, Medium and Low Resolution), then the saved ones; any of these names works as `preset` wherever flattener options are taken", - always, - presets_list - ), - cmd!( - query "flattener.presets.save", - "Save Transparency Flattener Preset", - [], - None, - "{name?: (default: a new \"Flattener Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else medium), …options (the keys of object.flattenTransparency, at the top level or in `options`)} create or change a saved preset (built-in ones can't change) → {name, options, created}", - always, - presets_save - ), - cmd!( - query "flattener.presets.delete", - "Delete Transparency Flattener Preset", - [], - None, - "{name} delete a saved preset (built-in ones stay) → {deleted: name}", - always, - presets_delete - ), - cmd!( - query "flattener.presets.import", - "Import Transparency Flattener Presets", - [], - None, - "{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else the imported ones get a number)} add the presets of a .vcflattener file (as flattener.presets.export writes) to the saved ones → {imported: [names]}", - always, - presets_import - ), - cmd!( - query "flattener.presets.export", - "Export Transparency Flattener Presets", - [], - None, - "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcflattener file (JSON) → {path, count}; without path → {data: the file's text, count}", - always, - presets_export - ), - cmd!( - query "flattener.preview", - "Flattener Preview", - [], - None, - "{highlight?: \"none\"|\"rasterizedRegions\" (areas the raster/vector balance rasterizes whole)|\"transparentObjects\"|\"allAffected\"|\"expandedPatterns\" (pattern art taking part)|\"outlinedStrokes\"|\"outlinedText\"|\"allRasterized\" (default none), overprints?: \"preserve\"|\"simulate\"|\"discard\" (simulate and discard flatten without preserveOverprints), preset?, …options as object.flattenTransparency, ids?|id?: (default: the whole document)} what flattening would do, without changing anything → {highlight, regions: [{bounds: {x, y, width, height}, id?}] (the highlighted objects, or areas), counts: {transparentObjects, allAffected, expandedPatterns, outlinedStrokes, outlinedText, rasterizedRegions, allRasterized, vectorRegions}, options}", - has_doc, - preview - ), - ] -} - -/// Object → Flatten Transparency settings: a preset's, adjusted by the command's params. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct FlattenOptions { - /// Raster/vector balance, 0–100: 0 rasterizes all flattened art; 100 keeps everything vector - /// that can be; in between, groups that split into more regions than it allows are rasterized. - pub balance: f64, - /// Resolution (ppi) of rasterized art. - pub line_art_ppi: f64, - /// Resolution (ppi) of rasterized areas that only gradients and meshes reach. - pub gradient_ppi: f64, - /// Outline all type, also where it isn't transparent. - pub text_to_outlines: bool, - /// Outline all strokes, also where they aren't transparent. - pub strokes_to_outlines: bool, - /// Clip rasterized areas to their region outlines (else they are rectangles). - pub clip_complex_regions: bool, - /// Smooth the edges of rasterized areas. - pub anti_alias: bool, - /// Keep the art's own alpha: composite over nothing instead of over white. - pub preserve_alpha: bool, - /// Areas showing a single paint keep it (spot and process colours, swatches) with its - /// overprint; off, the flattened objects lose their overprints. - pub preserve_overprints: bool, - /// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with - /// see-through pixels (these keys, [`see_through_images`]) count as transparency, and - /// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink - /// amounts). `None` for ordinary flattening. Not a preset option. - #[serde(skip)] - pub no_transparency: Option>, -} - -impl Default for FlattenOptions { - fn default() -> Self { - Self::builtin("medium") - } -} - -impl FlattenOptions { - /// Preset ids, finest first. - pub const PRESETS: [&'static str; 3] = ["high", "medium", "low"]; - - /// Display name of a preset id. - pub fn preset_label(id: &str) -> Option<&'static str> { - Some(match id { - "high" => "High Resolution", - "medium" => "Medium Resolution", - "low" => "Low Resolution", - _ => return None, - }) - } - - /// A preset by id or display name, any case. - pub fn preset(name: &str) -> Option { - let key = name.trim().to_ascii_lowercase(); - let id = Self::PRESETS.into_iter().find(|id| key == *id || Self::preset_label(id).is_some_and(|l| l.eq_ignore_ascii_case(&key)))?; - Some(Self::builtin(id)) - } - - /// The built-in preset `id` (one of [`Self::PRESETS`]; anything else is low). - fn builtin(id: &str) -> Self { - let (balance, line_art_ppi, gradient_ppi, clip_complex_regions, anti_alias) = match id { - "high" => (100.0, 1200.0, 300.0, true, false), - "medium" => (75.0, 300.0, 300.0, false, true), - _ => (75.0, 300.0, 150.0, false, true), - }; - Self { - balance, - line_art_ppi, - gradient_ppi, - text_to_outlines: false, - strokes_to_outlines: false, - clip_complex_regions, - anti_alias, - preserve_alpha: false, - preserve_overprints: true, - no_transparency: None, - } - } - - /// The built-in presets, finest first. - pub fn builtin_presets() -> Vec { - Self::PRESETS - .into_iter() - .filter_map(|id| Some(FlattenerPreset { name: Self::preset_label(id)?.into(), options: Self::preset(id)? })) - .collect() - } - - /// A built-in preset (by id or display name) or one of `saved` (by name), any case. - pub fn find_preset(name: &str, saved: &[FlattenerPreset]) -> Option { - Self::preset(name).or_else(|| saved_preset(saved, name).map(|p| p.options.clone())) - } - - /// The options `p` asks for, with the built-in presets only ([`Self::from_params_with`]). - pub fn from_params(p: &Value) -> std::result::Result { - Self::from_params_with(p, &[]) - } - - /// The options an export flattens with: preset `name` (built-in or one of `saved`; default - /// medium) with the export's `flattener` `options` over it. - pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result { - let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) }); - if let Some(name) = name { - q["preset"] = json!(name); - } - Self::from_params_with(&q, saved) - } - - /// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by - /// the option keys given at the top level or in `options`. - pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result { - let base = match str_param(p, "preset") { - Some(name) => Self::find_preset(name, saved) - .ok_or_else(|| format!("unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list)"))?, - None => Self::default(), - }; - let mut v = serde_json::to_value(&base).map_err(|e| e.to_string())?; - for src in [Some(p), p.get("options")].into_iter().flatten().filter_map(Value::as_object) { - for (k, val) in src { - if let Some(slot) = v.get_mut(k) { - *slot = val.clone(); - } - } - } - let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; - if !(0.0..=100.0).contains(&o.balance) { - return Err("balance must be between 0 and 100".into()); - } - if ![o.line_art_ppi, o.gradient_ppi].iter().all(|r| (1.0..=2400.0).contains(r)) { - return Err("resolutions must be between 1 and 2400 ppi".into()); - } - Ok(o) - } - - /// `n` shows an image whose see-through pixels count as transparency. - fn draws_see_through_image(&self, n: &Node) -> bool { - self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys)) - } - - /// Whether a group that splits into `regions` atomic regions is rasterized whole. - fn rasterize_all(&self, regions: usize) -> bool { - self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0)) - } -} - -fn flatten(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.flattenTransparency"; - let o = s.flatten_options(p).map_err(|m| bad(C, m))?; - let roots = target_roots(s, p)?; - if roots.is_empty() { - return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into())); - } - let src = s.doc()?.doc.clone(); - let failed = vectorcraft_render::decode_failures(); - let plan = plan(&src, &roots, &o, true)?; - let warning = super::rasterfx::undecoded_warning(failed); - let options = serde_json::to_value(&o).unwrap_or_default(); - if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) { - return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::>(), "rasterized": 0, "vector": 0, "options": options })); - } - let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| { - let threaded = super::threads::frames(d); - let out = apply(d, sel, plan, &o)?; - // Frames of threaded text that were outlined or flattened leave their threads with the - // text they show. - super::threads::leave_threads(d, &threaded); - Ok(out) - })?; - let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "rasterized": rasterized, "vector": vector, "options": options }); - if let Some(w) = warning { - out["warning"] = json!(w); - } - Ok(out) -} - -// ---------- presets ---------- - -/// A named set of flattener options: a built-in preset or a saved one -/// ([`crate::Prefs::flattener_presets`]). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FlattenerPreset { - pub name: String, - #[serde(default)] - pub options: FlattenOptions, -} - -/// What `flattener.presets.export` writes and `flattener.presets.import` reads. -#[derive(Serialize, Deserialize)] -struct PresetFile { - format: String, - #[serde(default)] - presets: Vec, -} - -/// The `format` of a presets file, also its extension. -pub const PRESET_FORMAT: &str = "vcflattener"; - -/// The extensions of presets files (opening one imports it). -pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; - -fn saved_preset<'a>(saved: &'a [FlattenerPreset], name: &str) -> Option<&'a FlattenerPreset> { - saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())) -} - -impl Session { - /// Every flattener preset: the built-in ones, then the saved ones. - pub fn flattener_presets(&self) -> Vec { - let mut v = FlattenOptions::builtin_presets(); - v.extend(self.prefs.flattener_presets.iter().cloned()); - v - } - - /// The preset `name` names: a built-in one (by id or display name) or a saved one, any case. - pub fn flattener_preset(&self, name: &str) -> Option { - let key = name.trim(); - let key = FlattenOptions::preset_label(&key.to_ascii_lowercase()).unwrap_or(key); - self.flattener_presets().into_iter().find(|p| p.name.eq_ignore_ascii_case(key)) - } - - /// The flattener options `p` asks for: a `preset` by name, built-in or saved, adjusted by the - /// option keys ([`FlattenOptions::from_params_with`]). - pub fn flatten_options(&self, p: &Value) -> std::result::Result { - FlattenOptions::from_params_with(p, &self.prefs.flattener_presets) - } - - /// The first free "Flattener Preset N": the name a new preset gets. - pub fn new_preset_name(&self) -> String { - (1..).map(|i| format!("Flattener Preset {i}")).find(|n| !self.preset_taken(n, None)).unwrap_or_default() - } - - /// The index of the saved preset `name`. - fn saved_index(&self, name: &str) -> Option { - self.prefs.flattener_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim())) - } - - /// Whether `name` is taken by a built-in preset or a saved one other than the one at `except`. - fn preset_taken(&self, name: &str, except: Option) -> bool { - FlattenOptions::preset(name).is_some() || self.saved_index(name).is_some_and(|i| Some(i) != except) - } -} - -fn presets_list(s: &mut Session, _: &Value) -> Result { - let rows = FlattenOptions::builtin_presets() - .into_iter() - .map(|p| (p, true)) - .chain(s.prefs.flattener_presets.iter().cloned().map(|p| (p, false))) - .map(|(p, builtin)| json!({"name": p.name, "builtIn": builtin, "options": p.options})); - Ok(json!({ "presets": rows.collect::>() })) -} - -fn presets_save(s: &mut Session, p: &Value) -> Result { - const C: &str = "flattener.presets.save"; - let name = match str_param(p, "name").map(str::trim) { - Some("") => return Err(bad(C, "`name` is empty")), - Some(n) => n.to_string(), - None => s.new_preset_name(), - }; - if FlattenOptions::preset(&name).is_some() { - return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name"))); - } - let at = s.saved_index(&name); - // A saved preset changes from its own options unless `preset` says where to start. - let mut q = p.clone(); - if let (Some(i), Some(o), None) = (at, q.as_object_mut(), str_param(p, "preset")) { - o.insert("preset".into(), json!(s.prefs.flattener_presets[i].name)); - } - let options = s.flatten_options(&q).map_err(|m| bad(C, m))?; - let name = match str_param(p, "newName").map(str::trim) { - Some("") => return Err(bad(C, "`newName` is empty")), - Some(n) if s.preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))), - Some(n) => n.to_string(), - None => at.map_or(name, |i| s.prefs.flattener_presets[i].name.clone()), - }; - let preset = FlattenerPreset { name: name.clone(), options: options.clone() }; - match at { - Some(i) => s.prefs.flattener_presets[i] = preset, - None => s.prefs.flattener_presets.push(preset), - } - Ok(json!({"name": name, "options": options, "created": at.is_none()})) -} - -fn presets_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "flattener.presets.delete"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; - if FlattenOptions::preset(name).is_some() { - return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); - } - let i = s.saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?; - Ok(json!({ "deleted": s.prefs.flattener_presets.remove(i).name })) -} - -fn presets_export(s: &mut Session, p: &Value) -> Result { - const C: &str = "flattener.presets.export"; - let presets = match p.get("names").and_then(Value::as_array) { - Some(names) => { - let find = |n: &Value| { - let n = n.as_str().unwrap_or_default(); - s.flattener_preset(n).ok_or_else(|| bad(C, format!("no preset named `{n}`"))) - }; - names.iter().map(find).collect::>>()? - } - None => s.prefs.flattener_presets.clone(), - }; - if presets.is_empty() { - return Err(bad(C, "no saved presets to export (name built-in ones in `names`)")); - } - let count = presets.len(); - let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?; - match str_param(p, "path") { - Some(path) => { - super::fileio::write_file(path, text.as_bytes())?; - Ok(json!({"path": path, "count": count})) - } - None => Ok(json!({"data": text, "count": count})), - } -} - -fn presets_import(s: &mut Session, p: &Value) -> Result { - const C: &str = "flattener.presets.import"; - let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) { - (Some(path), ..) => super::fileio::read_file(path)?, - (None, Some(text), _) => text.as_bytes().to_vec(), - (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, - _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), - }; - let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a flattener presets file: {e}")))?; - if file.format != PRESET_FORMAT { - return Err(bad(C, format!("not a flattener presets file (format `{}`)", file.format))); - } - // Values a hand-edited file may carry are rejected as the commands reject them. - for q in &file.presets { - FlattenOptions::from_params(&json!({ "options": q.options })).map_err(|m| bad(C, format!("`{}`: {m}", q.name)))?; - } - let replace = bool_or(p, "replace", false); - let mut imported = vec![]; - for mut preset in file.presets { - let base = Some(preset.name.trim()).filter(|n| !n.is_empty()).unwrap_or("Flattener Preset").to_string(); - match s.saved_index(&base) { - Some(i) if replace => { - preset.name = s.prefs.flattener_presets[i].name.clone(); - imported.push(preset.name.clone()); - s.prefs.flattener_presets[i] = preset; - } - _ => { - preset.name = unique_name(&base, |n| s.preset_taken(n, None)); - imported.push(preset.name.clone()); - s.prefs.flattener_presets.push(preset); - } - } - } - Ok(json!({ "imported": imported })) -} - -/// The top-level objects among `ids` (or the selection), in paint order: layers stand for their -/// contents; hidden and locked objects and clipping paths are left alone. -fn target_roots(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let mut ids = vec![]; - for id in targets(s, p)? { - match d.node(id) { - Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), - Some(_) => ids.push(id), - None => {} - } - } - let mut keyed: Vec<(Vec, NodeId)> = ids.into_iter().filter_map(|id| d.index_path(id).map(|p| (p, id))).collect(); - keyed.sort(); - keyed.dedup(); - let roots = roots_of(d, keyed.into_iter().map(|(_, id)| id).collect()); - // A clipping path stays where it clips. - let clipping = |id: NodeId| { - d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id)) - }; - let editable = d.editable(); - Ok(roots.into_iter().filter(|id| d.is_visible(*id) && editable(*id) && !clipping(*id)).collect()) -} - -// ---------- preview ---------- - -/// What the Flattener Preview highlights. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Highlight { - /// Nothing: the art in colour. - #[default] - None, - /// Areas the raster/vector balance rasterizes whole. - RasterizedRegions, - /// Objects with transparency of their own. - TransparentObjects, - /// Every object flattening changes. - AllAffected, - /// Objects with pattern paint in the flattened art. - ExpandedPatterns, - /// Strokes that become filled outlines. - OutlinedStrokes, - /// Type that becomes outlines. - OutlinedText, - /// Every area that becomes an image. - AllRasterized, -} - -impl Highlight { - pub const ALL: [Self; 8] = [ - Self::None, - Self::RasterizedRegions, - Self::TransparentObjects, - Self::AllAffected, - Self::ExpandedPatterns, - Self::OutlinedStrokes, - Self::OutlinedText, - Self::AllRasterized, - ]; - - /// The id `flattener.preview` takes. - pub fn id(self) -> &'static str { - match self { - Self::None => "none", - Self::RasterizedRegions => "rasterizedRegions", - Self::TransparentObjects => "transparentObjects", - Self::AllAffected => "allAffected", - Self::ExpandedPatterns => "expandedPatterns", - Self::OutlinedStrokes => "outlinedStrokes", - Self::OutlinedText => "outlinedText", - Self::AllRasterized => "allRasterized", - } - } - - pub fn label(self) -> &'static str { - match self { - Self::None => "None (Color Preview)", - Self::RasterizedRegions => "Rasterized Complex Regions", - Self::TransparentObjects => "Transparent Objects", - Self::AllAffected => "All Affected Objects", - Self::ExpandedPatterns => "Expanded Patterns", - Self::OutlinedStrokes => "Outlined Strokes", - Self::OutlinedText => "Outlined Text", - Self::AllRasterized => "All Rasterized Regions", - } - } - - /// A highlight by id or label, any case. - pub fn parse(s: &str) -> Option { - let s = s.trim(); - Self::ALL.into_iter().find(|h| h.id().eq_ignore_ascii_case(s) || h.label().eq_ignore_ascii_case(s)) - } -} - -/// How the Flattener Preview treats overprints: kept as they are, simulated in the preview, or -/// dropped; only Preserve flattens with Preserve Overprints and Spot Colors. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Overprints { - #[default] - Preserve, - Simulate, - Discard, -} - -impl Overprints { - pub const ALL: [Self; 3] = [Self::Preserve, Self::Simulate, Self::Discard]; - - pub fn id(self) -> &'static str { - match self { - Self::Preserve => "preserve", - Self::Simulate => "simulate", - Self::Discard => "discard", - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Preserve => "Preserve", - Self::Simulate => "Simulate", - Self::Discard => "Discard", - } - } - - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|o| o.id().eq_ignore_ascii_case(s.trim()) || o.label().eq_ignore_ascii_case(s.trim())) - } -} - -/// What flattening would do (Window → Flattener Preview, `flattener.preview`). -#[derive(Clone, Debug, Default)] -pub struct FlattenReport { - /// Top-level objects with transparency of their own. - pub transparent: Vec, - /// Top-level objects flattening changes: the overlapping groups with transparency, and those - /// the outline and overprint options change. - pub affected: Vec, - /// Objects with pattern paint in the flattened art. - pub patterns: Vec, - /// Paths whose strokes become filled outlines. - pub strokes: Vec, - /// Type that becomes outlines. - pub text: Vec, - /// Areas the raster/vector balance rasterizes whole. - pub complex: Vec, - /// Every area that becomes an image. - pub rasterized: Vec, - /// Flat-colour regions made. - pub vector: usize, -} - -impl FlattenReport { - /// The objects `h` highlights. - pub fn objects(&self, h: Highlight) -> &[NodeId] { - match h { - Highlight::TransparentObjects => &self.transparent, - Highlight::AllAffected => &self.affected, - Highlight::ExpandedPatterns => &self.patterns, - Highlight::OutlinedStrokes => &self.strokes, - Highlight::OutlinedText => &self.text, - _ => &[], - } - } - - /// The areas `h` highlights. - pub fn areas(&self, h: Highlight) -> &[PathData] { - match h { - Highlight::RasterizedRegions => &self.complex, - Highlight::AllRasterized => &self.rasterized, - _ => &[], - } - } - - /// How many objects or areas each highlight shows. - pub fn counts(&self) -> Value { - json!({ - "transparentObjects": self.transparent.len(), - "allAffected": self.affected.len(), - "expandedPatterns": self.patterns.len(), - "outlinedStrokes": self.strokes.len(), - "outlinedText": self.text.len(), - "rasterizedRegions": self.complex.len(), - "allRasterized": self.rasterized.len(), - "vectorRegions": self.vector, - }) - } - - /// The report as `flattener.preview` answers: the highlighted objects (or areas) of `doc` and - /// the counts. - pub fn to_json(&self, doc: &Document, h: Highlight) -> Value { - let rect = |r: Rect| json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}); - let objects = self.objects(h).iter().filter_map(|id| Some(json!({"id": id.0, "bounds": rect(doc.node(*id)?.visual_bounds()?)}))); - let areas = self.areas(h).iter().filter_map(|p| Some(json!({ "bounds": rect(p.bounds()?) }))); - json!({"highlight": h.id(), "regions": objects.chain(areas).collect::>(), "counts": self.counts()}) - } - - /// The art `h` highlights, opaque, alone in a copy of `doc`: the objects and the areas (filled - /// black). Its coverage is the highlight. `None` when nothing is highlighted. - pub fn highlight_art(&self, doc: &Document, h: Highlight) -> Option { - let (ids, areas) = (self.objects(h), self.areas(h)); - if ids.is_empty() && areas.is_empty() { - return None; - } - let mut tmp = doc.clone(); - let mut art: Vec = ids.iter().filter_map(|id| doc.node(*id).cloned()).collect(); - art.iter_mut().for_each(opaque); - for path in areas { - let mut n = shape_node(&mut tmp, path.clone(), None); - n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into(); - art.push(n); - } - Some(isolated_doc(&tmp, art)) - } -} - -/// What flattening `roots` of `doc` with `o` would do. -pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> Result { - let plan = plan(doc, roots, o, false)?; - let mut r = FlattenReport { transparent: plan.transparent, ..Default::default() }; - // Every object under `id` (but a compound path's own pieces), visible ones only. - fn visit(n: &Node, f: &mut dyn FnMut(&Node)) { - if !n.visible { - return; - } - f(n); - if !matches!(n.kind, NodeKind::Compound { .. }) { - for c in n.children().into_iter().flatten() { - visit(c, f); - } - } - } - let pattern = |n: &Node| n.appearance.items.iter().any(|i| i.visible() && matches!(i.paint(), Paint::Pattern { .. })); - for f in &plan.flat { - r.affected.extend(&f.roots); - r.vector += f.regions.len(); - for root in f.roots.iter().filter_map(|id| doc.node(*id)) { - visit(root, &mut |n| { - // Flattened art is outlined throughout and its patterns become part of the image. - if matches!(n.kind, NodeKind::Text(_)) { - r.text.push(n.id); - } else if stroked(n) { - r.strokes.push(n.id); - } - if pattern(n) { - r.patterns.push(n.id); - } - }); - } - if let Some(a) = &f.area { - if a.whole { - r.complex.push(a.outline()); - } - r.rasterized.push(a.outline()); - } - } - for root in plan.kept.iter().filter_map(|id| doc.node(*id)).filter(|n| kept_work(n, o)) { - r.affected.push(root.id); - visit(root, &mut |n| { - if o.text_to_outlines && matches!(n.kind, NodeKind::Text(_)) { - r.text.push(n.id); - } else if o.strokes_to_outlines && stroked(n) { - r.strokes.push(n.id); - } - }); - } - r.affected.sort_by_key(|id| doc.index_path(*id)); - Ok(r) -} - -impl Session { - /// What flattening would do with the options `p` asks for (as `flattener.preview`): the - /// options and the report. - pub fn flattener_report(&self, p: &Value) -> Result<(FlattenOptions, FlattenReport)> { - const C: &str = "flattener.preview"; - let mut o = self.flatten_options(p).map_err(|m| bad(C, m))?; - if let Some(v) = str_param(p, "overprints") { - let ov = Overprints::parse(v).ok_or_else(|| bad(C, format!("unknown overprints `{v}` (preserve, simulate or discard)")))?; - o.preserve_overprints = ov == Overprints::Preserve; - } - let roots = if p.get("ids").is_some() { - target_roots(self, p)? - } else { - let layers: Vec = self.doc()?.doc.layers.iter().map(|l| l.id.0).collect(); - target_roots(self, &json!({ "ids": layers }))? - }; - let r = report(&self.doc()?.doc, &roots, &o)?; - Ok((o, r)) - } -} - -fn preview(s: &mut Session, p: &Value) -> Result { - let h = match str_param(p, "highlight") { - Some(v) => Highlight::parse(v).ok_or_else(|| bad("flattener.preview", format!("unknown highlight `{v}`")))?, - None => Highlight::None, - }; - let (o, r) = s.flattener_report(p)?; - let mut out = r.to_json(&s.doc()?.doc, h); - out["options"] = serde_json::to_value(&o).unwrap_or_default(); - Ok(out) -} - -// ---------- plan ---------- - -/// What the flattening does, worked out before the document changes. -struct Plan { - flat: Vec, - /// Objects without transparency (they stay, but for the outline options). - kept: Vec, - /// The objects with transparency of their own. - transparent: Vec, -} - -/// One group of overlapping objects with transparency and what replaces it. -struct Flat { - /// Its objects in paint order; the result takes the place of the last. - roots: Vec, - regions: Regions, - /// Where its image goes, and the image (once rendered). - area: Option, - raster: Option, -} - -/// Where a group's image goes. -struct Area { - rect: Rect, - ppi: f64, - /// The complex regions it is clipped to (Clip Complex Regions). - clip: Option, - /// The raster/vector balance rasterizes the whole group (it splits into too many regions). - whole: bool, -} - -impl Area { - /// The area's outline. - fn outline(&self) -> PathData { - self.clip.clone().unwrap_or_else(|| shapes::rectangle(self.rect)) - } -} - -/// Flat-colour regions: their outlines and paints. -type Regions = Vec<(PathData, RegionFill)>; - -/// A flat-colour region's paint. -struct RegionFill { - paint: Paint, - alpha: f32, - overprint: bool, -} - -/// A rendered image of complex regions, clipped to them or not. -struct Raster { - blob: ImageBlob, - width: u32, - height: u32, - xf: Affine, - clip: Option, -} - -/// The flattening of `roots` with `o`; `render` renders the images (the preview only needs where -/// they go). Fails only when an image can't be encoded. -fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> Result { - let tmp = isolated_doc(src, roots.iter().filter_map(|id| src.node(*id).cloned()).collect()); - // Geometry effects first: what is left on the art are raster effects. - let baked = effects::bake_document(&tmp).unwrap_or(tmp); - let art: Vec = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect(); - let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; - let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); - let reaches: Vec> = plain.iter().map(reach).collect(); - let see_through: Vec = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect(); - let cmyk = src.color_mode == ColorMode::Cmyk; - let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; - // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). - vectorcraft_doc::cancel::check()?; - for g in overlapping(&reaches) { - vectorcraft_doc::cancel::check()?; - let ids: Vec = g.iter().map(|&i| roots[i]).collect(); - let group = g.iter().any(|&i| see_through[i]).then(|| { - let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); - flatten_group(&plain, o, cmyk) - }); - let Some((regions, area)) = group.flatten() else { - out.kept.extend(ids); - continue; - }; - let raster = match area.as_ref().filter(|_| render) { - Some(a) => { - let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); - Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?) - } - None => None, - }; - out.flat.push(Flat { roots: ids, regions, area, raster }); - } - Ok(out) -} - -/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as -/// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. -fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { - let mut b = Builder { cmyk, ..Default::default() }; - let elems: Vec = plain.iter().filter_map(|n| b.elem(n)).collect(); - if b.shapes.is_empty() { - return None; - } - // An unclipped full raster needs no regions. - let whole = o.balance <= 0.0 && !o.clip_complex_regions; - let regions = if whole { vec![] } else { po::regions(&b.shapes) }; - // An arrangement that failed (degenerate geometry) leaves only rasterizing. - let all = regions.is_empty() || o.rasterize_all(regions.len()); - let mut vector: Vec<(usize, RegionFill)> = vec![]; - let mut complex: Vec = vec![]; - let mut gradients_only = !all; - for (k, r) in regions.iter().enumerate() { - if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { - gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); - complex.push(k); - } else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) { - vector.push((k, fill)); - } - } - let bounds = |ks: &[usize]| ks.iter().filter_map(|&k| regions[k].path.bounds()).reduce(|a, b| a.union(b)); - if !o.clip_complex_regions && !all { - // A rectangle of raster takes in every region it overlaps. - while let Some(r) = bounds(&complex) { - let before = complex.len(); - let rect = shapes::rectangle(r); - vector.retain(|(k, _)| { - let path = ®ions[*k].path; - let hit = path.bounds().is_some_and(|b| b.intersect(r).area() > 0.0) - && po::area(&po::boolean(path, FillRule::NonZero, &rect, FillRule::NonZero, po::BoolOp::Intersect), FillRule::NonZero) > 1e-6; - if hit { - complex.push(*k); - } - !hit - }); - if complex.len() == before { - break; - } - gradients_only = false; - } - } - let raster_rect = if all { plain.iter().filter_map(|n| reach(n)).reduce(|a, b| a.union(b)) } else { bounds(&complex) }; - let area = raster_rect.map(|rect| { - let clip = o.clip_complex_regions.then(|| PathData::new(complex.iter().flat_map(|&k| regions[k].path.subpaths.iter().cloned()).collect())); - let ppi = if gradients_only { o.gradient_ppi } else { o.line_art_ppi }; - Area { rect, ppi, clip: clip.filter(|c| !c.is_empty()), whole: all } - }); - let vector = vector.into_iter().map(|(k, f)| (regions[k].path.clone(), f)).collect(); - Some((vector, area)) -} - -/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where -/// nothing paints it. -fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { - let mut c = Composer { sources, top: None, plane: 0 }; - let alone = c.eval(elems, CLEAR); - if alone[3] < 0.5 / 255.0 { - return None; - } - let start = if o.preserve_alpha { CLEAR } else { WHITE }; - let px = if o.preserve_alpha { - alone - } else { - c.top = None; - c.eval(elems, start) - }; - let rgb = [px[0], px[1], px[2]]; - // The K plane composites the same way. - let k = cmyk.then(|| { - c.plane = 1; - c.eval(elems, start)[0] - }); - // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. - let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3); - let same = |f: &Fill| px[3] >= 1.0 - 1e-4 && near(&f.planes[0], &rgb) && k.is_none_or(|k| near(&f.planes[1][..1], &[k])); - let color = match k { - Some(k) => { - let [c, m, y, k] = planes_cmyk(rgb, k); - Color::cmyk(c, m, y, k) - } - None => Color::rgb(rgb[0], rgb[1], rgb[2]), - }; - let (paint, overprint) = match c.top { - Some(f) if o.preserve_overprints && same(f) => (f.paint.clone(), f.overprint), - _ => (Paint::solid(color), false), - }; - Some(RegionFill { paint, alpha: px[3], overprint }) -} - -/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out -/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and -/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink -/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. -/// Fails when the image can't be encoded. -fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { - let mut scale = ppi / 72.0; - scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); - let px = 1.0 / scale; - let rect = rect.inflate(px, px); - let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); - let region = Rect::new( - rect.x0, - rect.y0, - rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale, - rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale, - ); - let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let tmp = isolated_doc(doc, art.to_vec()); - let mut r = vectorcraft_render::Renderer::new(); - let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha; - if no_alpha && doc.color_mode == ColorMode::Cmyk { - // Drawn as inks over the paper: as screen colours, the file would separate them again, - // and a CMYK colour comes back with other inks (K moves into C, M and Y). - let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() }; - let inks = r.render_region_inks(&tmp, region, scale, &opts); - vectorcraft_doc::cancel::check()?; - let (width, height) = vectorcraft_render::region_pixels(region, scale); - // Past the size of a CMYK image: drawn in screen colours, below. - if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) { - return Ok(Raster { blob, width, height, xf, clip }); - } - } - let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha); - // The drawing stops early when cancelled: this image is not whole. - vectorcraft_doc::cancel::check()?; - if no_alpha { - // Without transparency the clip alone shapes the image: it is opaque, over white (its - // edge pixels too). - for p in img.pixels.as_chunks_mut::<4>().0 { - p[3] = 255; - } - } else if !o.preserve_alpha || !o.anti_alias { - // The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial. - let cover: Vec = art - .iter() - .map(|n| { - let mut c = n.clone(); - opaque(&mut c); - c - }) - .collect(); - let cover = r.render_region(&isolated_doc(doc, cover), region, scale, false); - for (px, c) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(cover.pixels.as_chunks::<4>().0) { - let k = c[3]; - let k2 = if o.anti_alias || k == 0 || k == 255 { - k - } else if k >= 128 { - 255 - } else { - 0 - }; - if o.preserve_alpha { - let f = k2 as f32 / k.max(1) as f32; - for v in px.iter_mut() { - *v = (*v as f32 * f).round().min(255.0) as u8; - } - } else { - let white = 255 - k2; - for v in &mut px[..3] { - *v = v.saturating_sub(white).min(k2); - } - px[3] = k2; - } - } - } - let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?; - Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) -} - -/// `n` at full opacity with Normal blending throughout (objects, fills and strokes). -fn opaque(n: &mut Node) { - n.opacity = 1.0; - n.blend = BlendMode::Normal; - for i in &mut n.appearance.items { - match i { - AppearanceItem::Fill(f) => (f.opacity, f.blend) = (1.0, BlendMode::Normal), - AppearanceItem::Stroke(s) => (s.opacity, s.blend) = (1.0, BlendMode::Normal), - } - } - if let Some(ch) = n.children_mut() { - for c in ch { - opaque(Arc::make_mut(c)); - } - } -} - -/// `doc` with everything it draws flattened with `o`, as formats without transparency (EPS) -/// write it: every visible object of the visible layers, locked ones too (a clipping layer's -/// clipping path stays). `None` when that changes nothing. -pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result> { - let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints; - if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) { - return Ok(None); - } - let mut roots = vec![]; - for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) { - let children = l.children().map_or(&[][..], Vec::as_slice); - roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible).map(|c| c.id)); - } - let plan = plan(doc, &roots, o, true)?; - if plan.flat.is_empty() && !plan.kept.iter().any(|id| doc.node(*id).is_some_and(|n| kept_work(n, o))) { - return Ok(None); - } - let mut d = doc.clone(); - apply(&mut d, &mut vectorcraft_doc::Selection::default(), plan, o)?; - Ok(Some(d)) -} - -// ---------- apply ---------- - -fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec, usize, usize)> { - let (mut vector, mut rasterized) = (0, 0); - let (mut ids, mut flat) = (vec![], vec![]); - // The groups replace their objects in one pass at the end. When an object is listed twice or - // lies inside another listed one, each group replaces its objects as it is made instead. - let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::>()); - for f in plan.flat { - // A group always has objects. - let Some(&top) = f.roots.last() else { continue }; - let mut children = vec![]; - // The image goes under the regions, reaching a pixel past its own: their edges then fall on - // matching pixels instead of on what is behind the group. - if let Some(r) = f.raster { - children.push(Arc::new(raster_node(d, r))); - rasterized += 1; - } - for (path, fill) in f.regions { - let mut n = shape_node(d, path, None); - n.appearance = Appearance { - items: vec![AppearanceItem::Fill(FillLayer { overprint: fill.overprint, ..FillLayer::new(fill.paint) })], - ..Appearance::default() - } - .into(); - n.opacity = fill.alpha.min(1.0); - children.push(Arc::new(n)); - vector += 1; - } - let g = Node::group(d.alloc_id(), children); - ids.push(g.id); - if one_pass { - // The group replaces the topmost object, and the other objects are removed. - flat.extend(f.roots.iter().filter(|r| **r != top).map(|r| (*r, vec![]))); - flat.push((top, vec![g])); - } else { - let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; - d.insert(par, idx + 1, g)?; - for r in &f.roots { - d.remove(*r)?; - } - } - } - if !flat.is_empty() { - d.insert_beside(flat, true)?; - } - let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; - for mut id in plan.kept { - if o.text_to_outlines { - id = outline_texts_under(d, id)?; - } - if o.strokes_to_outlines { - id = outline_strokes_under(d, &brushes, id)?.0; - } - if !o.preserve_overprints - && let Some(n) = d.node_mut(id) - { - drop_overprints(n); - } - ids.push(id); - } - ids.sort_by_key(|id| d.index_path(*id)); - sel.set(ids.iter().copied()); - Ok((ids, vector, rasterized)) -} - -/// An image of `r` (in a clip group when it is clipped). -fn raster_node(d: &mut Document, r: Raster) -> Node { - let key = unique_key(d, "flattened"); - d.images.insert(key.clone(), r.blob); - let image = Node::new( - d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { - key, - width: r.width, - height: r.height, - xf: r.xf, - link: None, - placement: Default::default(), - pixels: None, - })), - ); - let Some(path) = r.clip else { return image }; - super::rasterfx::clip_group(d, path, FillRule::NonZero, image) -} - -/// Replace the type under `root` by its outlines: → the id now standing for `root`. -fn outline_texts_under(d: &mut Document, root: NodeId) -> Result { - let mut texts = vec![]; - if let Some(n) = d.node(root) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) { - texts.push(c.clone()); - } - }); - } - let mut out = root; - for t in texts { - let Some(o) = outlined_text(&t) else { continue }; - let o = d.reid(&o); - let nid = o.id; - *d.node_mut(t.id).ok_or(EngineError::NoNode(t.id))? = o; - if t.id == root { - out = nid; - } - } - Ok(out) -} - -/// Whether the outline and overprint options change `n`, an object without transparency. -fn kept_work(n: &Node, o: &FlattenOptions) -> bool { - let mut any = false; - n.walk(&mut |c| { - any |= (o.text_to_outlines && matches!(c.kind, NodeKind::Text(_))) - || (o.strokes_to_outlines && stroked(c)) - || (!o.preserve_overprints && overprints(c)); - }); - any -} - -/// Whether `n` is a path with a visible stroke (outlining turns it into a fill). -fn stroked(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) - && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0)) -} - -fn overprints(n: &Node) -> bool { - n.appearance.items.iter().any(AppearanceItem::overprint) - || matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke)) -} - -fn drop_overprints(n: &mut Node) { - if overprints(n) { - for i in &mut n.appearance.items { - *i.overprint_mut() = false; - } - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - (r.style.overprint_fill, r.style.overprint_stroke) = (false, false); - } - } - } - if let Some(ch) = n.children_mut() { - for c in ch { - drop_overprints(Arc::make_mut(c)); - } - } -} - -// ---------- plain art ---------- - -/// Converts art into what the compositor works on. -struct Scratch { - /// A copy of the document the art comes from (outlining allocates ids in it). - doc: Document, - brushes: Vec, -} - -impl Scratch { - /// `n` as plain filled shapes: type outlined, symbol instances replaced by their art, live - /// objects evaluated and strokes outlined (brush strokes as their art), each keeping the - /// object's transparency. - fn plain(&mut self, n: &Node) -> Node { - if !n.visible { - return n.clone(); - } - let made = match &n.kind { - NodeKind::Text(_) => outlined_text(n), - NodeKind::SymbolInstance { symbol, .. } => { - let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); - art.and_then(|a| effects::outline_art(Some(&self.doc), n, Some(&a))).map(|g| carry(n, g)) - } - // A placed document stays one: its art's resources aren't the document's. - NodeKind::PlacedDocument(_) => None, - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { - let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; - let hooks = vectorcraft_doc::live::Hooks { outline: Some(hook), symbols: Some(&self.doc), ..Default::default() }; - Some(carry(n, vectorcraft_doc::live::expanded_group_hooks(n, hooks))) - } - NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => outline_strokes(&mut self.doc, &self.brushes, n), - _ => None, - }; - let mut m = match made { - // The pieces of one object never knock each other out. - Some(m) if m.is_container() => Node { knockout: Knockout::Off, ..m }, - Some(m) => m, - None => n.clone(), - }; - if !matches!(m.kind, NodeKind::Compound { .. }) - && let Some(ch) = m.children_mut() - { - for c in ch.iter_mut() { - *c = Arc::new(self.plain(c)); - } - } - m - } -} - -/// Type as outlines painted like it ([`effects::outline_text`]), keeping its transparency. -fn outlined_text(n: &Node) -> Option { - effects::outline_text(n).map(|g| Node { knockout: Knockout::Off, ..carry(n, g) }) -} - -/// `to` (art made from `from`) with `from`'s name, transparency, opacity mask and effects. -fn carry(from: &Node, mut to: Node) -> Node { - to.name = from.name.clone(); - to.opacity = from.opacity; - to.blend = from.blend; - to.isolate = from.isolate; - to.knockout_shape = from.knockout_shape; - to.extras_mut().mask = from.extras().mask.clone(); - to.appearance.effects = from.appearance.effects.clone(); - to -} - -/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque -/// everywhere). -pub(crate) fn see_through_images(doc: &Document) -> BTreeSet { - let see_through = |b: &ImageBlob| { - b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255)) - }; - doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect() -} - -/// `n` shows an image of `keys` (itself or inside). -fn draws_image(n: &Node, keys: &BTreeSet) -> bool { - !keys.is_empty() - && n.visible - && match &n.kind { - NodeKind::Image(im) => keys.contains(&im.key), - _ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))), - } -} - -fn visible_fx(fx: &[Effect]) -> bool { - fx.iter().any(|e| e.visible) -} - -/// Effects on the object or on one of its fills or strokes (raster effects, once baked). -fn has_effects(n: &Node) -> bool { - visible_fx(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible_fx(i.effects())) -} - -fn masked(n: &Node) -> bool { - n.extras().mask.as_ref().is_some_and(|m| !m.disabled) -} - -/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). -fn reach(n: &Node) -> Option { - if !n.visible { - return None; - } - let b = match n.children() { - Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, - _ => n.visual_bounds()?, - }; - let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); - Some(b.inflate(fx + 1.0, fx + 1.0)) -} - -/// Indices of `bounds` grouped by overlap (transitively), each group in index order. -fn overlapping(bounds: &[Option]) -> Vec> { - fn find(p: &mut [usize], mut i: usize) -> usize { - while p[i] != i { - p[i] = p[p[i]]; - i = p[i]; - } - i - } - let mut parent: Vec = (0..bounds.len()).collect(); - let mut order: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.map(|b| (i, b))).collect(); - order.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); - // Sweep left to right, comparing each box with those still open at its left edge. - let mut open: Vec<(usize, Rect)> = vec![]; - for (i, b) in order { - open.retain(|(_, o)| o.x1 >= b.x0); - for (j, o) in &open { - if o.y0 <= b.y1 && b.y0 <= o.y1 { - let (a, c) = (find(&mut parent, i), find(&mut parent, *j)); - parent[a.max(c)] = a.min(c); - } - } - open.push((i, b)); - } - let mut groups: Vec> = vec![]; - let mut slot: Vec> = vec![None; bounds.len()]; - for i in 0..bounds.len() { - let r = find(&mut parent, i); - match slot[r] { - Some(g) => groups[g].push(i), - None => { - slot[r] = Some(groups.len()); - groups.push(vec![i]); - } - } - } - groups -} - -// ---------- the compositor ---------- - -/// What an arrangement shape stands for. -#[derive(Clone, Copy, Debug, PartialEq)] -enum Kind { - /// Solid fills and clipping paths: regions inside get a flat colour. - Vector, - /// A gradient or mesh: regions inside are rasterized at the gradient resolution. - Gradient, - /// Anything else that has no single colour (patterns, images, masks, raster effects). - Other, -} - -/// A solid fill as the compositor uses it. -struct Fill { - /// What it composites: its screen colour (twice), or in a CMYK document its two ink planes - /// ([`cmyk_planes`]). - planes: [[f32; 3]; 2], - opacity: f32, - blend: BlendMode, - paint: Paint, - overprint: bool, -} - -/// One object of the art being flattened, with the transparency the renderer gives it. -struct Elem { - opacity: f32, - blend: BlendMode, - isolate: bool, - knockout: Knockout, - knockout_shape: bool, - /// Blending inside reaches the backdrop ([`Node::blends_through`]). - blends: bool, - /// Inside a knockout group its children are elements of their own ([`Node::passes_knockout_through`]). - pass_through: bool, - body: Body, -} - -enum Body { - /// Solid fills (bottom first) of arrangement shape `shape`. - Fills { shape: usize, fills: Vec }, - /// A group's children, clipped to arrangement shape `clip` in a clip group. - Group { clip: Option, children: Vec }, - /// Art without a flat colour: its regions are rasterized, so it never composites here. - Complex, -} - -/// Builds the compositor's objects and the arrangement shapes they cover. -#[derive(Default)] -struct Builder { - shapes: Vec, - kinds: Vec, - /// Building a CMYK document's art. - cmyk: bool, -} - -impl Builder { - /// What colour `c` composites as ([`Fill::planes`]). - fn planes(&self, c: &Color) -> [[f32; 3]; 2] { - if self.cmyk { - let cms = vectorcraft_color::cms::active(); - cmyk_planes(cms.to_cmyk(c, cms.settings().intent)) - } else { - [c.to_rgb(); 2] - } - } - - fn shape(&mut self, path: PathData, rule: FillRule, kind: Kind) -> Option { - path.bounds()?; - self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); - self.kinds.push(kind); - Some(self.shapes.len() - 1) - } - - fn elem(&mut self, n: &Node) -> Option { - if !n.visible { - return None; - } - let body = if masked(n) || has_effects(n) { - self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Other)?; - Body::Complex - } else { - match &n.kind { - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - let (clip, rest) = if n.clips() { - let (first, rest) = children.split_first()?; - // Nothing to clip by hides the clipped art. - let (bp, rule) = effects::clip_outline(first)?; - (Some(self.shape(PathData::from_bezpath(&bp), rule, Kind::Vector)?), rest) - } else { - (None, &children[..]) - }; - Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() } - } - NodeKind::Path { guide: true, .. } => return None, - NodeKind::Path { .. } | NodeKind::Compound { .. } => { - let (path, rule) = node_path(n)?; - let mut fills = vec![]; - for item in n.appearance.items.iter().filter(|i| i.is_fill() && i.visible() && !i.paint().is_none()) { - match item.paint() { - Paint::Solid { color, .. } => fills.push(Fill { - planes: self.planes(color), - opacity: item.opacity(), - blend: item.blend(), - paint: item.paint().clone(), - overprint: item.overprint(), - }), - p => { - let kind = if matches!(p, Paint::Gradient(_)) { Kind::Gradient } else { Kind::Other }; - self.shape(path.clone(), rule, kind); - } - } - } - if fills.is_empty() { Body::Complex } else { Body::Fills { shape: self.shape(path, rule, Kind::Vector)?, fills } } - } - NodeKind::Image(im) => { - let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - self.shape(frame, FillRule::NonZero, Kind::Other)?; - Body::Complex - } - NodeKind::Mesh(_) => { - self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Gradient)?; - Body::Complex - } - _ => { - self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Other)?; - Body::Complex - } - } - }; - Some(Elem { - opacity: n.opacity, - blend: n.blend, - isolate: n.isolate, - knockout: n.knockout, - knockout_shape: n.knockout_shape, - blends: n.blends_through(), - pass_through: n.passes_knockout_through(), - body, - }) - } -} - -/// A straight (not premultiplied) RGBA colour in 0..=1. -type Px = [f32; 4]; -const CLEAR: Px = [0.0; 4]; -const WHITE: Px = [1.0; 4]; - -fn premul(p: Px) -> Px { - [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] -} - -fn unpremul(q: Px) -> Px { - if q[3] <= 0.0 { CLEAR } else { [q[0] / q[3], q[1] / q[3], q[2] / q[3], q[3].min(1.0)] } -} - -/// Composites the objects covering one atomic region exactly as the renderer composites pixels -/// (its transparency groups, non-isolated groups, knockout groups and clip groups): the same -/// paints cover the whole region, so one colour stands for all of it. -struct Composer<'a> { - /// Arrangement shapes covering the region, ascending. - sources: &'a [usize], - /// The last solid fill composited. - top: Option<&'a Fill>, - /// Which of the fills' [`Fill::planes`] composites. - plane: usize, -} - -impl<'a> Composer<'a> { - fn inside(&self, shape: usize) -> bool { - self.sources.binary_search(&shape).is_ok() - } - - /// `elems` (paint order) composited over `start`. - fn eval(&mut self, elems: &'a [Elem], start: Px) -> Px { - elems.iter().fold(start, |acc, e| self.draw(e, acc, false, false, false)) - } - - /// `e` drawn over backdrop `b` inside a group that knocks out when `enclosing`. `as_shape` - /// draws it as its knockout shape (full object opacity); `nested` is drawing inside a layer, - /// where a non-isolated group can't copy its backdrop and is drawn isolated. - fn draw(&mut self, e: &'a Elem, b: Px, enclosing: bool, as_shape: bool, nested: bool) -> Px { - let opacity = if as_shape { 1.0 } else { e.opacity }; - let ko = matches!(e.body, Body::Group { .. }) && e.knockout.resolve(enclosing); - if opacity >= 1.0 && e.blend == BlendMode::Normal && !e.isolate && !ko { - return self.content(e, b, ko, None, nested); - } - if e.blends && !e.isolate && !nested { - // Drawn over a copy of the backdrop, so blending inside reaches it. - let c = self.content(e, b, ko, Some(b), false); - if e.blend == BlendMode::Normal { - let (pb, pc) = (premul(b), premul(c)); - return unpremul([0, 1, 2, 3].map(|i| pb[i] + (pc[i] - pb[i]) * opacity)); - } - // The group's own colour: the backdrop's share taken out by the group's coverage. - let (pc, pa, pb) = (premul(c), premul(self.content(e, CLEAR, ko, None, false)), premul(b)); - let keep = 1.0 - pa[3]; - let g = [0, 1, 2].map(|i| (pc[i] - keep * pb[i]).clamp(0.0, pa[3])); - let g = unpremul([g[0], g[1], g[2], pa[3]]); - return composite(e.blend, b, [g[0], g[1], g[2], g[3] * opacity]); - } - let inner_nested = !(e.blends && (e.isolate || !nested)); - let g = self.content(e, CLEAR, ko, None, inner_nested); - composite(e.blend, b, [g[0], g[1], g[2], g[3] * opacity]) - } - - /// What `e` draws over `b` inside its group (`ko`: its children knock each other out, against - /// `backdrop` when the group was drawn over a copy of it). - fn content(&mut self, e: &'a Elem, b: Px, ko: bool, backdrop: Option, nested: bool) -> Px { - match &e.body { - Body::Fills { shape, fills } if self.inside(*shape) => fills.iter().fold(b, |acc, f| { - self.top = Some(f); - let [r, g, b] = f.planes[self.plane]; - composite(f.blend, acc, [r, g, b, f.opacity]) - }), - Body::Group { clip, children } if clip.is_none_or(|c| self.inside(c)) => { - if ko { - self.knockout(children, b, backdrop) - } else { - children.iter().fold(b, |acc, c| self.draw(c, acc, false, false, nested)) - } - } - _ => b, - } - } - - /// The children of a knockout group over `b`: each first erases what the ones below it drew - /// by its knockout shape, then adds itself composited against the group's backdrop. - fn knockout(&mut self, children: &'a [Elem], b: Px, backdrop: Option) -> Px { - fn elements<'e>(children: &'e [Elem], out: &mut Vec<&'e Elem>) { - for c in children { - match &c.body { - Body::Group { children, .. } if c.pass_through => elements(children, out), - _ => out.push(c), - } - } - } - let mut els = vec![]; - elements(children, &mut els); - let mut acc = premul(b); - for c in els { - let s = self.draw(c, CLEAR, true, !c.knockout_shape, true)[3]; - let own = match backdrop { - Some(b0) => premul(self.draw(c, b0, true, false, true)).map(|v| v * s), - None => premul(self.draw(c, CLEAR, true, false, true)), - }; - acc = [0, 1, 2, 3].map(|i| acc[i] * (1.0 - s) + own[i]); - } - unpremul(acc) - } -} +mod analysis; +mod apply; +mod commands; +mod compose; +mod options; +mod presets; +mod report; + +pub(crate) use analysis::see_through_images; +pub(crate) use apply::flatten_document; +pub use commands::specs; +pub use options::FlattenOptions; +pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT}; +pub use report::{FlattenReport, Highlight, Overprints, report}; diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs new file mode 100644 index 000000000..c90ff8aa5 --- /dev/null +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -0,0 +1,152 @@ +use super::*; + +// ---------- plain art ---------- + +/// Converts art into what the compositor works on. +pub(super) struct Scratch { + /// A copy of the document the art comes from (outlining allocates ids in it). + pub(super) doc: Document, + pub(super) brushes: Vec, +} + +impl Scratch { + /// `n` as plain filled shapes: type outlined, symbol instances replaced by their art, live + /// objects evaluated and strokes outlined (brush strokes as their art), each keeping the + /// object's transparency. + pub(super) fn plain(&mut self, n: &Node) -> Node { + if !n.visible { + return n.clone(); + } + let made = match &n.kind { + NodeKind::Text(_) => outlined_text(n), + NodeKind::SymbolInstance { symbol, .. } => { + let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); + art.and_then(|a| effects::outline_art(Some(&self.doc), n, Some(&a))).map(|g| carry(n, g)) + } + // A placed document stays one: its art's resources aren't the document's. + NodeKind::PlacedDocument(_) => None, + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { + let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; + let hooks = vectorcraft_doc::live::Hooks { outline: Some(hook), symbols: Some(&self.doc), ..Default::default() }; + Some(carry(n, vectorcraft_doc::live::expanded_group_hooks(n, hooks))) + } + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => outline_strokes(&mut self.doc, &self.brushes, n), + _ => None, + }; + let mut m = match made { + // The pieces of one object never knock each other out. + Some(m) if m.is_container() => Node { knockout: Knockout::Off, ..m }, + Some(m) => m, + None => n.clone(), + }; + if !matches!(m.kind, NodeKind::Compound { .. }) + && let Some(ch) = m.children_mut() + { + for c in ch.iter_mut() { + *c = Arc::new(self.plain(c)); + } + } + m + } +} + +/// Type as outlines painted like it ([`effects::outline_text`]), keeping its transparency. +pub(super) fn outlined_text(n: &Node) -> Option { + effects::outline_text(n).map(|g| Node { knockout: Knockout::Off, ..carry(n, g) }) +} + +/// `to` (art made from `from`) with `from`'s name, transparency, opacity mask and effects. +fn carry(from: &Node, mut to: Node) -> Node { + to.name = from.name.clone(); + to.opacity = from.opacity; + to.blend = from.blend; + to.isolate = from.isolate; + to.knockout_shape = from.knockout_shape; + to.extras_mut().mask = from.extras().mask.clone(); + to.appearance.effects = from.appearance.effects.clone(); + to +} + +/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque +/// everywhere). +pub(crate) fn see_through_images(doc: &Document) -> BTreeSet { + let see_through = |b: &ImageBlob| { + b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255)) + }; + doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect() +} + +/// `n` shows an image of `keys` (itself or inside). +pub(super) fn draws_image(n: &Node, keys: &BTreeSet) -> bool { + !keys.is_empty() + && n.visible + && match &n.kind { + NodeKind::Image(im) => keys.contains(&im.key), + _ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))), + } +} + +fn visible_fx(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.visible) +} + +/// Effects on the object or on one of its fills or strokes (raster effects, once baked). +pub(super) fn has_effects(n: &Node) -> bool { + visible_fx(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible_fx(i.effects())) +} + +pub(super) fn masked(n: &Node) -> bool { + n.extras().mask.as_ref().is_some_and(|m| !m.disabled) +} + +/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). +pub(super) fn reach(n: &Node) -> Option { + if !n.visible { + return None; + } + let b = match n.children() { + Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, + _ => n.visual_bounds()?, + }; + let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); + Some(b.inflate(fx + 1.0, fx + 1.0)) +} + +/// Indices of `bounds` grouped by overlap (transitively), each group in index order. +pub(super) fn overlapping(bounds: &[Option]) -> Vec> { + fn find(p: &mut [usize], mut i: usize) -> usize { + while p[i] != i { + p[i] = p[p[i]]; + i = p[i]; + } + i + } + let mut parent: Vec = (0..bounds.len()).collect(); + let mut order: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.map(|b| (i, b))).collect(); + order.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); + // Sweep left to right, comparing each box with those still open at its left edge. + let mut open: Vec<(usize, Rect)> = vec![]; + for (i, b) in order { + open.retain(|(_, o)| o.x1 >= b.x0); + for (j, o) in &open { + if o.y0 <= b.y1 && b.y0 <= o.y1 { + let (a, c) = (find(&mut parent, i), find(&mut parent, *j)); + parent[a.max(c)] = a.min(c); + } + } + open.push((i, b)); + } + let mut groups: Vec> = vec![]; + let mut slot: Vec> = vec![None; bounds.len()]; + for i in 0..bounds.len() { + let r = find(&mut parent, i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups +} diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs new file mode 100644 index 000000000..04923b6d4 --- /dev/null +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -0,0 +1,460 @@ +use super::analysis::{Scratch, outlined_text, overlapping, reach}; +use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE}; +use super::options::FlattenOptions; +use super::*; + +// ---------- plan ---------- + +/// What the flattening does, worked out before the document changes. +pub(super) struct Plan { + pub(super) flat: Vec, + /// Objects without transparency (they stay, but for the outline options). + pub(super) kept: Vec, + /// The objects with transparency of their own. + pub(super) transparent: Vec, +} + +/// One group of overlapping objects with transparency and what replaces it. +pub(super) struct Flat { + /// Its objects in paint order; the result takes the place of the last. + pub(super) roots: Vec, + pub(super) regions: Regions, + /// Where its image goes, and the image (once rendered). + pub(super) area: Option, + raster: Option, +} + +/// Where a group's image goes. +pub(super) struct Area { + rect: Rect, + ppi: f64, + /// The complex regions it is clipped to (Clip Complex Regions). + clip: Option, + /// The raster/vector balance rasterizes the whole group (it splits into too many regions). + pub(super) whole: bool, +} + +impl Area { + /// The area's outline. + pub(super) fn outline(&self) -> PathData { + self.clip.clone().unwrap_or_else(|| shapes::rectangle(self.rect)) + } +} + +/// Flat-colour regions: their outlines and paints. +pub(super) type Regions = Vec<(PathData, RegionFill)>; + +/// A flat-colour region's paint. +pub(super) struct RegionFill { + paint: Paint, + alpha: f32, + overprint: bool, +} + +/// A rendered image of complex regions, clipped to them or not. +struct Raster { + blob: ImageBlob, + width: u32, + height: u32, + xf: Affine, + clip: Option, +} + +/// The flattening of `roots` with `o`; `render` renders the images (the preview only needs where +/// they go). Fails only when an image can't be encoded. +pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> Result { + let tmp = isolated_doc(src, roots.iter().filter_map(|id| src.node(*id).cloned()).collect()); + // Geometry effects first: what is left on the art are raster effects. + let baked = effects::bake_document(&tmp).unwrap_or(tmp); + let art: Vec = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect(); + let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; + let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); + let reaches: Vec> = plain.iter().map(reach).collect(); + let see_through: Vec = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect(); + let cmyk = src.color_mode == ColorMode::Cmyk; + let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; + // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). + vectorcraft_doc::cancel::check()?; + for g in overlapping(&reaches) { + vectorcraft_doc::cancel::check()?; + let ids: Vec = g.iter().map(|&i| roots[i]).collect(); + let group = g.iter().any(|&i| see_through[i]).then(|| { + let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); + flatten_group(&plain, o, cmyk) + }); + let Some((regions, area)) = group.flatten() else { + out.kept.extend(ids); + continue; + }; + let raster = match area.as_ref().filter(|_| render) { + Some(a) => { + let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); + Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?) + } + None => None, + }; + out.flat.push(Flat { roots: ids, regions, area, raster }); + } + Ok(out) +} + +/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as +/// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. +fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { + let mut b = Builder { cmyk, ..Default::default() }; + let elems: Vec = plain.iter().filter_map(|n| b.elem(n)).collect(); + if b.shapes.is_empty() { + return None; + } + // An unclipped full raster needs no regions. + let whole = o.balance <= 0.0 && !o.clip_complex_regions; + let regions = if whole { vec![] } else { po::regions(&b.shapes) }; + // An arrangement that failed (degenerate geometry) leaves only rasterizing. + let all = regions.is_empty() || o.rasterize_all(regions.len()); + let mut vector: Vec<(usize, RegionFill)> = vec![]; + let mut complex: Vec = vec![]; + let mut gradients_only = !all; + for (k, r) in regions.iter().enumerate() { + if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { + gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); + complex.push(k); + } else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) { + vector.push((k, fill)); + } + } + let bounds = |ks: &[usize]| ks.iter().filter_map(|&k| regions[k].path.bounds()).reduce(|a, b| a.union(b)); + if !o.clip_complex_regions && !all { + // A rectangle of raster takes in every region it overlaps. + while let Some(r) = bounds(&complex) { + let before = complex.len(); + let rect = shapes::rectangle(r); + vector.retain(|(k, _)| { + let path = ®ions[*k].path; + let hit = path.bounds().is_some_and(|b| b.intersect(r).area() > 0.0) + && po::area(&po::boolean(path, FillRule::NonZero, &rect, FillRule::NonZero, po::BoolOp::Intersect), FillRule::NonZero) > 1e-6; + if hit { + complex.push(*k); + } + !hit + }); + if complex.len() == before { + break; + } + gradients_only = false; + } + } + let raster_rect = if all { plain.iter().filter_map(|n| reach(n)).reduce(|a, b| a.union(b)) } else { bounds(&complex) }; + let area = raster_rect.map(|rect| { + let clip = o.clip_complex_regions.then(|| PathData::new(complex.iter().flat_map(|&k| regions[k].path.subpaths.iter().cloned()).collect())); + let ppi = if gradients_only { o.gradient_ppi } else { o.line_art_ppi }; + Area { rect, ppi, clip: clip.filter(|c| !c.is_empty()), whole: all } + }); + let vector = vector.into_iter().map(|(k, f)| (regions[k].path.clone(), f)).collect(); + Some((vector, area)) +} + +/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where +/// nothing paints it. +fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { + let mut c = Composer { sources, top: None, plane: 0 }; + let alone = c.eval(elems, CLEAR); + if alone[3] < 0.5 / 255.0 { + return None; + } + let start = if o.preserve_alpha { CLEAR } else { WHITE }; + let px = if o.preserve_alpha { + alone + } else { + c.top = None; + c.eval(elems, start) + }; + let rgb = [px[0], px[1], px[2]]; + // The K plane composites the same way. + let k = cmyk.then(|| { + c.plane = 1; + c.eval(elems, start)[0] + }); + // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3); + let same = |f: &Fill| px[3] >= 1.0 - 1e-4 && near(&f.planes[0], &rgb) && k.is_none_or(|k| near(&f.planes[1][..1], &[k])); + let color = match k { + Some(k) => { + let [c, m, y, k] = planes_cmyk(rgb, k); + Color::cmyk(c, m, y, k) + } + None => Color::rgb(rgb[0], rgb[1], rgb[2]), + }; + let (paint, overprint) = match c.top { + Some(f) if o.preserve_overprints && same(f) => (f.paint.clone(), f.overprint), + _ => (Paint::solid(color), false), + }; + Some(RegionFill { paint, alpha: px[3], overprint }) +} + +/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out +/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and +/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink +/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. +/// Fails when the image can't be encoded. +fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { + let mut scale = ppi / 72.0; + scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + let px = 1.0 / scale; + let rect = rect.inflate(px, px); + let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); + let region = Rect::new( + rect.x0, + rect.y0, + rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale, + rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale, + ); + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + let tmp = isolated_doc(doc, art.to_vec()); + let mut r = vectorcraft_render::Renderer::new(); + let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha; + if no_alpha && doc.color_mode == ColorMode::Cmyk { + // Drawn as inks over the paper: as screen colours, the file would separate them again, + // and a CMYK colour comes back with other inks (K moves into C, M and Y). + let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() }; + let inks = r.render_region_inks(&tmp, region, scale, &opts); + vectorcraft_doc::cancel::check()?; + let (width, height) = vectorcraft_render::region_pixels(region, scale); + // Past the size of a CMYK image: drawn in screen colours, below. + if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) { + return Ok(Raster { blob, width, height, xf, clip }); + } + } + let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha); + // The drawing stops early when cancelled: this image is not whole. + vectorcraft_doc::cancel::check()?; + if no_alpha { + // Without transparency the clip alone shapes the image: it is opaque, over white (its + // edge pixels too). + for p in img.pixels.as_chunks_mut::<4>().0 { + p[3] = 255; + } + } else if !o.preserve_alpha || !o.anti_alias { + // The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial. + let cover: Vec = art + .iter() + .map(|n| { + let mut c = n.clone(); + opaque(&mut c); + c + }) + .collect(); + let cover = r.render_region(&isolated_doc(doc, cover), region, scale, false); + for (px, c) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(cover.pixels.as_chunks::<4>().0) { + let k = c[3]; + let k2 = if o.anti_alias || k == 0 || k == 255 { + k + } else if k >= 128 { + 255 + } else { + 0 + }; + if o.preserve_alpha { + let f = k2 as f32 / k.max(1) as f32; + for v in px.iter_mut() { + *v = (*v as f32 * f).round().min(255.0) as u8; + } + } else { + let white = 255 - k2; + for v in &mut px[..3] { + *v = v.saturating_sub(white).min(k2); + } + px[3] = k2; + } + } + } + let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?; + Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) +} + +/// `n` at full opacity with Normal blending throughout (objects, fills and strokes). +pub(super) fn opaque(n: &mut Node) { + n.opacity = 1.0; + n.blend = BlendMode::Normal; + for i in &mut n.appearance.items { + match i { + AppearanceItem::Fill(f) => (f.opacity, f.blend) = (1.0, BlendMode::Normal), + AppearanceItem::Stroke(s) => (s.opacity, s.blend) = (1.0, BlendMode::Normal), + } + } + if let Some(ch) = n.children_mut() { + for c in ch { + opaque(Arc::make_mut(c)); + } + } +} + +/// `doc` with everything it draws flattened with `o`, as formats without transparency (EPS) +/// write it: every visible object of the visible layers, locked ones too (a clipping layer's +/// clipping path stays). `None` when that changes nothing. +pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result> { + let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints; + if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) { + return Ok(None); + } + let mut roots = vec![]; + for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) { + let children = l.children().map_or(&[][..], Vec::as_slice); + roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible).map(|c| c.id)); + } + let plan = plan(doc, &roots, o, true)?; + if plan.flat.is_empty() && !plan.kept.iter().any(|id| doc.node(*id).is_some_and(|n| kept_work(n, o))) { + return Ok(None); + } + let mut d = doc.clone(); + apply(&mut d, &mut vectorcraft_doc::Selection::default(), plan, o)?; + Ok(Some(d)) +} + +// ---------- apply ---------- + +pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec, usize, usize)> { + let (mut vector, mut rasterized) = (0, 0); + let (mut ids, mut flat) = (vec![], vec![]); + // The groups replace their objects in one pass at the end. When an object is listed twice or + // lies inside another listed one, each group replaces its objects as it is made instead. + let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::>()); + for f in plan.flat { + // A group always has objects. + let Some(&top) = f.roots.last() else { continue }; + let mut children = vec![]; + // The image goes under the regions, reaching a pixel past its own: their edges then fall on + // matching pixels instead of on what is behind the group. + if let Some(r) = f.raster { + children.push(Arc::new(raster_node(d, r))); + rasterized += 1; + } + for (path, fill) in f.regions { + let mut n = shape_node(d, path, None); + n.appearance = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: fill.overprint, ..FillLayer::new(fill.paint) })], + ..Appearance::default() + } + .into(); + n.opacity = fill.alpha.min(1.0); + children.push(Arc::new(n)); + vector += 1; + } + let g = Node::group(d.alloc_id(), children); + ids.push(g.id); + if one_pass { + // The group replaces the topmost object, and the other objects are removed. + flat.extend(f.roots.iter().filter(|r| **r != top).map(|r| (*r, vec![]))); + flat.push((top, vec![g])); + } else { + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + d.insert(par, idx + 1, g)?; + for r in &f.roots { + d.remove(*r)?; + } + } + } + if !flat.is_empty() { + d.insert_beside(flat, true)?; + } + let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; + for mut id in plan.kept { + if o.text_to_outlines { + id = outline_texts_under(d, id)?; + } + if o.strokes_to_outlines { + id = outline_strokes_under(d, &brushes, id)?.0; + } + if !o.preserve_overprints + && let Some(n) = d.node_mut(id) + { + drop_overprints(n); + } + ids.push(id); + } + ids.sort_by_key(|id| d.index_path(*id)); + sel.set(ids.iter().copied()); + Ok((ids, vector, rasterized)) +} + +/// An image of `r` (in a clip group when it is clipped). +fn raster_node(d: &mut Document, r: Raster) -> Node { + let key = unique_key(d, "flattened"); + d.images.insert(key.clone(), r.blob); + let image = Node::new( + d.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key, + width: r.width, + height: r.height, + xf: r.xf, + link: None, + placement: Default::default(), + pixels: None, + })), + ); + let Some(path) = r.clip else { return image }; + crate::cmd::rasterfx::clip_group(d, path, FillRule::NonZero, image) +} + +/// Replace the type under `root` by its outlines: → the id now standing for `root`. +fn outline_texts_under(d: &mut Document, root: NodeId) -> Result { + let mut texts = vec![]; + if let Some(n) = d.node(root) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Text(_)) { + texts.push(c.clone()); + } + }); + } + let mut out = root; + for t in texts { + let Some(o) = outlined_text(&t) else { continue }; + let o = d.reid(&o); + let nid = o.id; + *d.node_mut(t.id).ok_or(EngineError::NoNode(t.id))? = o; + if t.id == root { + out = nid; + } + } + Ok(out) +} + +/// Whether the outline and overprint options change `n`, an object without transparency. +pub(super) fn kept_work(n: &Node, o: &FlattenOptions) -> bool { + let mut any = false; + n.walk(&mut |c| { + any |= (o.text_to_outlines && matches!(c.kind, NodeKind::Text(_))) + || (o.strokes_to_outlines && stroked(c)) + || (!o.preserve_overprints && overprints(c)); + }); + any +} + +/// Whether `n` is a path with a visible stroke (outlining turns it into a fill). +pub(super) fn stroked(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0)) +} + +fn overprints(n: &Node) -> bool { + n.appearance.items.iter().any(AppearanceItem::overprint) + || matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke)) +} + +fn drop_overprints(n: &mut Node) { + if overprints(n) { + for i in &mut n.appearance.items { + *i.overprint_mut() = false; + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + (r.style.overprint_fill, r.style.overprint_stroke) = (false, false); + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch { + drop_overprints(Arc::make_mut(c)); + } + } +} diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs new file mode 100644 index 000000000..d252c2ff4 --- /dev/null +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -0,0 +1,72 @@ +use super::options::flatten; +use super::presets::{presets_delete, presets_export, presets_import, presets_list, presets_save}; +use super::report::preview; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.flattenTransparency", + "Flatten Transparency…", + ["Object"], + None, + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + has_doc, + flatten + ), + cmd!( + query "flattener.presets.list", + "Transparency Flattener Presets", + [], + None, + "{} → {presets: [{name, builtIn, options}]} the built-in presets (High, Medium and Low Resolution), then the saved ones; any of these names works as `preset` wherever flattener options are taken", + always, + presets_list + ), + cmd!( + query "flattener.presets.save", + "Save Transparency Flattener Preset", + [], + None, + "{name?: (default: a new \"Flattener Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else medium), …options (the keys of object.flattenTransparency, at the top level or in `options`)} create or change a saved preset (built-in ones can't change) → {name, options, created}", + always, + presets_save + ), + cmd!( + query "flattener.presets.delete", + "Delete Transparency Flattener Preset", + [], + None, + "{name} delete a saved preset (built-in ones stay) → {deleted: name}", + always, + presets_delete + ), + cmd!( + query "flattener.presets.import", + "Import Transparency Flattener Presets", + [], + None, + "{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else the imported ones get a number)} add the presets of a .vcflattener file (as flattener.presets.export writes) to the saved ones → {imported: [names]}", + always, + presets_import + ), + cmd!( + query "flattener.presets.export", + "Export Transparency Flattener Presets", + [], + None, + "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcflattener file (JSON) → {path, count}; without path → {data: the file's text, count}", + always, + presets_export + ), + cmd!( + query "flattener.preview", + "Flattener Preview", + [], + None, + "{highlight?: \"none\"|\"rasterizedRegions\" (areas the raster/vector balance rasterizes whole)|\"transparentObjects\"|\"allAffected\"|\"expandedPatterns\" (pattern art taking part)|\"outlinedStrokes\"|\"outlinedText\"|\"allRasterized\" (default none), overprints?: \"preserve\"|\"simulate\"|\"discard\" (simulate and discard flatten without preserveOverprints), preset?, …options as object.flattenTransparency, ids?|id?: (default: the whole document)} what flattening would do, without changing anything → {highlight, regions: [{bounds: {x, y, width, height}, id?}] (the highlighted objects, or areas), counts: {transparentObjects, allAffected, expandedPatterns, outlinedStrokes, outlinedText, rasterizedRegions, allRasterized, vectorRegions}, options}", + has_doc, + preview + ), + ] +} diff --git a/crates/engine/src/cmd/flatten/compose.rs b/crates/engine/src/cmd/flatten/compose.rs new file mode 100644 index 000000000..ac8fcf41c --- /dev/null +++ b/crates/engine/src/cmd/flatten/compose.rs @@ -0,0 +1,256 @@ +use super::analysis::{has_effects, masked, reach}; +use super::*; + +// ---------- the compositor ---------- + +/// What an arrangement shape stands for. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Kind { + /// Solid fills and clipping paths: regions inside get a flat colour. + Vector, + /// A gradient or mesh: regions inside are rasterized at the gradient resolution. + Gradient, + /// Anything else that has no single colour (patterns, images, masks, raster effects). + Other, +} + +/// A solid fill as the compositor uses it. +pub(super) struct Fill { + /// What it composites: its screen colour (twice), or in a CMYK document its two ink planes + /// ([`cmyk_planes`]). + pub(super) planes: [[f32; 3]; 2], + opacity: f32, + blend: BlendMode, + pub(super) paint: Paint, + pub(super) overprint: bool, +} + +/// One object of the art being flattened, with the transparency the renderer gives it. +pub(super) struct Elem { + opacity: f32, + blend: BlendMode, + isolate: bool, + knockout: Knockout, + knockout_shape: bool, + /// Blending inside reaches the backdrop ([`Node::blends_through`]). + blends: bool, + /// Inside a knockout group its children are elements of their own ([`Node::passes_knockout_through`]). + pass_through: bool, + body: Body, +} + +enum Body { + /// Solid fills (bottom first) of arrangement shape `shape`. + Fills { shape: usize, fills: Vec }, + /// A group's children, clipped to arrangement shape `clip` in a clip group. + Group { clip: Option, children: Vec }, + /// Art without a flat colour: its regions are rasterized, so it never composites here. + Complex, +} + +/// Builds the compositor's objects and the arrangement shapes they cover. +#[derive(Default)] +pub(super) struct Builder { + pub(super) shapes: Vec, + pub(super) kinds: Vec, + /// Building a CMYK document's art. + pub(super) cmyk: bool, +} + +impl Builder { + /// What colour `c` composites as ([`Fill::planes`]). + fn planes(&self, c: &Color) -> [[f32; 3]; 2] { + if self.cmyk { + let cms = vectorcraft_color::cms::active(); + cmyk_planes(cms.to_cmyk(c, cms.settings().intent)) + } else { + [c.to_rgb(); 2] + } + } + + fn shape(&mut self, path: PathData, rule: FillRule, kind: Kind) -> Option { + path.bounds()?; + self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); + self.kinds.push(kind); + Some(self.shapes.len() - 1) + } + + pub(super) fn elem(&mut self, n: &Node) -> Option { + if !n.visible { + return None; + } + let body = if masked(n) || has_effects(n) { + self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Other)?; + Body::Complex + } else { + match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + let (clip, rest) = if n.clips() { + let (first, rest) = children.split_first()?; + // Nothing to clip by hides the clipped art. + let (bp, rule) = effects::clip_outline(first)?; + (Some(self.shape(PathData::from_bezpath(&bp), rule, Kind::Vector)?), rest) + } else { + (None, &children[..]) + }; + Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() } + } + NodeKind::Path { guide: true, .. } => return None, + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let (path, rule) = node_path(n)?; + let mut fills = vec![]; + for item in n.appearance.items.iter().filter(|i| i.is_fill() && i.visible() && !i.paint().is_none()) { + match item.paint() { + Paint::Solid { color, .. } => fills.push(Fill { + planes: self.planes(color), + opacity: item.opacity(), + blend: item.blend(), + paint: item.paint().clone(), + overprint: item.overprint(), + }), + p => { + let kind = if matches!(p, Paint::Gradient(_)) { Kind::Gradient } else { Kind::Other }; + self.shape(path.clone(), rule, kind); + } + } + } + if fills.is_empty() { Body::Complex } else { Body::Fills { shape: self.shape(path, rule, Kind::Vector)?, fills } } + } + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + self.shape(frame, FillRule::NonZero, Kind::Other)?; + Body::Complex + } + NodeKind::Mesh(_) => { + self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Gradient)?; + Body::Complex + } + _ => { + self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Other)?; + Body::Complex + } + } + }; + Some(Elem { + opacity: n.opacity, + blend: n.blend, + isolate: n.isolate, + knockout: n.knockout, + knockout_shape: n.knockout_shape, + blends: n.blends_through(), + pass_through: n.passes_knockout_through(), + body, + }) + } +} + +/// A straight (not premultiplied) RGBA colour in 0..=1. +pub(super) type Px = [f32; 4]; + +pub(super) const CLEAR: Px = [0.0; 4]; + +pub(super) const WHITE: Px = [1.0; 4]; + +fn premul(p: Px) -> Px { + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] +} + +fn unpremul(q: Px) -> Px { + if q[3] <= 0.0 { CLEAR } else { [q[0] / q[3], q[1] / q[3], q[2] / q[3], q[3].min(1.0)] } +} + +/// Composites the objects covering one atomic region exactly as the renderer composites pixels +/// (its transparency groups, non-isolated groups, knockout groups and clip groups): the same +/// paints cover the whole region, so one colour stands for all of it. +pub(super) struct Composer<'a> { + /// Arrangement shapes covering the region, ascending. + pub(super) sources: &'a [usize], + /// The last solid fill composited. + pub(super) top: Option<&'a Fill>, + /// Which of the fills' [`Fill::planes`] composites. + pub(super) plane: usize, +} + +impl<'a> Composer<'a> { + fn inside(&self, shape: usize) -> bool { + self.sources.binary_search(&shape).is_ok() + } + + /// `elems` (paint order) composited over `start`. + pub(super) fn eval(&mut self, elems: &'a [Elem], start: Px) -> Px { + elems.iter().fold(start, |acc, e| self.draw(e, acc, false, false, false)) + } + + /// `e` drawn over backdrop `b` inside a group that knocks out when `enclosing`. `as_shape` + /// draws it as its knockout shape (full object opacity); `nested` is drawing inside a layer, + /// where a non-isolated group can't copy its backdrop and is drawn isolated. + fn draw(&mut self, e: &'a Elem, b: Px, enclosing: bool, as_shape: bool, nested: bool) -> Px { + let opacity = if as_shape { 1.0 } else { e.opacity }; + let ko = matches!(e.body, Body::Group { .. }) && e.knockout.resolve(enclosing); + if opacity >= 1.0 && e.blend == BlendMode::Normal && !e.isolate && !ko { + return self.content(e, b, ko, None, nested); + } + if e.blends && !e.isolate && !nested { + // Drawn over a copy of the backdrop, so blending inside reaches it. + let c = self.content(e, b, ko, Some(b), false); + if e.blend == BlendMode::Normal { + let (pb, pc) = (premul(b), premul(c)); + return unpremul([0, 1, 2, 3].map(|i| pb[i] + (pc[i] - pb[i]) * opacity)); + } + // The group's own colour: the backdrop's share taken out by the group's coverage. + let (pc, pa, pb) = (premul(c), premul(self.content(e, CLEAR, ko, None, false)), premul(b)); + let keep = 1.0 - pa[3]; + let g = [0, 1, 2].map(|i| (pc[i] - keep * pb[i]).clamp(0.0, pa[3])); + let g = unpremul([g[0], g[1], g[2], pa[3]]); + return composite(e.blend, b, [g[0], g[1], g[2], g[3] * opacity]); + } + let inner_nested = !(e.blends && (e.isolate || !nested)); + let g = self.content(e, CLEAR, ko, None, inner_nested); + composite(e.blend, b, [g[0], g[1], g[2], g[3] * opacity]) + } + + /// What `e` draws over `b` inside its group (`ko`: its children knock each other out, against + /// `backdrop` when the group was drawn over a copy of it). + fn content(&mut self, e: &'a Elem, b: Px, ko: bool, backdrop: Option, nested: bool) -> Px { + match &e.body { + Body::Fills { shape, fills } if self.inside(*shape) => fills.iter().fold(b, |acc, f| { + self.top = Some(f); + let [r, g, b] = f.planes[self.plane]; + composite(f.blend, acc, [r, g, b, f.opacity]) + }), + Body::Group { clip, children } if clip.is_none_or(|c| self.inside(c)) => { + if ko { + self.knockout(children, b, backdrop) + } else { + children.iter().fold(b, |acc, c| self.draw(c, acc, false, false, nested)) + } + } + _ => b, + } + } + + /// The children of a knockout group over `b`: each first erases what the ones below it drew + /// by its knockout shape, then adds itself composited against the group's backdrop. + fn knockout(&mut self, children: &'a [Elem], b: Px, backdrop: Option) -> Px { + fn elements<'e>(children: &'e [Elem], out: &mut Vec<&'e Elem>) { + for c in children { + match &c.body { + Body::Group { children, .. } if c.pass_through => elements(children, out), + _ => out.push(c), + } + } + } + let mut els = vec![]; + elements(children, &mut els); + let mut acc = premul(b); + for c in els { + let s = self.draw(c, CLEAR, true, !c.knockout_shape, true)[3]; + let own = match backdrop { + Some(b0) => premul(self.draw(c, b0, true, false, true)).map(|v| v * s), + None => premul(self.draw(c, CLEAR, true, false, true)), + }; + acc = [0, 1, 2, 3].map(|i| acc[i] * (1.0 - s) + own[i]); + } + unpremul(acc) + } +} diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs new file mode 100644 index 000000000..242f9f259 --- /dev/null +++ b/crates/engine/src/cmd/flatten/options.rs @@ -0,0 +1,180 @@ +use super::analysis::draws_image; +use super::apply::{apply, kept_work, plan}; +use super::presets::{FlattenerPreset, saved_preset}; +use super::report::target_roots; +use super::*; + +/// Object → Flatten Transparency settings: a preset's, adjusted by the command's params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FlattenOptions { + /// Raster/vector balance, 0–100: 0 rasterizes all flattened art; 100 keeps everything vector + /// that can be; in between, groups that split into more regions than it allows are rasterized. + pub balance: f64, + /// Resolution (ppi) of rasterized art. + pub line_art_ppi: f64, + /// Resolution (ppi) of rasterized areas that only gradients and meshes reach. + pub gradient_ppi: f64, + /// Outline all type, also where it isn't transparent. + pub text_to_outlines: bool, + /// Outline all strokes, also where they aren't transparent. + pub strokes_to_outlines: bool, + /// Clip rasterized areas to their region outlines (else they are rectangles). + pub clip_complex_regions: bool, + /// Smooth the edges of rasterized areas. + pub anti_alias: bool, + /// Keep the art's own alpha: composite over nothing instead of over white. + pub preserve_alpha: bool, + /// Areas showing a single paint keep it (spot and process colours, swatches) with its + /// overprint; off, the flattened objects lose their overprints. + pub preserve_overprints: bool, + /// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with + /// see-through pixels (these keys, [`see_through_images`]) count as transparency, and + /// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink + /// amounts). `None` for ordinary flattening. Not a preset option. + #[serde(skip)] + pub no_transparency: Option>, +} + +impl Default for FlattenOptions { + fn default() -> Self { + Self::builtin("medium") + } +} + +impl FlattenOptions { + /// Preset ids, finest first. + pub const PRESETS: [&'static str; 3] = ["high", "medium", "low"]; + + /// Display name of a preset id. + pub fn preset_label(id: &str) -> Option<&'static str> { + Some(match id { + "high" => "High Resolution", + "medium" => "Medium Resolution", + "low" => "Low Resolution", + _ => return None, + }) + } + + /// A preset by id or display name, any case. + pub fn preset(name: &str) -> Option { + let key = name.trim().to_ascii_lowercase(); + let id = Self::PRESETS.into_iter().find(|id| key == *id || Self::preset_label(id).is_some_and(|l| l.eq_ignore_ascii_case(&key)))?; + Some(Self::builtin(id)) + } + + /// The built-in preset `id` (one of [`Self::PRESETS`]; anything else is low). + fn builtin(id: &str) -> Self { + let (balance, line_art_ppi, gradient_ppi, clip_complex_regions, anti_alias) = match id { + "high" => (100.0, 1200.0, 300.0, true, false), + "medium" => (75.0, 300.0, 300.0, false, true), + _ => (75.0, 300.0, 150.0, false, true), + }; + Self { + balance, + line_art_ppi, + gradient_ppi, + text_to_outlines: false, + strokes_to_outlines: false, + clip_complex_regions, + anti_alias, + preserve_alpha: false, + preserve_overprints: true, + no_transparency: None, + } + } + + /// The built-in presets, finest first. + pub fn builtin_presets() -> Vec { + Self::PRESETS + .into_iter() + .filter_map(|id| Some(FlattenerPreset { name: Self::preset_label(id)?.into(), options: Self::preset(id)? })) + .collect() + } + + /// A built-in preset (by id or display name) or one of `saved` (by name), any case. + pub fn find_preset(name: &str, saved: &[FlattenerPreset]) -> Option { + Self::preset(name).or_else(|| saved_preset(saved, name).map(|p| p.options.clone())) + } + + /// The options `p` asks for, with the built-in presets only ([`Self::from_params_with`]). + pub fn from_params(p: &Value) -> std::result::Result { + Self::from_params_with(p, &[]) + } + + /// The options an export flattens with: preset `name` (built-in or one of `saved`; default + /// medium) with the export's `flattener` `options` over it. + pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result { + let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) }); + if let Some(name) = name { + q["preset"] = json!(name); + } + Self::from_params_with(&q, saved) + } + + /// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by + /// the option keys given at the top level or in `options`. + pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result { + let base = match str_param(p, "preset") { + Some(name) => Self::find_preset(name, saved) + .ok_or_else(|| format!("unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list)"))?, + None => Self::default(), + }; + let mut v = serde_json::to_value(&base).map_err(|e| e.to_string())?; + for src in [Some(p), p.get("options")].into_iter().flatten().filter_map(Value::as_object) { + for (k, val) in src { + if let Some(slot) = v.get_mut(k) { + *slot = val.clone(); + } + } + } + let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + if !(0.0..=100.0).contains(&o.balance) { + return Err("balance must be between 0 and 100".into()); + } + if ![o.line_art_ppi, o.gradient_ppi].iter().all(|r| (1.0..=2400.0).contains(r)) { + return Err("resolutions must be between 1 and 2400 ppi".into()); + } + Ok(o) + } + + /// `n` shows an image whose see-through pixels count as transparency. + pub(super) fn draws_see_through_image(&self, n: &Node) -> bool { + self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys)) + } + + /// Whether a group that splits into `regions` atomic regions is rasterized whole. + pub(super) fn rasterize_all(&self, regions: usize) -> bool { + self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0)) + } +} + +pub(super) fn flatten(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.flattenTransparency"; + let o = s.flatten_options(p).map_err(|m| bad(C, m))?; + let roots = target_roots(s, p)?; + if roots.is_empty() { + return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into())); + } + let src = s.doc()?.doc.clone(); + let failed = vectorcraft_render::decode_failures(); + let plan = plan(&src, &roots, &o, true)?; + let warning = crate::cmd::rasterfx::undecoded_warning(failed); + let options = serde_json::to_value(&o).unwrap_or_default(); + if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) { + return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::>(), "rasterized": 0, "vector": 0, "options": options })); + } + let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| { + let threaded = crate::cmd::threads::frames(d); + let out = apply(d, sel, plan, &o)?; + // Frames of threaded text that were outlined or flattened leave their threads with the + // text they show. + crate::cmd::threads::leave_threads(d, &threaded); + Ok(out) + })?; + let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "rasterized": rasterized, "vector": vector, "options": options }); + if let Some(w) = warning { + out["warning"] = json!(w); + } + Ok(out) +} diff --git a/crates/engine/src/cmd/flatten/presets.rs b/crates/engine/src/cmd/flatten/presets.rs new file mode 100644 index 000000000..6e92f4ac5 --- /dev/null +++ b/crates/engine/src/cmd/flatten/presets.rs @@ -0,0 +1,181 @@ +use super::options::FlattenOptions; +use super::*; + +// ---------- presets ---------- + +/// A named set of flattener options: a built-in preset or a saved one +/// ([`crate::Prefs::flattener_presets`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlattenerPreset { + pub name: String, + #[serde(default)] + pub options: FlattenOptions, +} + +/// What `flattener.presets.export` writes and `flattener.presets.import` reads. +#[derive(Serialize, Deserialize)] +struct PresetFile { + format: String, + #[serde(default)] + presets: Vec, +} + +/// The `format` of a presets file, also its extension. +pub const PRESET_FORMAT: &str = "vcflattener"; + +/// The extensions of presets files (opening one imports it). +pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; + +pub(super) fn saved_preset<'a>(saved: &'a [FlattenerPreset], name: &str) -> Option<&'a FlattenerPreset> { + saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())) +} + +impl Session { + /// Every flattener preset: the built-in ones, then the saved ones. + pub fn flattener_presets(&self) -> Vec { + let mut v = FlattenOptions::builtin_presets(); + v.extend(self.prefs.flattener_presets.iter().cloned()); + v + } + + /// The preset `name` names: a built-in one (by id or display name) or a saved one, any case. + pub fn flattener_preset(&self, name: &str) -> Option { + let key = name.trim(); + let key = FlattenOptions::preset_label(&key.to_ascii_lowercase()).unwrap_or(key); + self.flattener_presets().into_iter().find(|p| p.name.eq_ignore_ascii_case(key)) + } + + /// The flattener options `p` asks for: a `preset` by name, built-in or saved, adjusted by the + /// option keys ([`FlattenOptions::from_params_with`]). + pub fn flatten_options(&self, p: &Value) -> std::result::Result { + FlattenOptions::from_params_with(p, &self.prefs.flattener_presets) + } + + /// The first free "Flattener Preset N": the name a new preset gets. + pub fn new_preset_name(&self) -> String { + (1..).map(|i| format!("Flattener Preset {i}")).find(|n| !self.preset_taken(n, None)).unwrap_or_default() + } + + /// The index of the saved preset `name`. + fn saved_index(&self, name: &str) -> Option { + self.prefs.flattener_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim())) + } + + /// Whether `name` is taken by a built-in preset or a saved one other than the one at `except`. + fn preset_taken(&self, name: &str, except: Option) -> bool { + FlattenOptions::preset(name).is_some() || self.saved_index(name).is_some_and(|i| Some(i) != except) + } +} + +pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result { + let rows = FlattenOptions::builtin_presets() + .into_iter() + .map(|p| (p, true)) + .chain(s.prefs.flattener_presets.iter().cloned().map(|p| (p, false))) + .map(|(p, builtin)| json!({"name": p.name, "builtIn": builtin, "options": p.options})); + Ok(json!({ "presets": rows.collect::>() })) +} + +pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result { + const C: &str = "flattener.presets.save"; + let name = match str_param(p, "name").map(str::trim) { + Some("") => return Err(bad(C, "`name` is empty")), + Some(n) => n.to_string(), + None => s.new_preset_name(), + }; + if FlattenOptions::preset(&name).is_some() { + return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name"))); + } + let at = s.saved_index(&name); + // A saved preset changes from its own options unless `preset` says where to start. + let mut q = p.clone(); + if let (Some(i), Some(o), None) = (at, q.as_object_mut(), str_param(p, "preset")) { + o.insert("preset".into(), json!(s.prefs.flattener_presets[i].name)); + } + let options = s.flatten_options(&q).map_err(|m| bad(C, m))?; + let name = match str_param(p, "newName").map(str::trim) { + Some("") => return Err(bad(C, "`newName` is empty")), + Some(n) if s.preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))), + Some(n) => n.to_string(), + None => at.map_or(name, |i| s.prefs.flattener_presets[i].name.clone()), + }; + let preset = FlattenerPreset { name: name.clone(), options: options.clone() }; + match at { + Some(i) => s.prefs.flattener_presets[i] = preset, + None => s.prefs.flattener_presets.push(preset), + } + Ok(json!({"name": name, "options": options, "created": at.is_none()})) +} + +pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "flattener.presets.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + if FlattenOptions::preset(name).is_some() { + return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); + } + let i = s.saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?; + Ok(json!({ "deleted": s.prefs.flattener_presets.remove(i).name })) +} + +pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result { + const C: &str = "flattener.presets.export"; + let presets = match p.get("names").and_then(Value::as_array) { + Some(names) => { + let find = |n: &Value| { + let n = n.as_str().unwrap_or_default(); + s.flattener_preset(n).ok_or_else(|| bad(C, format!("no preset named `{n}`"))) + }; + names.iter().map(find).collect::>>()? + } + None => s.prefs.flattener_presets.clone(), + }; + if presets.is_empty() { + return Err(bad(C, "no saved presets to export (name built-in ones in `names`)")); + } + let count = presets.len(); + let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + crate::cmd::fileio::write_file(path, text.as_bytes())?; + Ok(json!({"path": path, "count": count})) + } + None => Ok(json!({"data": text, "count": count})), + } +} + +pub(super) fn presets_import(s: &mut Session, p: &Value) -> Result { + const C: &str = "flattener.presets.import"; + let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => crate::cmd::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, + _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), + }; + let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a flattener presets file: {e}")))?; + if file.format != PRESET_FORMAT { + return Err(bad(C, format!("not a flattener presets file (format `{}`)", file.format))); + } + // Values a hand-edited file may carry are rejected as the commands reject them. + for q in &file.presets { + FlattenOptions::from_params(&json!({ "options": q.options })).map_err(|m| bad(C, format!("`{}`: {m}", q.name)))?; + } + let replace = bool_or(p, "replace", false); + let mut imported = vec![]; + for mut preset in file.presets { + let base = Some(preset.name.trim()).filter(|n| !n.is_empty()).unwrap_or("Flattener Preset").to_string(); + match s.saved_index(&base) { + Some(i) if replace => { + preset.name = s.prefs.flattener_presets[i].name.clone(); + imported.push(preset.name.clone()); + s.prefs.flattener_presets[i] = preset; + } + _ => { + preset.name = unique_name(&base, |n| s.preset_taken(n, None)); + imported.push(preset.name.clone()); + s.prefs.flattener_presets.push(preset); + } + } + } + Ok(json!({ "imported": imported })) +} diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs new file mode 100644 index 000000000..125281d5c --- /dev/null +++ b/crates/engine/src/cmd/flatten/report.rs @@ -0,0 +1,305 @@ +use super::apply::{kept_work, opaque, plan, stroked}; +use super::options::FlattenOptions; +use super::*; + +/// The top-level objects among `ids` (or the selection), in paint order: layers stand for their +/// contents; hidden and locked objects and clipping paths are left alone. +pub(super) fn target_roots(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let mut ids = vec![]; + for id in targets(s, p)? { + match d.node(id) { + Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), + Some(_) => ids.push(id), + None => {} + } + } + let mut keyed: Vec<(Vec, NodeId)> = ids.into_iter().filter_map(|id| d.index_path(id).map(|p| (p, id))).collect(); + keyed.sort(); + keyed.dedup(); + let roots = roots_of(d, keyed.into_iter().map(|(_, id)| id).collect()); + // A clipping path stays where it clips. + let clipping = |id: NodeId| { + d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id)) + }; + let editable = d.editable(); + Ok(roots.into_iter().filter(|id| d.is_visible(*id) && editable(*id) && !clipping(*id)).collect()) +} + +// ---------- preview ---------- + +/// What the Flattener Preview highlights. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Highlight { + /// Nothing: the art in colour. + #[default] + None, + /// Areas the raster/vector balance rasterizes whole. + RasterizedRegions, + /// Objects with transparency of their own. + TransparentObjects, + /// Every object flattening changes. + AllAffected, + /// Objects with pattern paint in the flattened art. + ExpandedPatterns, + /// Strokes that become filled outlines. + OutlinedStrokes, + /// Type that becomes outlines. + OutlinedText, + /// Every area that becomes an image. + AllRasterized, +} + +impl Highlight { + pub const ALL: [Self; 8] = [ + Self::None, + Self::RasterizedRegions, + Self::TransparentObjects, + Self::AllAffected, + Self::ExpandedPatterns, + Self::OutlinedStrokes, + Self::OutlinedText, + Self::AllRasterized, + ]; + + /// The id `flattener.preview` takes. + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::RasterizedRegions => "rasterizedRegions", + Self::TransparentObjects => "transparentObjects", + Self::AllAffected => "allAffected", + Self::ExpandedPatterns => "expandedPatterns", + Self::OutlinedStrokes => "outlinedStrokes", + Self::OutlinedText => "outlinedText", + Self::AllRasterized => "allRasterized", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "None (Color Preview)", + Self::RasterizedRegions => "Rasterized Complex Regions", + Self::TransparentObjects => "Transparent Objects", + Self::AllAffected => "All Affected Objects", + Self::ExpandedPatterns => "Expanded Patterns", + Self::OutlinedStrokes => "Outlined Strokes", + Self::OutlinedText => "Outlined Text", + Self::AllRasterized => "All Rasterized Regions", + } + } + + /// A highlight by id or label, any case. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + Self::ALL.into_iter().find(|h| h.id().eq_ignore_ascii_case(s) || h.label().eq_ignore_ascii_case(s)) + } +} + +/// How the Flattener Preview treats overprints: kept as they are, simulated in the preview, or +/// dropped; only Preserve flattens with Preserve Overprints and Spot Colors. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Overprints { + #[default] + Preserve, + Simulate, + Discard, +} + +impl Overprints { + pub const ALL: [Self; 3] = [Self::Preserve, Self::Simulate, Self::Discard]; + + pub fn id(self) -> &'static str { + match self { + Self::Preserve => "preserve", + Self::Simulate => "simulate", + Self::Discard => "discard", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Preserve => "Preserve", + Self::Simulate => "Simulate", + Self::Discard => "Discard", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|o| o.id().eq_ignore_ascii_case(s.trim()) || o.label().eq_ignore_ascii_case(s.trim())) + } +} + +/// What flattening would do (Window → Flattener Preview, `flattener.preview`). +#[derive(Clone, Debug, Default)] +pub struct FlattenReport { + /// Top-level objects with transparency of their own. + pub transparent: Vec, + /// Top-level objects flattening changes: the overlapping groups with transparency, and those + /// the outline and overprint options change. + pub affected: Vec, + /// Objects with pattern paint in the flattened art. + pub patterns: Vec, + /// Paths whose strokes become filled outlines. + pub strokes: Vec, + /// Type that becomes outlines. + pub text: Vec, + /// Areas the raster/vector balance rasterizes whole. + pub complex: Vec, + /// Every area that becomes an image. + pub rasterized: Vec, + /// Flat-colour regions made. + pub vector: usize, +} + +impl FlattenReport { + /// The objects `h` highlights. + pub fn objects(&self, h: Highlight) -> &[NodeId] { + match h { + Highlight::TransparentObjects => &self.transparent, + Highlight::AllAffected => &self.affected, + Highlight::ExpandedPatterns => &self.patterns, + Highlight::OutlinedStrokes => &self.strokes, + Highlight::OutlinedText => &self.text, + _ => &[], + } + } + + /// The areas `h` highlights. + pub fn areas(&self, h: Highlight) -> &[PathData] { + match h { + Highlight::RasterizedRegions => &self.complex, + Highlight::AllRasterized => &self.rasterized, + _ => &[], + } + } + + /// How many objects or areas each highlight shows. + pub fn counts(&self) -> Value { + json!({ + "transparentObjects": self.transparent.len(), + "allAffected": self.affected.len(), + "expandedPatterns": self.patterns.len(), + "outlinedStrokes": self.strokes.len(), + "outlinedText": self.text.len(), + "rasterizedRegions": self.complex.len(), + "allRasterized": self.rasterized.len(), + "vectorRegions": self.vector, + }) + } + + /// The report as `flattener.preview` answers: the highlighted objects (or areas) of `doc` and + /// the counts. + pub fn to_json(&self, doc: &Document, h: Highlight) -> Value { + let rect = |r: Rect| json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}); + let objects = self.objects(h).iter().filter_map(|id| Some(json!({"id": id.0, "bounds": rect(doc.node(*id)?.visual_bounds()?)}))); + let areas = self.areas(h).iter().filter_map(|p| Some(json!({ "bounds": rect(p.bounds()?) }))); + json!({"highlight": h.id(), "regions": objects.chain(areas).collect::>(), "counts": self.counts()}) + } + + /// The art `h` highlights, opaque, alone in a copy of `doc`: the objects and the areas (filled + /// black). Its coverage is the highlight. `None` when nothing is highlighted. + pub fn highlight_art(&self, doc: &Document, h: Highlight) -> Option { + let (ids, areas) = (self.objects(h), self.areas(h)); + if ids.is_empty() && areas.is_empty() { + return None; + } + let mut tmp = doc.clone(); + let mut art: Vec = ids.iter().filter_map(|id| doc.node(*id).cloned()).collect(); + art.iter_mut().for_each(opaque); + for path in areas { + let mut n = shape_node(&mut tmp, path.clone(), None); + n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into(); + art.push(n); + } + Some(isolated_doc(&tmp, art)) + } +} + +/// What flattening `roots` of `doc` with `o` would do. +pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> Result { + let plan = plan(doc, roots, o, false)?; + let mut r = FlattenReport { transparent: plan.transparent, ..Default::default() }; + // Every object under `id` (but a compound path's own pieces), visible ones only. + fn visit(n: &Node, f: &mut dyn FnMut(&Node)) { + if !n.visible { + return; + } + f(n); + if !matches!(n.kind, NodeKind::Compound { .. }) { + for c in n.children().into_iter().flatten() { + visit(c, f); + } + } + } + let pattern = |n: &Node| n.appearance.items.iter().any(|i| i.visible() && matches!(i.paint(), Paint::Pattern { .. })); + for f in &plan.flat { + r.affected.extend(&f.roots); + r.vector += f.regions.len(); + for root in f.roots.iter().filter_map(|id| doc.node(*id)) { + visit(root, &mut |n| { + // Flattened art is outlined throughout and its patterns become part of the image. + if matches!(n.kind, NodeKind::Text(_)) { + r.text.push(n.id); + } else if stroked(n) { + r.strokes.push(n.id); + } + if pattern(n) { + r.patterns.push(n.id); + } + }); + } + if let Some(a) = &f.area { + if a.whole { + r.complex.push(a.outline()); + } + r.rasterized.push(a.outline()); + } + } + for root in plan.kept.iter().filter_map(|id| doc.node(*id)).filter(|n| kept_work(n, o)) { + r.affected.push(root.id); + visit(root, &mut |n| { + if o.text_to_outlines && matches!(n.kind, NodeKind::Text(_)) { + r.text.push(n.id); + } else if o.strokes_to_outlines && stroked(n) { + r.strokes.push(n.id); + } + }); + } + r.affected.sort_by_key(|id| doc.index_path(*id)); + Ok(r) +} + +impl Session { + /// What flattening would do with the options `p` asks for (as `flattener.preview`): the + /// options and the report. + pub fn flattener_report(&self, p: &Value) -> Result<(FlattenOptions, FlattenReport)> { + const C: &str = "flattener.preview"; + let mut o = self.flatten_options(p).map_err(|m| bad(C, m))?; + if let Some(v) = str_param(p, "overprints") { + let ov = Overprints::parse(v).ok_or_else(|| bad(C, format!("unknown overprints `{v}` (preserve, simulate or discard)")))?; + o.preserve_overprints = ov == Overprints::Preserve; + } + let roots = if p.get("ids").is_some() { + target_roots(self, p)? + } else { + let layers: Vec = self.doc()?.doc.layers.iter().map(|l| l.id.0).collect(); + target_roots(self, &json!({ "ids": layers }))? + }; + let r = report(&self.doc()?.doc, &roots, &o)?; + Ok((o, r)) + } +} + +pub(super) fn preview(s: &mut Session, p: &Value) -> Result { + let h = match str_param(p, "highlight") { + Some(v) => Highlight::parse(v).ok_or_else(|| bad("flattener.preview", format!("unknown highlight `{v}`")))?, + None => Highlight::None, + }; + let (o, r) = s.flattener_report(p)?; + let mut out = r.to_json(&s.doc()?.doc, h); + out["options"] = serde_json::to_value(&o).unwrap_or_default(); + Ok(out) +} diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index cd8fa7b36..4ae431bd7 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -19,5251 +19,30 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes} use super::typecmd::refresh_bounds; use super::*; -pub fn specs() -> Vec { - vec![ - cmd!( - "graph.create", - "Create Graph", - [], - None, - "{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label", - has_doc, - create - ), - cmd!( - "graph.setData", - "Data…", - ["Object", "Graph"], - None, - "{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty); then one row per category: label; values…; an empty cell or a null is a blank value; a quoted number a label; a `|` in a label a line break, transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; no data → the current {csv, series, categories, rows}", - has_selection, - set_data - ), - cmd!( - "graph.setType", - "Type…", - ["Object", "Graph"], - None, - "{id?, type?, seriesIndexes?: [index], columnWidth?: % (1–1000; past 100 columns overlap), clusterWidth?: % (1–1000), legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, firstRowInFront?: bool, firstColumnInFront?: bool (which overlapping columns are drawn on top: the first category over the next within each series, the first series over the others), filledLines?: bool (Draw Filled Lines: connecting lines as 3 pt bands filled with the series colour), dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); no options → the current ones", - has_selection, - set_type - ), - cmd!( - "graph.design", - "Design…", - ["Object", "Graph"], - None, - "{new?: name|true (New Design: the selected art becomes a graph design, named \"New Design\" when true; put a rectangle at the back to set the size it draws at), rename?: name, to?: new name (the graphs drawing it follow), paste?: name (Paste Design: a copy of the design's art into the document, selected, to edit and save again), delete?: name|[names] (graphs drawing them go back to their default marks), ids?|id?: object ids (default: the selection), selectUnused?: bool (Select Unused: just the list)}; no params or selectUnused → {designs: [names], unused: [the names no graph draws with]}", - has_doc, - design - ), - cmd!( - "graph.column", - "Column…", - ["Object", "Graph"], - None, - "{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); type in the design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits, the first digit giving the least number of places before the decimal point (zeros added in front; 0 = as many as the value needs) and the second digit giving the places after it (rounded half away from zero), at the type's own size where the design's copy puts it (a repeating design shows it once, where a whole copy ending at the value end puts it; type inside a group that clips, is hidden or has its own opacity, blend mode, isolation, knockout, appearance or opacity mask, or inside a blend, compound shape, envelope, repeat or intertwine, stays there and is filled in each copy, drawn with the design; type inside a symbol stays as typed), and is left out of the legend swatch (a design of such type alone gets the default swatch); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}", - has_selection, - set_column - ), - cmd!( - "graph.marker", - "Marker…", - ["Object", "Graph"], - None, - "{id?, seriesIndexes?: [index], design?: name|null} draw the data points and legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, scaled so its backmost object fills the default marker's square; null = the default marker. Line, scatter and radar series. No design → {designs: [names], design: the selected series' design or null}", - has_selection, - set_marker - ), - ] -} - -// ---------- data ---------- - -/// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value). -struct Cell { - text: String, - quoted: bool, -} - -impl Cell { - fn number(&self) -> Option { - if self.quoted { None } else { self.text.parse::().ok().filter(|v| v.is_finite()) } - } -} - -/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside). -fn split_unquoted(s: &str, sep: char) -> Vec<&str> { - let mut parts = vec![]; - let mut quoted = false; - let mut start = 0; - for (i, ch) in s.char_indices() { - if ch == '"' { - quoted = !quoted; - } else if ch == sep && !quoted { - parts.push(s.get(start..i).unwrap_or_default()); - start = i + ch.len_utf8(); - } - } - parts.push(s.get(start..).unwrap_or_default()); - parts -} - -/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed. -fn cell(c: &str) -> Cell { - let c = c.trim(); - match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) { - Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true }, - None => Cell { text: c.trim_matches('"').to_string(), quoted: false }, - } -} - -fn parse_csv(csv: &str) -> (Vec, Vec, Vec>>) { - // Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes. - let rows: Vec> = split_unquoted(csv, '\n') - .into_iter() - .map(|l| l.trim_end_matches('\r')) - .filter(|l| !l.trim().is_empty()) - .map(|l| { - let tabs = split_unquoted(l, '\t'); - let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') }; - parts.into_iter().map(cell).collect() - }) - .collect(); - let Some(first) = rows.first() else { return (vec![], vec![], vec![]) }; - // A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value. - let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty()); - // A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`). - // All-empty series names mean no legend. - let empty = |c: &Cell| c.text.is_empty() && !c.quoted; - let header = first.first().is_some_and(empty) - || first.iter().skip(1).any(label) - || (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty)); - let mut series: Vec = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] }; - if series.iter().all(String::is_empty) { - series.clear(); - } - let data = rows.get(usize::from(header)..).unwrap_or_default(); - // The first column holds the category labels if any row starts with one; then it does for every row, so a - // row with an empty label doesn't shift its values one place left. - let labelled = data.iter().any(|r| r.first().is_some_and(label)); - let mut categories = vec![]; - let mut values = vec![]; - for r in data { - if labelled { - categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default()); - } - values.push(r.iter().skip(labelled as usize).map(Cell::number).collect()); - } - (series, categories, values) -} - -fn to_csv(g: &GraphSpec) -> String { - g.to_csv() -} - -/// Apply CSV graph data to the graph root `id` and rebuild its art (Variables data merge). -pub(crate) fn apply_csv(d: &mut Document, id: NodeId, csv: &str) -> Result<()> { - let Some(node) = d.node(id) else { - return Err(EngineError::NoNode(id)); - }; - let Some(mut spec) = node.extras().graph.as_deref().cloned() else { - return Err(bad("graph.applyCsv", format!("node {} is not a graph", id.0))); - }; - let (series, categories, rows) = parse_csv(csv); - spec.series = series; - spec.categories = categories; - spec.set_cells(rows); - regenerate(d, id, spec) -} - -fn apply_data(g: &mut GraphSpec, p: &Value) -> bool { - let mut changed = false; - if let Some(csv) = str_param(p, "csv") { - let (s, c, r) = parse_csv(csv); - g.series = s; - g.categories = c; - g.set_cells(r); - changed = true; - } - let strings = - |v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::>()); - if let Some(s) = p.get("series").and_then(strings) { - g.series = s; - changed = true; - } - if let Some(c) = p.get("categories").and_then(strings) { - g.categories = c; - changed = true; - } - if let Some(rows) = p.get("rows").and_then(Value::as_array) { - // `null` (or anything that isn't a finite number) is a blank cell. - g.set_cells( - rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(), - ); - changed = true; - } - drop_empty_rows(g); - changed -} - -/// Drop rows with neither a value nor a label; a label-only row stays, its values blank. -fn drop_empty_rows(g: &mut GraphSpec) { - let mut cells = g.cells(); - let keep: Vec = (0..cells.len()) - .map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty())) - .collect(); - if keep.contains(&false) || cells.len() < g.rows.len() { - let mut k = keep.iter(); - cells.retain(|_| k.next().copied().unwrap_or(false)); - let mut k = keep.iter(); - g.categories.retain(|_| k.next().copied().unwrap_or(true)); - g.set_cells(cells); - } -} - -fn fmt_value(v: f64) -> String { - if (v - v.round()).abs() < 1e-9 { - // Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so - // `{:.0}` prints its exact digits. - if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") } - } else { - format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string() - } -} - -// ---------- generation ---------- - -/// Series colours: black, then greys (graphs start in greyscale). -fn default_series_color(i: usize) -> Color { - const LEVELS: [f32; 8] = [0.0, 0.55, 0.8, 0.3, 0.68, 0.15, 0.9, 0.42]; - let l = LEVELS[i % LEVELS.len()]; - Color::rgb(l, l, l) -} - -fn default_series_paint(i: usize) -> Paint { - Paint::solid(default_series_color(i)) -} - -/// Fill and stroke for one generated mark of series `i`. A stored fill is used only where the -/// generator paints with the series colour (`series_fill`). A stored stroke replaces that part's -/// own stroke, so a column's stroke stays a stroke and a line's markers do not repaint its line. -fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: Paint, width: f64) -> (Paint, Paint, f64) { - let slot = g.series_paints.get(i); - let fill = if series_fill { slot.and_then(|p| p.fill.clone()).unwrap_or_else(|| default_series_paint(i)) } else { Paint::None }; - let stroke = slot.and_then(|p| p.stroke.clone()).unwrap_or(stroke); - let width = if slot.is_some_and(|p| p.stroke.is_some()) { slot.and_then(|p| p.stroke_width).unwrap_or(width) } else { width }; - (fill, stroke, width) -} - -const LABEL_SIZE: f64 = 9.0; -/// Most tick marks per division (Graph Type › Tick Marks), and most category tick marks a graph draws. -const MAX_TICK_MARKS: usize = 20; -const MAX_CATEGORY_TICKS: usize = 10_000; -/// Longest axis label prefix or suffix (Graph Type › Add Labels). -const MAX_AFFIX: usize = 64; - -/// An axis label prefix or suffix: one line (no control characters or line separators), at most [`MAX_AFFIX`] long. -fn one_line(s: &str) -> String { - s.chars().filter(|c| !c.is_control() && !matches!(c, '\u{2028}' | '\u{2029}')).take(MAX_AFFIX).collect() -} - -/// A count parameter: a whole number, also as the float a dialog sends back (3.0), at most `max`. -fn count_param(p: &Value, key: &str, max: u64) -> Option { - let v = p.get(key)?; - let n = v.as_u64().or_else(|| v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).map(|x| x.round().min(max as f64) as u64))?; - usize::try_from(n.min(max)).ok() -} - -/// A "nice" axis: (min, max, step). -fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) { - let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) }; - let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64; - let raw = (hi - lo) / n; - let mag = 10f64.powf(raw.log10().floor()); - let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw - 1e-12).unwrap_or(10.0 * mag); - ((lo / step).floor() * step, (hi / step).ceil() * step, step) -} - -/// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that -/// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound -/// was given. -fn value_axis(ticks: usize, axis_min: Option, axis_max: Option, lo: f64, hi: f64) -> (f64, f64, f64) { - match (axis_min, axis_max) { - (Some(min), Some(max)) if max > min && (max - min).is_finite() => { - let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64; - (min, max, (max - min) / n) - } - _ => { - // An override too wide for f64 (or NaN) gives a non-finite axis, which would reach `clamp` with NaN bounds - // and panic: fall back to the data, then to 0..1. - let finite = |(a, b, s): (f64, f64, f64)| a.is_finite() && b.is_finite() && s.is_finite() && b > a && s > 0.0; - [nice_axis(axis_min.unwrap_or(lo), axis_max.unwrap_or(hi), ticks), nice_axis(lo, hi, ticks)] - .into_iter() - .find(|&a| finite(a)) - .unwrap_or((0.0, 1.0, 0.2)) - } - } -} - -/// The tick values from `lo` to `hi` every `step`, both ends included: counted, not accumulated, so the last tick -/// lands on `hi` and a step too small for the range (or not finite) can't loop forever. At most 1000 ticks. -fn tick_values(lo: f64, hi: f64, step: f64) -> Vec { - let n = ((hi - lo) / step).round(); - if !(lo.is_finite() && step.is_finite() && step > 0.0 && n.is_finite() && n >= 0.0) { - return vec![]; - } - (0..=(n as usize).min(1000)).map(|i| lo + i as f64 * step).collect() -} - -struct Gen<'a> { - d: &'a mut Document, - out: Vec>, - /// The labels' kept character styles. - styles: &'a vectorcraft_doc::LabelStyles, - /// What the column designs may still add. - columns: Allowance, -} - -/// What a graph label shows, for its kept character style. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum Role { - Value, - Category, - Legend, -} - -/// The character style labels of `role` are set in: the kept one, else 9 pt black. -fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> CharStyle { - let kept = match role { - Role::Value => &styles.values, - Role::Category => &styles.categories, - Role::Legend => &styles.legend, - }; - let mut st = kept.clone().unwrap_or_else(|| CharStyle { size: LABEL_SIZE, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }); - // A file's style within the limits the Character controls keep to (it is copied onto every label). - let within = |v: f64, lo: f64, hi: f64, or: f64| if v.is_finite() { v.clamp(lo, hi) } else { or }; - st.size = within(st.size, 0.1, 1296.0, LABEL_SIZE); - st.leading = st.leading.map(|l| within(l, 0.1, 5000.0, LABEL_SIZE * 1.2)); - st.tracking = within(st.tracking, -1000.0, 10000.0, 0.0); - st.h_scale = within(st.h_scale, 1.0, 10000.0, 100.0); - st.v_scale = within(st.v_scale, 1.0, 10000.0, 100.0); - st.stroke_width = within(st.stroke_width, 0.0, 1000.0, 0.0); - st.font_family = st.font_family.chars().take(256).collect(); - st.font_style = st.font_style.chars().take(256).collect(); - st -} - -/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. -fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { - label_style(styles, role).size -} - -impl Gen<'_> { - fn path(&mut self, pd: PathData, fill: Paint, stroke: Paint, w: f64) -> Arc { - Arc::new(Node::path(self.d.alloc_id(), pd, Appearance::basic(fill, stroke, w))) - } - fn line(&mut self, a: Point, b: Point) -> Arc { - let mut bp = BezPath::new(); - bp.move_to(a); - bp.line_to(b); - self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5) - } - fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { - let style = label_style(self.styles, role); - let mut t = TextObject::point(at, s, style); - t.para.justify = justify; - t.system_layout = self.d.setup.system_layout; - refresh_bounds(&mut t); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) - } - /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred - /// on `at` (labels beside an axis or in a wedge) instead of running down from it. - fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { - let lines = s.split('|').count(); - let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at }; - self.text(role, at, &s.replace('|', "\n"), justify) - } - fn group(&mut self, name: &str, children: Vec>) -> Arc { - let mut g = Node::group(self.d.alloc_id(), children); - g.name = Some(name.into()); - Arc::new(g) - } - /// A series' line joining its data points (Connect Data Points): a 1 pt stroke in the series colour, or with Draw - /// Filled Lines a band FILLED_LINE_WIDTH wide filled with it and outlined in black. - fn data_line(&mut self, g: &GraphSpec, s: usize, pd: PathData, filled: bool) -> Arc { - let band = if filled { - vectorcraft_pathops::outline_stroke(&pd, FILLED_LINE_WIDTH, vectorcraft_pathops::Cap::Butt, vectorcraft_pathops::Join::Miter, 4.0) - } else { - PathData::default() - }; - // A run the outline can't be made for (its points all in one place) stays a thin line. - if !band.is_empty() { - // The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline. - let slot = g.series_paints.get(s); - let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s)); - return self.path(band, fill, Paint::solid(Color::BLACK), 0.25); - } - let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0); - self.path(pd, fill, stroke, w) - } - /// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint. - fn marker(&mut self, design: Option<&Arc>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc { - match design.and_then(|a| fit_design(self.d, a, cell)) { - Some(n) => self.design_copy(n), - None => self.path(shapes::rectangle(cell), fill, stroke, w), - } - } - /// A legend swatch filling `cell`: the series' column design with its totals left out, else, and when the design - /// draws nothing else, its [`Self::marker`]. - fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc { - let fitted = match column.and_then(|c| Some((c, c.legend.as_ref()?))) { - Some((c, legend)) if self.columns.take(legend.count()) => c.fit(self.d, legend, cell), - _ => None, - }; - match fitted { - Some(n) => self.design_copy(n), - None => self.marker(marker, cell, paint), - } - } - /// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the - /// paths directly inside its group and the design keeps its own paint. - fn design_copy(&mut self, n: Arc) -> Arc { - if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n } - } - /// A series group. Empty series are omitted by the caller; the index is stored on the group - /// so later paint edits find the series without counting siblings. - fn series(&mut self, name: &str, index: usize, children: Vec>) -> Arc { - let mut g = Node::group(self.d.alloc_id(), children); - g.name = Some(name.into()); - g.extras_mut().series_index = u32::try_from(index).ok(); - Arc::new(g) - } -} - -/// Add Drop Shadow: how far the shadow sits down and right of its mark, and how dark it is. -const SHADOW_OFFSET: f64 = 2.0; -const SHADOW_OPACITY: f32 = 0.35; -/// Draw Filled Lines: how wide a filled line is. -const FILLED_LINE_WIDTH: f64 = 3.0; -const FILLED_LINE_BUDGET: usize = 20_000; -/// Most marks a graph draws shadows for (each is a copy). -const SHADOW_BUDGET: usize = 200_000; - -/// Mark `n`'s shadow: a black copy moved down and right, its fill and stroke (whichever it has) black. -fn shadow_of(d: &mut Document, n: &Node) -> Arc { - // Generated marks carry only paint and a stroke width, so that's all a shadow keeps. - let mut c = n.clone(); - c.transform(Affine::translate((SHADOW_OFFSET, SHADOW_OFFSET)), false); - let fill = if c.appearance.fill_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) }; - let stroke = if c.appearance.stroke_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) }; - c.appearance = Appearance::basic(fill, stroke, c.appearance.stroke_width()).into(); - Arc::new(d.reid(&c)) -} - -/// How wide label `s` is set (its widest line), without adding it to a document. -fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { - let style = label_style(styles, Role::Legend); - let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style); - refresh_bounds(&mut t); - Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width()) -} - -/// A label with nothing to show (empty, or only `|` line breaks). -fn blank_label(s: &str) -> bool { - s.chars().all(|c| c == '|') -} - -/// Text node `t` with its characters painted `colour`. -fn recolour_text(t: &Arc, colour: Color) -> Arc { - let mut n = (**t).clone(); - if let NodeKind::Text(text) = &mut n.kind { - for run in &mut text.runs { - run.style.fill = Paint::solid(colour); - } - } - Arc::new(n) -} - -/// The stretches of two or more points between blank cells, which a line connects. -fn runs(pts: &[Option]) -> Vec> { - pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::>()).filter(|r| r.len() > 1).collect() -} - -fn polyline(pts: &[Point], closed: bool) -> PathData { - let mut bp = BezPath::new(); - for (i, p) in pts.iter().enumerate() { - if i == 0 { - bp.move_to(*p); - } else { - bp.line_to(*p); - } - } - if closed { - bp.close_path(); - } - PathData::from_bezpath(&bp) -} - -/// Most objects a graph's marker designs may add (each data point and legend swatch draws a copy of its design). -const MARKER_BUDGET: usize = 200_000; - -/// Each series' marker design art, when the document has the design. -fn marker_designs(d: &Document, g: &GraphSpec) -> Vec>> { - let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); - (0..nser) - .map(|s| { - let name = g.series_markers.get(s)?.as_deref()?; - d.graph_designs.iter().find(|x| x.name == name).map(|x| x.art.clone()) - }) - .collect() -} - -/// How many objects the marker designs `designs` add to graph `g`: a copy per data point and legend swatch. -fn marker_cost(g: &GraphSpec, designs: &[Option>]) -> usize { - let points = g.rows.len().min(vectorcraft_doc::MAX_GRAPH_CATEGORIES) + 1; - designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add) -} - -/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a -/// single object). `None` for art with no area. -fn design_frame(art: &Node) -> Option { - let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite(); - let group = matches!(art.kind, NodeKind::Group { .. }); - let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable); - backmost.or_else(|| art.geometric_bounds().filter(usable)) -} - -/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is -/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it. -/// Fresh ids. `None` for art with no area. -fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option> { - let a = fit_affine(design_frame(art)?, cell); - // Strokes, effects and pattern tiles scale with the art. - let art = super::place::transformed(art.clone(), a); - Some(Arc::new(d.reid(&art))) -} - -/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]). -fn fit_affine(frame: Rect, cell: Rect) -> Affine { - Affine::translate(cell.origin().to_vec2()) - * Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height()) - * Affine::translate(-frame.origin().to_vec2()) -} - -/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy). -const COLUMN_BUDGET: usize = 200_000; - -/// A series' column design (Object › Graph › Column): its options; its art without guide lines and without the totals -/// taken out of it ([`without_totals`]); the bounds of those guides; those totals; its frame (the art's without guides, -/// totals included, [`design_frame`]); whether its art still holds totals (inside groups that do more than group, or -/// inside other objects that hold objects), which are filled where they are; and its art without any total for the -/// legend swatch, `None` when that draws nothing. -struct ColumnArt { - options: ColumnDesign, - art: Node, - guides: Vec, - totals: Vec, - frame: Option, - in_place: bool, - legend: Option, -} - -impl ColumnArt { - /// A copy of `art` with the design's frame fitted to `cell`, as [`fit_design`] fits it. Fresh ids. - fn fit(&self, d: &mut Document, art: &Node, cell: Rect) -> Option> { - let art = super::place::transformed(art.clone(), fit_affine(self.frame?, cell)); - Some(Arc::new(d.reid(&art))) - } -} - -/// The column design totals in `s` (Add totals to a column design): each `%` followed by two digits, as its byte offset, -/// the places before the decimal point (the first digit) and the places after it (the second), left to right. A `%` not -/// followed by two digits is plain text. -fn total_spans(s: &str) -> Vec<(usize, usize, usize)> { - let b = s.as_bytes(); - let mut out = vec![]; - let mut i = 0; - while let Some(w) = b.get(i..i + 3) { - match *w { - [b'%', x, y] if x.is_ascii_digit() && y.is_ascii_digit() => { - out.push((i, usize::from(x - b'0'), usize::from(y - b'0'))); - i += 3; - } - _ => i += 1, - } - } - out -} - -/// Value `v` as a column design total shows it: rounded half away from zero, from its shortest decimal form, to `after` -/// places after the decimal point, zeros added as needed; and with at least `before` places before the point, zeros -/// added in front (0: as many as the value needs). The places before the point are never cut, and a value that rounds -/// to 0 has no minus sign. -fn total_text(v: f64, before: usize, after: usize) -> String { - if !v.is_finite() { - return fmt_value(v); - } - let s = format!("{}", v.abs()); - let (int, frac) = s.split_once('.').unwrap_or((s.as_str(), "")); - // The digits kept, the point `after` places from the end. - let mut digits: Vec = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(after)).collect(); - if frac.as_bytes().get(after).is_some_and(|d| *d >= b'5') { - let mut carry = true; - for d in digits.iter_mut().rev() { - if *d == b'9' { - *d = b'0'; - } else { - *d += 1; - carry = false; - break; - } - } - if carry { - digits.insert(0, b'1'); - } - } - let (whole, places) = digits.split_at(digits.len().saturating_sub(after)); - let mut out = String::new(); - if v < 0.0 && digits.iter().any(|d| *d != b'0') { - out.push('-'); - } - out.extend(std::iter::repeat_n('0', before.saturating_sub(whole.len()))); - out.extend(whole.iter().map(|d| char::from(*d))); - if after > 0 { - out.push('.'); - out.extend(places.iter().map(|d| char::from(*d))); - } - out -} - -/// Type `t` with each total in it ([`total_spans`]) replaced by value `v` ([`total_text`]), styled as its `%` was; true -/// when it held one. -fn fill_totals(t: &mut TextObject, v: f64) -> bool { - let spans = total_spans(&t.plain_text()); - // Last first, so the offsets of earlier ones stay valid. - for &(at, before, after) in spans.iter().rev() { - let s = total_text(v, before, after); - t.splice_paras(at, at + 3, &s); - vectorcraft_text::edit::replace_range(&mut t.runs, at, at + 3, &s); - } - if !spans.is_empty() { - refresh_bounds(t); - } - !spans.is_empty() -} - -/// Is `n` type that holds a total ([`total_spans`])? -fn holds_total(n: &Node) -> bool { - matches!(&n.kind, NodeKind::Text(t) if !total_spans(&t.plain_text()).is_empty()) -} - -/// Does group `n` only group its children: no clipping, hiding, opacity, blend mode, isolation, knockout, appearance of -/// its own or opacity mask, so that type taken out of it draws as it did inside it? -fn only_groups(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) - && n.visible - && n.opacity == 1.0 - && n.blend == vectorcraft_color::BlendMode::Normal - && !n.isolate - && n.knockout == vectorcraft_doc::Knockout::default() - && n.appearance.items.is_empty() - && n.appearance.effects.is_empty() - && n.extras().mask.is_none() -} - -/// Is `n` a clipping group whose clipping path is type that holds a total? -fn clipped_by_total(n: &Node) -> bool { - matches!(&n.kind, NodeKind::Group { clip: true, children } if children.first().is_some_and(|c| holds_total(c))) -} - -/// `art` without the type in it that holds a total, inside groups at any depth that only group ([`only_groups`]), and -/// that type, pushed to `totals` in paint order (Add totals to a column design); with `all`, without every total inside -/// any group or other object that holds objects (a blend, compound shape, envelope, repeat or intertwine), and with -/// each group that such type clips left empty. A design that is such type alone leaves an empty group. -fn without_totals(art: &Node, all: bool, totals: &mut Vec) -> Node { - if holds_total(art) || (all && clipped_by_total(art)) { - totals.push(art.clone()); - return Node::group(art.id, vec![]); - } - let mut n = art.clone(); - let take = all || only_groups(&n); - if take && let Some(children) = n.children_mut() { - for c in std::mem::take(children) { - if holds_total(&c) { - totals.push((*c).clone()); - } else if c.children().is_some() { - children.push(Arc::new(without_totals(&c, all, totals))); - } else { - children.push(c); - } - } - } - n -} - -/// `n` with the totals in it at any depth, in groups and the other objects that hold objects, showing value `v` where -/// they are ([`fill_totals`]). -fn fill_in_place(n: &mut Node, v: f64) { - if let NodeKind::Text(t) = &mut n.kind { - fill_totals(t, v); - } else if let Some(children) = n.children_mut() { - for c in children.iter_mut() { - fill_in_place(Arc::make_mut(c), v); - } - } -} - -/// `art` without the paths made into guides, at any depth. -fn without_guides(art: &Node) -> Node { - let mut n = art.clone(); - if let Some(ch) = n.children_mut() { - let old = std::mem::take(ch); - *ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect(); - } - n -} - -/// Each series' column design, when the document has the design. -fn column_designs(d: &Document, g: &GraphSpec) -> Vec> { - let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); - (0..nser) - .map(|s| { - let options = g.series_columns.get(s)?.as_ref()?; - let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art; - let mut guides = vec![]; - art.walk(&mut |n| { - if matches!(n.kind, NodeKind::Path { guide: true, .. }) - && let Some(b) = n.geometric_bounds() - { - guides.push(b); - } - }); - let art = without_guides(art); - let frame = design_frame(&art); - let legend = without_totals(&art, true, &mut vec![]); - let mut draws = false; - legend.walk(&mut |n| draws |= n.children().is_none()); - let mut totals = vec![]; - let art = without_totals(&art, false, &mut totals); - let mut in_place = false; - art.walk(&mut |n| in_place |= holds_total(n)); - Some(ColumnArt { options: options.clone(), art, guides, totals, frame, in_place, legend: draws.then_some(legend) }) - }) - .collect() -} - -/// The value one copy of a repeating design stands for, when the design repeats and the value is usable. -fn repeat_each(c: &ColumnDesign) -> Option { - (c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each) -} - -/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than -/// was left, and the graph is then drawn with plain columns. -struct Allowance { - left: usize, - short: bool, -} - -impl Allowance { - fn new() -> Self { - Self { left: COLUMN_BUDGET, short: false } - } - /// Spend `n` objects, when that many are left. - fn take(&mut self, n: usize) -> bool { - match self.left.checked_sub(n) { - Some(rest) => { - self.left = rest; - true - } - None => { - self.short = true; - false - } - } - } -} - -/// Column or bar `rect` of value `value` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, -/// its copies spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a -/// column above zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot -/// points per unit of value. The design's totals show `value` and come last: each keeps its size and is moved to where -/// the design's copy puts the point it is set from; in a repeating design they are drawn once, where a whole copy ending -/// at the value end puts them, and a sliding design moves each with the part of the design on its side of the guide. -/// Totals left in the art ([`ColumnArt`]) show `value` in each copy, drawn with it. `None` for art with no area, for a -/// column of no length and when `left` runs out, which are drawn plain. -fn column_art( - d: &mut Document, - c: &ColumnArt, - (rect, value): (Rect, f64), - (horizontal, far_low): (bool, bool), - per_unit: f64, - left: &mut Allowance, -) -> Option> { - let frame = c.frame?; - let length = |r: Rect| if horizontal { r.width() } else { r.height() }; - let thickness = |r: Rect| if horizontal { r.height() } else { r.width() }; - if length(rect) <= 1e-9 || !length(rect).is_finite() { - return None; - } - let cost = c.art.count_with_masks(); - // A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved - // by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when - // `keep_low` (two objects more). - let copy = |d: &mut Document, a: Affine| { - let mut art = d.reid(&super::place::transformed(c.art.clone(), a)); - if c.in_place { - fill_in_place(&mut art, value); - } - art - }; - let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| left.take(cost).then(|| Arc::new(copy(d, fit_affine(frame, cell)))); - let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| { - let art = left.take(cost + 2).then(|| copy(d, a))?; - // The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed - // stroke counts only in its own object's box. - let mut b = vectorcraft_render::painted_bounds(d, &art); - art.walk(&mut |n| { - if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) { - b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n)); - } - }); - // A copy that draws nothing (a design of totals alone) needs no clip. - let Some(b) = b.or_else(|| art.geometric_bounds()) else { return Some(Arc::new(art)) }; - let keep = match (along_x, keep_low) { - (true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1), - (true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1), - (false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)), - (false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1), - }; - Some(Arc::new(super::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art))) - }; - // The part of the column from `a` to `b` points out from its zero end. - let piece = |a: f64, b: f64| match (horizontal, far_low) { - (true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1), - (true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1), - (false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a), - (false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b), - }; - // The column's group: `parts`, then the totals, each showing `value` at its own size, moved as `a` moves the point it - // is set from; with `sliding` (the kept part's map, the stretched part's map and the guide, as [`slide`] gives them), - // as the map of the part on its side of the guide moves it. One object each. - let group = |d: &mut Document, mut parts: Vec>, a: Affine, sliding: Option<(Affine, Affine, f64)>, left: &mut Allowance| { - for t in &c.totals { - if !left.take(1) { - return None; - } - let mut n = t.clone(); - let mut by = Affine::IDENTITY; - if let NodeKind::Text(text) = &mut n.kind { - // A move only, so the type keeps its size; pattern paints move with it. - let p = text.xf * Point::ZERO; - let a = match sliding { - Some((kept, stretched, at)) => { - let q = kept * p; - if (horizontal && q.x >= at) || (!horizontal && q.y <= at) { kept } else { stretched } - } - None => a, - }; - fill_totals(text, value); - by = Affine::translate(a * p - p); - } - parts.push(Arc::new(d.reid(&super::place::transformed(n, by)))); - } - Some(Arc::new(Node::group(d.alloc_id(), parts))) - }; - let mut parts = vec![]; - let (mut label, mut sliding) = (fit_affine(frame, rect), None); - match c.options.column_type { - ColumnType::Uniform => { - // Scaled both ways to the column's length, centered across it. - let t = thickness(frame) * length(rect) / length(frame) / 2.0; - let cell = if horizontal { - Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t) - } else { - Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1) - }; - parts.push(fit(d, cell, left)?); - label = fit_affine(frame, cell); - } - ColumnType::Repeating => { - // When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design - // is drawn vertically scaled. - let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else { - parts.push(fit(d, rect, left)?); - return group(d, parts, label, None, left); - }; - // The totals go where a whole copy ending at the value end puts them. - label = fit_affine(frame, piece(length(rect) - unit, length(rect))); - // The whole copies, and the points of the column left for the last one (none under a millionth of a point). - let whole = ((length(rect) + 1e-6) / unit).floor(); - let rest = length(rect) - whole * unit; - // Each copy is spent from `left`, which stops the loop. - for i in 0..whole as usize { - parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?); - } - if rest > 1e-6 { - let start = whole * unit; - match c.options.fractions { - Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?), - Fractions::Chop => { - // The last copy cut off where the column ends, and nowhere else. - let cut = match (horizontal, far_low) { - (true, true) => (true, false, rect.x0), - (true, false) => (true, true, rect.x1), - (false, true) => (false, false, rect.y0), - (false, false) => (false, true, rect.y1), - }; - parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?); - } - } - } - } - ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) { - Some((kept, stretched, at)) => { - // Each copy cut at the guide, on its own side. - parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?); - parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?); - sliding = Some((kept, stretched, at)); - } - None => parts.push(fit(d, rect, left)?), - }, - ColumnType::Vertical => parts.push(fit(d, rect, left)?), - } - group(d, parts, label, sliding, left) -} - -/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first -/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and -/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left -/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too -/// short for the part kept in proportion. -fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> { - // A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across. - let inside = |g: &&Rect| { - if horizontal { - g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1 - } else { - g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1 - } - }; - let g = guides.iter().find(inside)?.center(); - if horizontal { - let s = rect.height() / frame.height(); - let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0); - let rest = rect.width() - kept * s; - if !(rest.is_finite() && rest > 1e-9) { - return None; - } - let k = rest / stretched; - let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]); - let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]); - Some((kept_map, stretched_map, rect.x1 - kept * s)) - } else { - let s = rect.width() / frame.width(); - let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y); - let rest = rect.height() - kept * s; - if !(rest.is_finite() && rest > 1e-9) { - return None; - } - let k = rest / stretched; - let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]); - let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]); - Some((kept_map, stretched_map, rect.y0 + kept * s)) - } -} - -/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it, -/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects. -fn generate(d: &mut Document, g: &GraphSpec) -> Vec> { - let column_looks = column_designs(d, g); - match generate_with(d, g, &column_looks) { - Some(out) => out, - None => generate_with(d, g, &[]).unwrap_or_default(), - } -} - -/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects. -fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option]) -> Option>> { - let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width); - // Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all - // when the designs would draw more than MARKER_BUDGET objects. - let mut designs = marker_designs(d, g); - if marker_cost(g, &designs) > MARKER_BUDGET { - designs.clear(); - } - let design = |s: usize| designs.get(s).and_then(Option::as_ref); - let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref); - let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() }; - // The labels' sizes (9 pt unless formatted), which their spacing follows. - let (vs, cs, ls) = - (label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend)); - let r = g.rect; - let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1); - let ncat = g.rows.len().max(1); - let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default(); - let mut series: Vec>> = vec![vec![]; nser]; - // A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points - // break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them. - let cells = g.cells(); - let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten(); - let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0); - // Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own. - let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); - let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind); - // Series drawn as columns or bars, which column designs apply to. - let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar); - let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar); - // Where the art right of the plot ends (the right value axis' labels), so the legend starts after it. - let mut right_edge = r.x1; - - // Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names. - let mut wedge_labels = vec![]; - let mut on_top = vec![]; - // Draw Filled Lines, up to FILLED_LINE_BUDGET data points (the outlines are rebuilt on every change). - let filled = g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET; - // Where columns and bars reach (past the plot when their widths are over 100%), so the legend keeps clear. - let mut bars_reach: Option = None; - // Add Drop Shadow: the columns, bars, lines and wedges to cast one. - let mut shadowed: Vec<(usize, Arc)> = vec![]; - match g.kind { - GraphKind::Pie => { - // One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size - // (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back. - let totals: Vec = (0..ncat).map(|c| (0..nser).map(|s| val(c, s).max(0.0)).sum()).collect(); - let biggest = totals.iter().copied().fold(0.0, f64::max); - let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked; - let w = if stacked { r.width() } else { r.width() / ncat as f64 }; - let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0); - let radius = |total: f64| match g.pie_position { - vectorcraft_doc::PiePosition::Even => full, - _ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0), - _ => full, - }; - // Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere. - let order = |c: usize| { - let mut o: Vec = (0..nser).collect(); - let by = match g.pie_sort { - vectorcraft_doc::PieSort::None => return o, - vectorcraft_doc::PieSort::All => c, - vectorcraft_doc::PieSort::First => 0, - }; - o.sort_by(|a, b| val(by, *b).max(0.0).total_cmp(&val(by, *a).max(0.0))); - o - }; - let mut pies: Vec = (0..ncat).collect(); - if stacked { - pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0))); - } - // Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller - // one, so a pie never covers another (a pie as big as the next one has no ring and is left out). - let inner: Vec = if stacked { - (0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect() - } else { - vec![0.0; pies.len()] - }; - for (k, c) in pies.into_iter().enumerate() { - let total = totals.get(c).copied().unwrap_or(0.0); - let rad = radius(total); - let hole = inner.get(k).copied().unwrap_or(0.0); - if hole >= rad - 1e-9 { - continue; - } - let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) }; - let mut a0 = -std::f64::consts::FRAC_PI_2; - for s in order(c) { - let v = val(c, s).max(0.0); - if total <= 0.0 || v <= 0.0 { - continue; - } - let sweep = v / total * std::f64::consts::TAU; - let mut bp = BezPath::new(); - let dir = |a: f64| vectorcraft_geom::Vec2::new(a.cos(), a.sin()); - let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0); - if hole > 0.0 { - // A ring segment: out along the outer arc, back along the inner one. - bp.move_to(centre + dir(a0) * rad); - arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - bp.line_to(centre + dir(a0 + sweep) * hole); - let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0); - back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - } else { - bp.move_to(centre); - bp.line_to(centre + dir(a0) * rad); - arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - } - bp.close_path(); - let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.5); - // White text on a dark wedge. - let dark = fill.color().is_some_and(|c| { - let [r, g, b] = c.to_rgb(); - 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 - }); - let n = b.path(PathData::from_bezpath(&bp), fill, stroke, sw); - shadowed.push((s, n.clone())); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - // Legends in Wedges: the series label inside its wedge, centred two thirds of the way out. - if g.legend && g.pie_legend_in_wedges { - let label = series_label(s); - if !blank_label(&label) { - // Two thirds of the way out (of the ring, for a stacked pie). - let mid = a0 + sweep / 2.0; - let at = centre + dir(mid) * (hole + (rad - hole) * 0.62); - let t = b.label(Role::Legend, Point::new(at.x, at.y + ls * 0.35), &label, Justify::Center, true); - // White on a dark wedge, unless the legend labels were given a colour of their own. - let own_colour = g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); - wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); - } - } - a0 += sweep; - } - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - if stacked { - // Just above its own outline, all its lines, and drawn over the pies. - let lines = cat.split('|').count() as f64; - let at = Point::new(centre.x, centre.y - rad - cs * 0.4 - (lines - 1.0) * cs * 1.2); - let t = b.label(Role::Category, at, cat, Justify::Center, false); - on_top.push(t); - } else { - let t = b.label(Role::Category, Point::new(centre.x, centre.y + rad + cs * 1.6), cat, Justify::Center, false); - b.out.push(t); - } - } - } - } - GraphKind::Radar => { - let centre = r.center(); - let rad = (r.width().min(r.height()) / 2.0).max(1.0); - let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); - let at = |c: usize, v: f64| { - let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; - centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) - }; - let mut axes = vec![]; - for c in 0..ncat { - axes.push(b.line(centre, at(c, hi))); - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - let p = at(c, hi) + (at(c, hi) - centre).normalize() * (cs * 1.2); - axes.push(b.label(Role::Category, Point::new(p.x, p.y + cs * 0.35), cat, Justify::Center, true)); - } - } - for v in tick_values(lo, hi, step).into_iter().skip(1) { - let ring: Vec = (0..ncat).map(|c| at(c, v)).collect(); - axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25)); - } - let ax = b.group("Axes", axes); - b.out.push(ax); - for (s, items) in series.iter_mut().enumerate() { - let pts: Vec = (0..ncat).map(|c| at(c, val(c, s))).collect(); - if g.connect_points && pts.len() > 1 { - let n = b.data_line(g, s, polyline(&pts, true), filled); - shadowed.push((s, n.clone())); - items.push(n); - } - if g.mark_points { - for p in pts { - let paint = mark(s, true, Paint::None, 0.0); - items.push(b.marker(design(s), Rect::from_center_size(p, (4.0, 4.0)), paint)); - } - } - } - } - _ => { - let scatter = g.kind == GraphKind::Scatter; - // Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale - // of their own there (axis 1); every other series, and every series without separate scales, uses axis 0. - let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; - let mut on_right = vec![false; nser]; - if separate { - for i in &g.right_series { - if let Some(x) = on_right.get_mut(*i) { - *x = true; - } - } - } - let axis = |s: usize| usize::from(on_right.get(s).copied().unwrap_or(false)); - // Value range per axis (stacked graphs stack positives and negatives separately). - let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); - let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); - for c in 0..ncat { - if !scatter { - for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { - // Stacked columns (or bars) stack together, and so do areas; other series count one value each. - for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { - let members = || (0..nser).filter(|s| axis(*s) == a && stack.contains(&kind(*s))); - *hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum()); - *lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum()); - } - let single = |s: &usize| axis(*s) == a && matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); - for v in (0..nser).filter(single).filter_map(|s| cell(c, s)) { - *hi = hi.max(v); - *lo = lo.min(v); - } - } - } else { - for s in (0..nser).step_by(2) { - if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) { - hi[0] = hi[0].max(y); - lo[0] = lo[0].min(y); - xlo = xlo.min(x); - xhi = xhi.max(x); - } - } - } - } - // An axis with no series of its own shows the other one's scale. - let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]); - let used = |a: usize| (0..nser).any(|s| axis(s) == a); - let (left, right) = if !separate || !used(1) { - (left, left) - } else { - let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]); - if used(0) { (left, right) } else { (right, right) } - }; - let scale = |a: usize| if a == 1 { right } else { left }; - // Value → coordinate along value axis `a` (y for columns/lines, x for bars), and that axis' zero (or the - // end nearest it). - let vpos = |a: usize, v: f64| { - let (lo, hi, _) = scale(a); - let t = (v - lo) / (hi - lo); - if horizontal { r.x0 + t * r.width() } else { r.y1 - t * r.height() } - }; - let zero = |a: usize| { - let (lo, hi, _) = scale(a); - vpos(a, 0.0f64.clamp(lo, hi)) - }; - // Plot points per unit of value along value axis `a`. - let per_unit = |a: usize| { - let (lo, hi, _) = scale(a); - (if horizontal { r.width() } else { r.height() }) / (hi - lo) - }; - let mut axes = vec![]; - // Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right - // one shows the right scale). - let (on_left_side, on_right_side) = - if horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) }; - // Add Labels text per axis, kept to one short line whatever the file holds. - let affix = |s: &str| one_line(s); - let affixes = [(affix(&g.prefix), affix(&g.suffix)), (affix(&g.right_prefix), affix(&g.right_suffix))]; - // Each label's tick mark, then the extra tick marks of its division (Tick Marks per division). - let value_ticks = |b: &mut Gen, axes: &mut Vec>, a: usize, v: f64, step: f64, hi: f64| { - let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) }; - let n = marks.clamp(1, MAX_TICK_MARKS); - for k in 0..n { - let w = v + step * k as f64 / n as f64; - if k > 0 && w >= hi - step * 1e-9 { - break; - } - let q = vpos(a, w); - // A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing. - let repeat = a == 1 && on_left_side && g.tick_length == TickLength::Full && right == left; - let len = if len == TickLength::Full && ((q - zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len }; - let ends = match (len, horizontal, a == 1) { - (TickLength::None, ..) => None, - (TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))), - (TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))), - (TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))), - (TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))), - (TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))), - }; - if let Some((p0, p1)) = ends { - axes.push(b.line(p0, p1)); - } - if k == 0 { - // The label goes right after its own tick mark. - let (prefix, suffix) = affixes.get(a).map_or(("", ""), |(p, s)| (p.as_str(), s.as_str())); - let label = format!("{prefix}{}{suffix}", fmt_value(v)); - let t = if horizontal { - b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &label, Justify::Center) - } else if a == 1 { - b.text(Role::Value, Point::new(r.x1 + 6.0, q + vs * 0.35), &label, Justify::Left) - } else { - b.text(Role::Value, Point::new(r.x0 - 6.0, q + vs * 0.35), &label, Justify::Right) - }; - axes.push(t); - } - } - }; - if on_left_side { - let (lo, hi, step) = left; - for v in tick_values(lo, hi, step) { - value_ticks(&mut b, &mut axes, 0, v, step, hi); - } - } - if on_right_side { - let (lo, hi, step) = right; - for v in tick_values(lo, hi, step) { - value_ticks(&mut b, &mut axes, 1, v, step, hi); - } - } - // The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar - // graph's last bottom label running past the plot (6 points on; a short number stays within the gap). - for t in &axes { - if matches!(t.kind, NodeKind::Text(_)) - && let Some(bb) = t.geometric_bounds() - && bb.x1 > r.x1 - { - right_edge = right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 }); - } - } - if horizontal { - axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1))); - axes.push(b.line(Point::new(zero(0), r.y0), Point::new(zero(0), r.y1))); - } else { - if on_left_side { - axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1))); - } - if on_right_side { - axes.push(b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1))); - } - axes.push(b.line(Point::new(r.x0, zero(0)), Point::new(r.x1, zero(0)))); - // The right scale's zero, where its columns start, when it isn't the left one's. - if (zero(1) - zero(0)).abs() > 1e-9 { - axes.push(b.line(Point::new(r.x0, zero(1)), Point::new(r.x1, zero(1)))); - } - } - // Category positions. - let span = if horizontal { r.height() } else { r.width() }; - let cat_w = span / ncat as f64; - let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w }; - // Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every - // series puts its points: at the edges when all of them span the plot, else at the category centres. - let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge); - let mid = |c: usize, edges: bool| { - if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 } - }; - let label_edges = kinds.iter().all(|k| spans(*k)); - let cat_mid = |c: usize| mid(c, label_edges); - if !scatter { - for c in 0..ncat { - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - let t = if horizontal { - b.label(Role::Category, Point::new(r.x0 - 6.0, cat_mid(c) + cs * 0.35), cat, Justify::Right, true) - } else { - b.label(Role::Category, Point::new(cat_mid(c), r.y1 + cs * 1.4), cat, Justify::Center, false) - }; - axes.push(t); - } - } - // Category tick marks (none by default): at the labels, or between them (at the category edges), - // with the extra tick marks of each division between those. - let len = g.category_tick_length; - if len != TickLength::None { - let shown = ncat.min(MAX_CATEGORY_TICKS); - let base: Vec = match (g.ticks_between_labels, label_edges) { - // Labels at the plot's edges: halfway between neighbouring labels. - (true, true) => (1..shown).map(|c| (cat_mid(c - 1) + cat_mid(c)) / 2.0).collect(), - (true, false) => (0..=shown).map(cat_start).collect(), - (false, _) => (0..shown).map(cat_mid).collect(), - }; - // Fewer per division on a long axis, so the cap still ticks all of it. - let n = g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1); - let at = base.iter().enumerate().flat_map(|(i, q)| { - let next = base.get(i + 1).copied(); - (0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) }) - }); - // A full-length tick on a drawn axis line adds nothing. - let on_axis_line = |q: f64| { - if horizontal { - (q - r.y1).abs() < 1e-9 - } else { - ((q - r.x0).abs() < 1e-9 && on_left_side) || ((q - r.x1).abs() < 1e-9 && on_right_side) - } - }; - for q in at.take(MAX_CATEGORY_TICKS) { - if len == TickLength::Full && on_axis_line(q) { - continue; - } - let (p0, p1) = match (len, horizontal) { - (TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)), - (TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)), - (_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)), - (_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)), - }; - axes.push(b.line(p0, p1)); - } - } - } else { - // Scatter: a horizontal value axis too. - // Like the value axis: finite, and a counted (capped) list of ticks, so data near f64::MAX can't loop forever. - let x_lo = if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }; - let x_hi = if xhi == f64::MIN { 1.0 } else { xhi }; - let (xl, xh, xs) = value_axis(g.ticks, None, None, x_lo, x_hi); - for x in tick_values(xl, xh, xs) { - let q = r.x0 + (x - xl) / (xh - xl) * r.width(); - axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))); - axes.push(b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &fmt_value(x), Justify::Center)); - } - for (si, s) in (0..nser).step_by(2).enumerate() { - let pts: Vec> = (0..ncat) - .map(|c| match (cell(c, s), cell(c, s + 1)) { - (Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(0, y))), - _ => None, - }) - .collect(); - if g.connect_points { - for run in runs(&pts) { - let n = b.data_line(g, si, polyline(&run, false), filled); - shadowed.push((si, n.clone())); - series[si].push(n); - } - } - if g.mark_points { - for p in pts.iter().flatten() { - let paint = mark(si, true, Paint::None, 0.0); - let n = b.marker(design(si), Rect::from_center_size(*p, (5.0, 5.0)), paint); - series[si].push(n); - } - } - } - } - let ax = b.group("Axes", axes); - b.out.push(ax); - // Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with - // no plain columns, a width of their own. - let columns: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect(); - let stacks: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect(); - // One stack per value axis that has stacked series, each in a slot of its own. - let stack_axes: Vec = (0..2).filter(|a| stacks.iter().any(|s| axis(*s) == *a)).collect(); - let own_width = columns.is_empty() && stack_axes.len() < 2; - let slots = columns.len() + stack_axes.len(); - // Widths past 100% make the columns (or clusters) overlap. - let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 10.0); - let slot = cluster / slots.max(1) as f64; - let bar = slot * (g.column_width / 100.0).clamp(0.01, 10.0); - let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| { - if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) } - }; - // First Row in Front: the first category drawn last, over the next. - let cats: Vec = if g.first_row_in_front { (0..ncat).rev().collect() } else { (0..ncat).collect() }; - for c in cats { - let c0 = cat_start(c) + (cat_w - cluster) / 2.0; - for (i, &s) in columns.iter().enumerate() { - let Some(v) = cell(c, s) else { continue }; - let a = c0 + slot * i as f64 + (slot - bar) / 2.0; - let (fill, stroke, w) = mark(s, true, Paint::None, 0.0); - let (v0, v1) = (zero(axis(s)), vpos(axis(s), v)); - let rect = bar_rect(a, bar, v0, v1); - bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); - // A column drawn with a design casts no shadow. - let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) - { - Some(n) => { - // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. - if let Some(art) = n.geometric_bounds() { - bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); - } - n - } - None => { - let n = b.path(shapes::rectangle(rect), fill, stroke, w); - shadowed.push((s, n.clone())); - n - } - }; - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - let place = |ax: usize| { - if own_width { - // Up to the category's width, or past it when the widths are set past 100%. - let (col, clus) = ((g.column_width / 100.0).clamp(0.01, 10.0), (g.cluster_width / 100.0).clamp(0.01, 10.0)); - let w = (cat_w * col * clus * 1.2).min(cat_w * (col * clus).max(1.0)); - (cat_start(c) + (cat_w - w) / 2.0, w) - } else { - let i = columns.len() + stack_axes.iter().position(|a| *a == ax).unwrap_or(0); - (c0 + slot * i as f64 + (slot - bar) / 2.0, bar) - } - }; - // Each axis stacks its own series. - let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]); - for &s in &stacks { - let Some(v) = cell(c, s) else { continue }; - let ax = axis(s); - let (a, w) = place(ax); - let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue }; - let (v0, v1) = (vpos(ax, *base), vpos(ax, *base + v)); - *base += v; - let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25); - let rect = bar_rect(a, w, v0, v1); - bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); - let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) { - Some(n) => { - if let Some(art) = n.geometric_bounds() { - bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); - } - n - } - None => { - let n = b.path(shapes::rectangle(rect), fill, stroke, sw); - // A segment of 0 has no area to shade. - if v != 0.0 { - shadowed.push((s, n.clone())); - } - n - } - }; - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - // Cumulative area bands, each from the previous area total on its axis up to its own. - let mut belows: [Vec; 2] = [vec![0.0; ncat], vec![0.0; ncat]]; - for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) { - let a = axis(s); - let Some(below) = belows.get_mut(a) else { continue }; - let above: Vec = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect(); - // Mixed with series at the category centres, areas take their points there too. - let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(a, v.get(c).copied().unwrap_or(0.0))); - let mut pts: Vec = (0..ncat).map(|c| at(c, &above)).collect(); - pts.extend((0..ncat).rev().map(|c| at(c, below))); - let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25); - let n = b.path(polyline(&pts, true), fill, stroke, w); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - *below = above; - } - for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) { - let pts: Vec> = - (0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(axis(s), v)))).collect(); - if g.connect_points { - for run in runs(&pts) { - let n = b.data_line(g, s, polyline(&run, false), filled); - shadowed.push((s, n.clone())); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - if g.mark_points { - for p in pts.iter().flatten() { - let paint = mark(s, true, Paint::None, 0.0); - let n = b.marker(design(s), Rect::from_center_size(*p, (5.0, 5.0)), paint); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - } - } - } - // Legend: a swatch + label per series, right of the plot; the swatch joins its series group. - let legend_series = if g.kind == GraphKind::Scatter { nser.div_ceil(2) } else { nser }; - let mut labels = vec![]; - let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges); - if legend_on && g.legend_across_top { - // Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row - // just above it. - let entries: Vec<(usize, String)> = (0..legend_series) - .map(|s| (s, if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) })) - .filter(|(_, l)| !blank_label(l)) - .collect(); - let mut rows: Vec> = vec![vec![]]; - let mut used = 0.0; - for (s, label) in entries { - let w = 12.0 + label_width(&g.label_styles, &label) + 12.0; - // The gap after an entry may run past the plot's edge. - if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) { - rows.push(vec![]); - used = 0.0; - } - used += w; - if let Some(row) = rows.last_mut() { - row.push((s, label, w)); - } - } - let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; - // Above the plot and anything drawn over it (radar and stacked-pie labels). - let top = b.out.iter().chain(on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); - let top = bars_reach.map_or(top, |b| top.min(b.y0)); - let mut y = top - 8.0 - rows.iter().map(|row| height(row)).sum::(); - for row in &rows { - let mut x = r.x0; - for (s, label, w) in row { - let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line; - let paint = mark(*s, true, Paint::None, 0.0); - let swatch = - b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint); - if let Some(items) = series.get_mut(*s) { - items.push(swatch); - } - labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)); - x += w; - } - y += height(row); - } - } else if legend_on { - let x = bars_reach.map_or(right_edge, |b| right_edge.max(b.x1)) + 14.0; - // Each row as tall as its label's lines (a `|` in a label starts a new line). - let mut y = r.y0; - for (s, items) in series.iter_mut().enumerate().take(legend_series) { - let label = if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) }; - let row_y = y; - if blank_label(&label) { - y += ls * 1.8; - continue; - } - y += ls * 1.8 * label.split('|').count() as f64; - let y = row_y; - // Series drawn with markers show their marker design in the legend, and series drawn with a column design - // that design. - let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line; - let paint = mark(s, true, Paint::None, 0.0); - items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint)); - labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false)); - } - } - // Areas at the back, lines and points in front of columns; one graph type keeps the series order. - let depth = |s: usize| match kind(s) { - GraphKind::Area => 0, - GraphKind::Line => 2, - _ => 1, - }; - // Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just - // behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one - // behind. Left out past SHADOW_BUDGET marks. - if !g.drop_shadow || shadowed.len() > SHADOW_BUDGET { - shadowed.clear(); - } - let mut series: Vec<(usize, Vec>)> = series.into_iter().enumerate().collect(); - // First Column in Front: the first series drawn last in its layer, over the others. - if g.first_column_in_front { - series.reverse(); - } - series.sort_by_key(|(s, _)| depth(*s)); - let mut layer = None; - for (s, items) in series { - if items.is_empty() { - continue; - } - if layer != Some(depth(s)) { - layer = Some(depth(s)); - let copies: Vec> = shadowed.iter().filter(|(t, _)| depth(*t) == depth(s)).map(|(_, n)| shadow_of(b.d, n)).collect(); - if !copies.is_empty() { - let mut grp = Node::group(b.d.alloc_id(), copies); - grp.name = Some("Drop Shadow".into()); - grp.opacity = SHADOW_OPACITY; - b.out.push(Arc::new(grp)); - } - } - let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = b.series(&name, s, items); - b.out.push(grp); - } - b.out.append(&mut on_top); - labels.append(&mut wedge_labels); - if !labels.is_empty() { - let grp = b.group("Legend", labels); - b.out.push(grp); - } - (!b.columns.short).then_some(b.out) -} - -fn children_bounds(children: &[Arc]) -> Option { - children.iter().filter_map(|c| c.geometric_bounds()).reduce(|a, b| a.union(b)) -} - -/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last -/// generation, which is recorded in the spec's `placed` bounds). -fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { - let n = d.node(id).ok_or(EngineError::NoNode(id))?; - if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) - && placed.width() > 1e-9 - && placed.height() > 1e-9 - && (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6 - { - let a = Affine::translate(now.origin().to_vec2()) - * Affine::scale_non_uniform(now.width() / placed.width(), now.height() / placed.height()) - * Affine::translate(-placed.origin().to_vec2()); - spec.rect = a.transform_rect_bbox(spec.rect); - } - let children = generate(d, &spec); - spec.placed = children_bounds(&children); - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - if let Some(ch) = n.children_mut() { - *ch = children; - } - n.extras_mut().graph = Some(Box::new(spec)); - Ok(()) -} - -fn create(s: &mut Session, p: &Value) -> Result { - const C: &str = "graph.create"; - let kind = - str_param(p, "type").map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))).transpose()?.unwrap_or_default(); - let (x, y) = (f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let (w, h) = (f64_or(p, "width", 200.0).abs().max(1.0), f64_or(p, "height", 150.0).abs().max(1.0)); - let mut spec = GraphSpec { kind, rect: Rect::new(x, y, x + w, y + h), ..GraphSpec::default() }; - if !apply_data(&mut spec, p) { - // Starter data so the new graph shows something (edit it with Object → Graph → Data…). - spec.series = vec!["Series 1".into(), "Series 2".into()]; - spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; - spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]]; - } - let parent = s.doc()?.target_parent()?; - let id = s.edit("Graph", |d, sel| { - let gid = d.alloc_id(); - let mut g = Node::group(gid, vec![]); - g.name = Some(format!("{} Graph", kind.label())); - d.insert(parent, usize::MAX, g)?; - regenerate(d, gid, spec)?; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0 })) -} - -/// The selected graph (or the graph containing the selection). -fn target(s: &Session, p: &Value, cmd: &str) -> Result { - let st = s.doc()?; - let start = id_param(p, "id").into_iter().chain(st.selection.objects.iter().copied()); - for id in start { - let mut cur = Some(id); - while let Some(c) = cur { - if st.doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { - return Ok(c); - } - cur = st.doc.parent_of(c); - } - } - Err(bad(cmd, "select a graph")) -} - -fn spec_of(s: &Session, id: NodeId) -> Result { - s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) -} - -/// Where a node inside a graph's generated series art belongs: the graph, the series group and -/// the series index stored on that group. -#[derive(Clone, Copy)] -struct SeriesRef { - graph: NodeId, - group: NodeId, - index: usize, -} - -/// Every node in a graph's series groups (the groups too), found in one pass over the document. -/// Axes, the legend and groups whose index is past the graph's series (the index comes from the -/// file) are left out. -fn series_members(doc: &Document) -> HashMap { - let mut out = HashMap::new(); - doc.walk(|n| { - let Some(spec) = n.extras().graph.as_deref() else { return }; - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); - for group in n.children().into_iter().flatten() { - let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; - let r = SeriesRef { graph: n.id, group: group.id, index }; - group.walk(&mut |m| { - out.insert(m.id, r); - }); - } - }); - out -} - -/// Group Selection on a graph series targets its constituent marks and legend swatch, not a -/// group-level appearance which the generated art does not inherit. Other ids pass through. -pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { - let members = series_members(doc); - if members.is_empty() { - return ids.to_vec(); - } - let mut out = Vec::new(); - for id in ids { - // The series' own marks: the art of a marker design inside it keeps the design's paint (painting it would - // last only until the graph is drawn again), so edit the design to change it. - match members.get(id) { - Some(r) if r.group == *id => { - if let Some(group) = doc.node(*id) { - out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id)); - } - } - Some(r) if doc.parent_of(*id) != Some(r.group) => {} - _ => out.push(*id), - } - } - out.sort_unstable(); - out.dedup(); - out -} - -/// Copy the paint just applied to generated series art into that series' fill or stroke. Called -/// inside the same document edit, so the visible paint and future regeneration undo together. -/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour. -/// Returns whether a series' captured fill or stroke changed. -pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool { - let members = series_members(doc); - if members.is_empty() { - return false; - } - let captures: Vec<_> = ids - .iter() - .filter_map(|id| { - // Only a series' own marks set its paint, not the art of a marker design. - let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; - let node = doc.node(*id)?; - let paint = node.appearance.paint_at(None, fill)?.clone(); - let width = (!fill).then(|| node.appearance.stroke_width()); - Some((r.graph, r.index, paint, width)) - }) - .collect(); - let mut changed = false; - for (graph, index, paint, width) in captures { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - let was = spec.series_paints.clone(); - // `index` is below the graph's series count (`series_members`). - if spec.series_paints.len() <= index { - spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); - } - let Some(slot) = spec.series_paints.get_mut(index) else { continue }; - if fill { - slot.fill = Some(paint); - } else { - slot.stroke = Some(paint); - slot.stroke_width = width; - } - changed |= spec.series_paints != was; - } - changed -} - -/// Graph Data › Transpose row/column: categories become series and series categories. -fn transpose(g: &mut GraphSpec) { - let cells = g.cells(); - let width = cells.iter().map(Vec::len).max().unwrap_or(0); - let flipped: Vec>> = (0..width).map(|s| cells.iter().map(|row| row.get(s).copied().flatten()).collect()).collect(); - std::mem::swap(&mut g.series, &mut g.categories); - g.set_cells(flipped); -} - -/// Graph Data › Switch x/y (scatter graphs): each series' y and x columns trade places, with their labels. -fn switch_xy(g: &mut GraphSpec) { - let mut cells = g.cells(); - // Labels for every column, so each pair's labels trade places with its data. - let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len()); - g.series.resize(width, String::new()); - for row in &mut cells { - for pair in row.as_chunks_mut::<2>().0 { - pair.swap(0, 1); - } - } - for pair in g.series.as_chunks_mut::<2>().0 { - pair.swap(0, 1); - } - while g.series.last().is_some_and(String::is_empty) { - g.series.pop(); - } - g.set_cells(cells); -} - -/// Every generated label of the document's graphs: its graph and what it shows. Inside a graph's Axes group the -/// value axis numbers come first and the category labels (one per category with a label) last; a scatter graph's -/// are all numbers, a radar graph's all categories. Legend groups hold legend labels, and a pie graph's own texts -/// name its pies. -fn graph_labels(doc: &Document) -> HashMap { - let mut out = HashMap::new(); - doc.walk(|n| { - let Some(g) = n.extras().graph.as_deref() else { return }; - for c in n.children().into_iter().flatten() { - match (&c.kind, c.name.as_deref(), c.extras().series_index) { - (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { - out.insert(c.id, (n.id, Role::Category)); - } - (NodeKind::Group { .. }, Some("Legend"), None) => { - for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) { - out.insert(t.id, (n.id, Role::Legend)); - } - } - (NodeKind::Group { .. }, Some("Axes"), None) => { - let texts: Vec = - c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect(); - let cats = match g.kind { - GraphKind::Scatter => 0, - GraphKind::Radar => texts.len(), - _ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()), - }; - for (i, t) in texts.iter().enumerate() { - out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value })); - } - } - _ => {} - } - } - }); - out -} - -/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels -/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or -/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so -/// they undo together. Returns whether a kept style changed. -pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { - if ids.is_empty() { - return false; - } - let labels = graph_labels(doc); - if labels.is_empty() { - return false; - } - let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); - kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); - kinds.dedup(); - let mut changed = false; - for (graph, role) in kinds { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - let mut style = label_style(&spec.label_styles, role); - change(&mut style); - let slot = match role { - Role::Value => &mut spec.label_styles.values, - Role::Category => &mut spec.label_styles.categories, - Role::Legend => &mut spec.label_styles.legend, - }; - changed |= slot.as_ref() != Some(&style); - *slot = Some(style); - } - changed -} - -fn set_data(s: &mut Session, p: &Value) -> Result { - const C: &str = "graph.setData"; - let id = target(s, p, C)?; - let mut spec = spec_of(s, id)?; - let flip = bool_or(p, "transpose", false); - let swap = bool_or(p, "switchXY", false); - if swap && spec.kind != GraphKind::Scatter { - return Err(bad(C, "Switch x/y is for scatter graphs")); - } - // Transpose and Switch x/y act after any new data in the same call. - let changed = apply_data(&mut spec, p); - if flip { - // The categories become series: at most as many as a graph has series. - if spec.cells().len() > vectorcraft_doc::MAX_GRAPH_SERIES { - return Err(bad( - C, - format!("a graph has at most {} series, so it transposes with at most that many categories", vectorcraft_doc::MAX_GRAPH_SERIES), - )); - } - transpose(&mut spec); - drop_empty_rows(&mut spec); - } - if swap { - switch_xy(&mut spec); - } - if !(changed || flip || swap) { - // Blank cells come back as null, so the rows can be edited and sent back as they are. - return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() })); - } - // Series assigned to the right axis that the new data no longer has are dropped, so a series added later starts - // on the left. - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0); - spec.right_series.retain(|i| *i < count); - s.edit("Graph Data", |d, _| regenerate(d, id, spec))?; - Ok(json!({ "id": id.0 })) -} - -/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a -/// selection made only of this graph's series (Group Selection). Empty: the whole graph. -fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result> { - Ok(match p.get("seriesIndexes") { - Some(v) => { - let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`seriesIndexes` is a non-empty list of series indexes"))?; - a.iter() - .map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(cmd, format!("no series {i}")))) - .collect::>()? - } - None if p.get("id").is_none() => { - let st = s.doc()?; - let members = series_members(&st.doc); - let picked: Option> = st.selection.objects.iter().map(|n| members.get(n).filter(|r| r.graph == id).map(|r| r.index)).collect(); - let mut out = picked.unwrap_or_default(); - out.sort_unstable(); - out.dedup(); - out - } - None => vec![], - }) -} - -/// Select the series groups `indexes` of graph `id` (they are new nodes after a regeneration). -fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeId, indexes: &[u32]) { - let groups: Vec = d - .node(id) - .and_then(Node::children) - .into_iter() - .flatten() - .filter(|c| c.extras().series_index.is_some_and(|i| indexes.contains(&i))) - .map(|c| c.id) - .collect(); - sel.set(groups); -} - -/// The document's graph design names, in order. -fn design_names(d: &Document) -> Vec { - d.graph_designs.iter().map(|x| x.name.clone()).collect() -} - -/// Whether graph `g` draws a series with design `name`, as its marker or its columns. -fn draws_with(g: &GraphSpec, name: &str) -> bool { - g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name) -} - -/// Regenerate the graphs whose series use design `name` (it was added or removed). -fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { - let mut ids = vec![]; - d.walk(|n| { - if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) { - ids.push(n.id); - } - }); - for id in ids { - if let Some(spec) = d.node(id).and_then(|n| n.extras().graph.as_deref().cloned()) { - regenerate(d, id, spec)?; - } - } - Ok(()) -} - -/// The designs no graph's series draw with (Select Unused). -fn unused_designs(d: &Document) -> Vec { - let mut used = std::collections::HashSet::new(); - d.walk(|n| { - if let Some(g) = n.extras().graph.as_deref() { - used.extend(g.series_markers.iter().flatten().cloned()); - used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone())); - } - }); - d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect() -} - -/// Design names from param `key`: one name or a list of names. -fn names_param(p: &Value, key: &str, cmd: &str) -> Result>> { - match p.get(key) { - None => Ok(None), - Some(Value::String(n)) => Ok(Some(vec![n.clone()])), - Some(Value::Array(a)) if !a.is_empty() => a - .iter() - .map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(cmd, format!("`{key}` is a design name or a list of them")))) - .collect::>() - .map(Some), - Some(_) => Err(bad(cmd, format!("`{key}` is a design name or a list of them"))), - } -} - -fn design(s: &mut Session, p: &Value) -> Result { - const C: &str = "graph.design"; - for key in ["paste", "rename", "to"] { - if p.get(key).is_some_and(|v| !v.is_string()) { - return Err(bad(C, format!("`{key}` is a design name"))); - } - } - let taken = |s: &Session, name: &str| s.doc().is_ok_and(|st| st.doc.graph_designs.iter().any(|x| x.name == name)); - // New Design: the selected art, named `new` or "New Design" (numbered when that's taken). - if let Some(v) = p.get("new") { - let name = match v { - Value::String(n) => GraphDesign::clean_name(n).ok_or_else(|| bad(C, "a design needs a name"))?, - Value::Bool(true) | Value::Null => super::unique_name("New Design", |n| taken(s, n)), - _ => return Err(bad(C, "`new` is a design name, or true")), - }; - if taken(s, &name) { - return Err(bad(C, format!("there is already a design named `{name}`"))); - } - if s.doc()?.doc.graph_designs.len() >= GraphDesign::MAX { - return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX))); - } - let art = super::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?; - if art.count_with_masks() > GraphDesign::MAX_NODES { - return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES))); - } - // A graph saved as a design is kept as the art it draws. - let art = GraphDesign::plain_art(&art); - let saved = name.clone(); - s.edit("New Design", |d, _| { - let art = d.reid(&art); - d.graph_designs.push(GraphDesign { name: saved.clone(), art: Arc::new(art) }); - refresh_graphs_using(d, &saved) - })?; - return Ok(json!({ "name": name })); - } - // Rename: the graphs drawing the design follow it to its new name. - if let Some(old) = str_param(p, "rename") { - if !taken(s, old) { - return Err(bad(C, format!("no design named `{old}`"))); - } - let to = str_param(p, "to").and_then(GraphDesign::clean_name).ok_or_else(|| bad(C, "`to` is the design's new name"))?; - if to == old { - return Ok(json!({ "name": to })); - } - if taken(s, &to) { - return Err(bad(C, format!("there is already a design named `{to}`"))); - } - let (old, new) = (old.to_string(), to.clone()); - s.edit("Rename Design", |d, _| { - if let Some(x) = d.graph_designs.iter_mut().find(|x| x.name == old) { - x.name = new.clone(); - } - let mut graphs = vec![]; - d.walk(|n| { - if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) { - graphs.push(n.id); - } - }); - for id in graphs { - if let Some(g) = d.node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { - for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) { - *m = new.clone(); - } - for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) { - c.design = new.clone(); - } - } - } - Ok(()) - })?; - return Ok(json!({ "name": to })); - } - if let Some(name) = str_param(p, "paste") { - let art = s - .doc()? - .doc - .graph_designs - .iter() - .find(|x| x.name == name) - .map(|x| x.art.clone()) - .ok_or_else(|| bad(C, format!("no design named `{name}`")))?; - let parent = s.doc()?.insertion_parent(); - let id = s.edit("Paste Design", |d, sel| { - let n = d.reid(&art); - let id = n.id; - d.insert(parent, usize::MAX, n)?; - sel.set([id]); - Ok(id) - })?; - return Ok(json!({ "id": id.0 })); - } - if let Some(names) = names_param(p, "delete", C)? { - if let Some(n) = names.iter().find(|n| !taken(s, n)) { - return Err(bad(C, format!("no design named `{n}`"))); - } - s.edit("Delete Design", |d, _| { - d.graph_designs.retain(|x| !names.contains(&x.name)); - for name in &names { - refresh_graphs_using(d, name)?; - } - Ok(()) - })?; - return ok(); - } - // The list, with the designs no graph draws with (Select Unused picks those). - let d = &s.doc()?.doc; - Ok(json!({ "designs": design_names(d), "unused": unused_designs(d) })) -} - -fn set_marker(s: &mut Session, p: &Value) -> Result { - const C: &str = "graph.marker"; - let id = target(s, p, C)?; - let mut spec = spec_of(s, id)?; - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); - let picked = picked_series(s, p, id, count, C)?; - let names = design_names(&s.doc()?.doc); - let Some(v) = p.get("design") else { - // The design the picked series (all of them with none picked) share, or null. - let all: Vec = if picked.is_empty() { (0..count).collect() } else { picked }; - // A design deleted since doesn't count: those series draw the default marker. - let used: Vec> = - all.iter().map(|i| spec.series_markers.get(*i).and_then(Option::as_ref).filter(|n| names.contains(n))).collect(); - let shared = used.first().copied().flatten().filter(|d| used.iter().all(|x| *x == Some(*d))); - return Ok(json!({ "designs": names, "design": shared })); - }; - let design = match v { - Value::Null => None, - Value::String(n) if names.contains(n) => Some(n.clone()), - Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))), - _ => return Err(bad(C, "`design` is a design name, or null for the default marker")), - }; - let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; - let targets: Vec = if picked.is_empty() { (0..count).collect() } else { picked }; - spec.series_markers.truncate(count); - spec.series_markers.resize(count, None); - for i in targets { - if let Some(m) = spec.series_markers.get_mut(i) { - *m = design.clone(); - } - } - while spec.series_markers.last().is_some_and(Option::is_none) { - spec.series_markers.pop(); - } - if marker_cost(&spec, &marker_designs(&s.doc()?.doc, &spec)) > MARKER_BUDGET { - return Err(bad(C, "the design is too big to draw at every data point of this graph")); - } - s.edit("Graph Marker", |d, sel| { - regenerate(d, id, spec)?; - if !reselect.is_empty() { - reselect_series(d, sel, id, &reselect); - } - Ok(()) - })?; - Ok(json!({ "id": id.0 })) -} - -fn set_column(s: &mut Session, p: &Value) -> Result { - const C: &str = "graph.column"; - let id = target(s, p, C)?; - let mut spec = spec_of(s, id)?; - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); - let picked = picked_series(s, p, id, count, C)?; - let names = design_names(&s.doc()?.doc); - let all: Vec = if picked.is_empty() { (0..count).collect() } else { picked.clone() }; - // The column design the picked series (all of them with none picked) share; a design deleted since doesn't count, - // as those series draw plain columns. - let used: Vec> = - all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect(); - let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned(); - let Some(v) = p.get("design") else { - let options = shared.clone().unwrap_or_default(); - return Ok(json!({ - "designs": names, - "design": shared.map(|c| c.design), - "columnType": options.column_type.id(), - "each": options.each, - "fractions": options.fractions.id(), - })); - }; - let design = match v { - Value::Null => None, - Value::String(n) if names.contains(n) => Some(n.clone()), - Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))), - _ => return Err(bad(C, "`design` is a design name, or null for plain columns")), - }; - let mut options = shared.unwrap_or_default(); - if let Some(t) = p.get("columnType") { - options.column_type = - t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?; - } - if let Some(e) = p.get("each") { - options.each = - e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?; - } - if let Some(f) = p.get("fractions") { - options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?; - } - let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; - spec.series_columns.truncate(count); - spec.series_columns.resize(count, None); - for i in all { - if let Some(c) = spec.series_columns.get_mut(i) { - *c = design.clone().map(|design| ColumnDesign { design, ..options.clone() }); - } - } - while spec.series_columns.last().is_some_and(Option::is_none) { - spec.series_columns.pop(); - } - // Drawn once on a copy of the document, to refuse designs past the budget before the edit. - let mut probe = (*s.doc()?.doc).clone(); - let looks = column_designs(&probe, &spec); - if generate_with(&mut probe, &spec, &looks).is_none() { - return Err(bad(C, "the design is too big to draw in every column of this graph")); - } - s.edit("Graph Column", |d, sel| { - regenerate(d, id, spec)?; - if !reselect.is_empty() { - reselect_series(d, sel, id, &reselect); - } - Ok(()) - })?; - Ok(json!({ "id": id.0 })) -} - -fn set_type(s: &mut Session, p: &Value) -> Result { - const C: &str = "graph.setType"; - let id = target(s, p, C)?; - let mut spec = spec_of(s, id)?; - // The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group - // Selection). Anything else selected, the graph itself for one, is the whole graph. - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); - // A file's list of right-axis series, kept to series the graph has, at most once each. - spec.right_series.retain(|i| *i < count); - spec.right_series.sort_unstable(); - spec.right_series.dedup(); - if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() { - return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`")); - } - let picked = picked_series(s, p, id, count, C)?; - let keys = [ - "type", - "seriesIndexes", - "columnWidth", - "clusterWidth", - "legend", - "markPoints", - "connectPoints", - "edgeToEdge", - "ticks", - "axisMin", - "axisMax", - "valueAxis", - "separateScales", - "rightTicks", - "rightAxisMin", - "rightAxisMax", - "tickLength", - "tickMarks", - "rightTickLength", - "rightTickMarks", - "categoryTickLength", - "categoryTickMarks", - "ticksBetweenLabels", - "prefix", - "suffix", - "rightPrefix", - "rightSuffix", - "dropShadow", - "legendAcrossTop", - "firstRowInFront", - "firstColumnInFront", - "filledLines", - "pieLegend", - "piePosition", - "pieSort", - ]; - if !keys.iter().any(|k| p.get(*k).is_some()) { - // With series picked, the type and value axis they share, so the dialog's OK keeps them. - let kinds: Vec = picked.iter().map(|i| spec.series_kind(*i)).collect(); - let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind); - // Series on both axes give no value axis (null), so OK leaves each where it is. - let sides: Vec = picked.iter().map(|i| spec.right_series.binary_search(i).is_ok()).collect(); - let value_axis = match sides.first() { - None => Some(spec.value_axis.id()), - Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }), - Some(_) => None, - }; - let mut fields = json!({ - "type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend, - "markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks, - "axisMin": spec.axis_min, "axisMax": spec.axis_max, "valueAxis": value_axis, "separateScales": spec.separate_scales, - "rightTicks": spec.right_ticks, "rightAxisMin": spec.right_axis_min, "rightAxisMax": spec.right_axis_max, - "tickLength": spec.tick_length.id(), "tickMarks": spec.tick_marks.clamp(1, MAX_TICK_MARKS), "rightTickLength": spec.right_tick_length.id(), - "rightTickMarks": spec.right_tick_marks.clamp(1, MAX_TICK_MARKS), "categoryTickLength": spec.category_tick_length.id(), - "categoryTickMarks": spec.category_tick_marks.clamp(1, MAX_TICK_MARKS), "ticksBetweenLabels": spec.ticks_between_labels, - "prefix": one_line(&spec.prefix), "suffix": one_line(&spec.suffix), "rightPrefix": one_line(&spec.right_prefix), - "rightSuffix": one_line(&spec.right_suffix), "dropShadow": spec.drop_shadow, "legendAcrossTop": spec.legend_across_top, - "firstRowInFront": spec.first_row_in_front, "firstColumnInFront": spec.first_column_in_front, "filledLines": spec.filled_lines, - }); - // Only the options this graph's types use: overlap order for columns and bars, filled lines for lines. - let kinds: Vec = (0..count.max(1)).map(|i| spec.series_kind(i)).collect(); - let has = |ks: &[GraphKind]| kinds.iter().any(|k| ks.contains(k)); - if let Some(o) = fields.as_object_mut() { - if !has(&[GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar]) { - o.remove("firstRowInFront"); - o.remove("firstColumnInFront"); - } - if !has(&[GraphKind::Line, GraphKind::Scatter, GraphKind::Radar]) { - o.remove("filledLines"); - } - } - // Pie graphs: Legend, Position and Sort (their Legend stands for the legend checkbox). - if let Some(o) = fields.as_object_mut().filter(|_| spec.kind == GraphKind::Pie) { - o.remove("legend"); - o.insert( - "pieLegend".into(), - json!(if !spec.legend { - "none" - } else if spec.pie_legend_in_wedges { - "wedges" - } else { - "standard" - }), - ); - o.insert("piePosition".into(), json!(spec.pie_position.id())); - o.insert("pieSort".into(), json!(spec.pie_sort.id())); - } - return Ok(fields); - } - // Series picked with Group Selection stay selected through the regeneration (their groups are new nodes). - let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; - if let Some(t) = str_param(p, "type") { - let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?; - if picked.is_empty() { - spec.kind = kind; - } else { - if !kind.combines_with(spec.kind) { - return Err(bad(C, format!("a {} series can't go in a {} graph", kind.label(), spec.kind.label()))); - } - spec.series_kinds.resize(count, None); - for &i in &picked { - if let Some(k) = spec.series_kinds.get_mut(i) { - *k = (kind != spec.kind).then_some(kind); - } - } - while spec.series_kinds.last().is_some_and(Option::is_none) { - spec.series_kinds.pop(); - } - } - } - if let Some(v) = str_param(p, "valueAxis") { - let side = ValueAxisSide::parse(v).ok_or_else(|| bad(C, format!("unknown value axis `{v}` (left, right or both)")))?; - if picked.is_empty() { - spec.value_axis = side; - } else { - // A series goes on one axis. - if side == ValueAxisSide::Both { - return Err(bad(C, "a series goes on the left or the right value axis")); - } - spec.right_series.retain(|i| *i < count && !picked.contains(i)); - if side == ValueAxisSide::Right { - spec.right_series.extend(&picked); - spec.right_series.sort_unstable(); - } - } - } - spec.separate_scales = bool_or(p, "separateScales", spec.separate_scales); - for (key, slot) in [ - ("tickLength", &mut spec.tick_length), - ("rightTickLength", &mut spec.right_tick_length), - ("categoryTickLength", &mut spec.category_tick_length), - ] { - if let Some(v) = str_param(p, key) { - *slot = TickLength::parse(v).ok_or_else(|| bad(C, format!("unknown {key} `{v}` (none, short or full)")))?; - } - } - for (key, slot) in - [("tickMarks", &mut spec.tick_marks), ("rightTickMarks", &mut spec.right_tick_marks), ("categoryTickMarks", &mut spec.category_tick_marks)] - { - *slot = count_param(p, key, MAX_TICK_MARKS as u64).map_or(*slot, |n| n.max(1)); - } - spec.ticks_between_labels = bool_or(p, "ticksBetweenLabels", spec.ticks_between_labels); - if let Some(v) = str_param(p, "piePosition") { - spec.pie_position = - vectorcraft_doc::PiePosition::parse(v).ok_or_else(|| bad(C, format!("unknown piePosition `{v}` (even, ratio or stacked)")))?; - } - if let Some(v) = str_param(p, "pieSort") { - spec.pie_sort = vectorcraft_doc::PieSort::parse(v).ok_or_else(|| bad(C, format!("unknown pieSort `{v}` (none, all or first)")))?; - } - for (key, slot) in - [("prefix", &mut spec.prefix), ("suffix", &mut spec.suffix), ("rightPrefix", &mut spec.right_prefix), ("rightSuffix", &mut spec.right_suffix)] - { - if let Some(v) = str_param(p, key) { - // A label, not a paragraph: one line, at most MAX_AFFIX characters. - *slot = one_line(v); - } - } - spec.right_ticks = count_param(p, "rightTicks", 100).unwrap_or(spec.right_ticks); - if let Some(v) = p.get("rightAxisMin") { - spec.right_axis_min = v.as_f64(); - } - if let Some(v) = p.get("rightAxisMax") { - spec.right_axis_max = v.as_f64(); - } - spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0); - spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 1000.0); - spec.legend = bool_or(p, "legend", spec.legend); - spec.drop_shadow = bool_or(p, "dropShadow", spec.drop_shadow); - spec.first_row_in_front = bool_or(p, "firstRowInFront", spec.first_row_in_front); - spec.first_column_in_front = bool_or(p, "firstColumnInFront", spec.first_column_in_front); - spec.filled_lines = bool_or(p, "filledLines", spec.filled_lines); - spec.legend_across_top = bool_or(p, "legendAcrossTop", spec.legend_across_top); - // After `legend`, which the Graph Type dialog sends too. - if let Some(v) = str_param(p, "pieLegend") { - (spec.legend, spec.pie_legend_in_wedges) = match v.to_ascii_lowercase().as_str() { - "none" => (false, false), - "standard" => (true, false), - "wedges" => (true, true), - _ => return Err(bad(C, format!("unknown pieLegend `{v}` (none, standard or wedges)"))), - }; - } - spec.mark_points = bool_or(p, "markPoints", spec.mark_points); - spec.connect_points = bool_or(p, "connectPoints", spec.connect_points); - spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge); - spec.ticks = count_param(p, "ticks", 100).unwrap_or(spec.ticks); - if let Some(v) = p.get("axisMin") { - spec.axis_min = v.as_f64(); - } - if let Some(v) = p.get("axisMax") { - spec.axis_max = v.as_f64(); - } - let label = format!("{} Graph", spec.kind.label()); - s.edit("Graph Type", |d, sel| { - regenerate(d, id, spec)?; - if !reselect.is_empty() { - reselect_series(d, sel, id, &reselect); - } - if let Some(n) = d.node_mut(id) - && n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph")) - { - n.name = Some(label); - } - Ok(()) - })?; - Ok(json!({ "id": id.0 })) -} +mod axis; +mod columns; +mod commands; +mod data; +mod designs; +mod edit; +mod generate; +mod labels; +mod markers; +mod series; #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_color::Paint; - use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; - - use crate::{NodeId, Session}; - - #[test] - fn fmt_value_does_not_saturate_beyond_i64() { - assert_eq!(super::fmt_value(1e19), "10000000000000000000"); - assert_eq!(super::fmt_value(2e19), "20000000000000000000"); - assert_eq!(super::fmt_value(-2e19), "-20000000000000000000"); - assert_eq!(super::fmt_value(42.0), "42"); - assert_eq!(super::fmt_value(-0.0), "0"); - assert_eq!(super::fmt_value(2.5), "2.5"); - } - - fn graph(s: &mut Session, ty: &str) -> NodeId { - let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; - NodeId( - s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ) - } - - fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}")) - } - - /// The generated group stored for series `index`, not a sibling that happens to share its name. - fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) - } - - #[test] - fn every_graph_type_generates_series_groups() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { - let id = graph(&mut s, ty); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.extras().graph.is_some(), "{ty}"); - if ty != "scatter" { - assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); - assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); - } - assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}"); - } - } - - #[test] - fn column_heights_follow_data_and_data_edits_regenerate_in_place() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let heights = |s: &Session| -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - // Bars only (the legend swatch is the 8 × 8 square at the end). - let g = group(&n, "2025"); - let ch = g.children().unwrap(); - ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect() - }; - let h = heights(&s); - assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}"); - // Move the graph, then change the data: it stays where it was moved. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); - let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; - let v = s.execute("graph.setData", &json!({})).unwrap(); - assert!(v["csv"].as_str().unwrap().contains("Q2,5,4")); - s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); - let h2 = heights(&s); - assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}"); - assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); - // Type change keeps the data; undo restores the columns. - s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); - // Selecting a bar inside still targets the graph. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let bar = group(&n, "2024").children().unwrap()[0].id; - s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); - assert!(s.execute("graph.setData", &json!({})).is_ok()); - assert!(matches!(n.kind, NodeKind::Group { .. })); - } - - /// The x of every marker of series `index`, left to right. - fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let mut xs: Vec = series(&n, index) - .children() - .unwrap() - .iter() - .map(|c| c.geometric_bounds().unwrap()) - .filter(|b| (b.width() - 5.0).abs() < 1e-6) - .map(|b| b.center().x) - .collect(); - xs.sort_by(f64::total_cmp); - xs - } - - #[test] - fn edge_to_edge_lines_off_puts_line_points_at_category_centres() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400. - let id = graph(&mut s, "line"); - assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false)); - s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap(); - assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]); - // A graph saved before the option existed keeps its edge-to-edge lines. - let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap(); - assert!(old.edge_to_edge); - } - - #[test] - fn radar_graphs_follow_mark_and_connect_data_points() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "radar"); - let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len(); - // The ring, three markers and the legend swatch. - assert_eq!(parts(&s), 5); - s.execute("graph.setType", &json!({"markPoints": false})).unwrap(); - assert_eq!(parts(&s), 2); - s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap(); - assert_eq!(parts(&s), 4); - } - - #[test] - fn an_overridden_value_axis_is_used_exactly() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let tallest = |s: &Session| -> f64 { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let bars = series(&n, 0).children().unwrap(); - bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max) - }; - // Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot. - assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); - s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap(); - assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s)); - let o = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0))); - // One bound only keeps the calculated (nice) axis around it. - s.execute("graph.setType", &json!({"axisMax": null})).unwrap(); - assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); - } - - #[test] - fn a_scatter_x_axis_that_overflows_does_not_hang() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // Finite x values whose rounded axis end is infinite: this used to add ticks forever. Now the - // command returns: points that far off the plot can't be drawn, which is an error. - let csv = ",y1,x1\nP,1,1.7e308\nQ,2,1.5e308"; - let refused = |r: crate::Result| assert!(r.unwrap_err().to_string().contains("exceed the canvas")); - refused(s.execute("graph.create", &json!({"type": "scatter", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv}))); - // The same data through Graph Data, on an ordinary scatter graph: it stays as it was. - let axes = |s: &Session, id: NodeId| group(s.doc().unwrap().doc.node(id).unwrap(), "Axes").children().unwrap().len(); - let id = make(&mut s, "scatter", ",y1,x1\nP,1,10\nQ,2,20"); - let before = axes(&s, id); - refused(s.execute("graph.setData", &json!({"id": id.0, "csv": csv}))); - assert_eq!(axes(&s, id), before); - } - - #[test] - fn tick_values_end_on_the_maximum_and_never_run_away() { - // Counted, not accumulated: an offset range keeps its last tick. - let t = super::tick_values(1e10, 1e10 + 1.0, 0.01); - assert_eq!(t.len(), 101); - assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3); - // A range too wide for f64, or a step too small to move, gives no ticks or a capped list. - assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty()); - assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001); - assert!(super::tick_values(0.0, 1.0, 0.0).is_empty()); - // An override whose span overflows falls back to the calculated axis instead of hanging. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - graph(&mut s, "column"); - s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap(); - } - - #[test] - fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for ty in GraphKind::ALL { - let id = graph(&mut s, ty.id()); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); - assert_eq!(spec, restored); - let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); - assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); - let selected_group = group(s.doc().unwrap().doc.node(id).unwrap(), "2024"); - assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); - s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); - assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); - let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); - assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); - } - } - - #[test] - fn painting_a_series_is_one_undoable_edit() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); - assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] - .fill - .as_ref() - .and_then(Paint::color) - .unwrap() - .to_hex(), - "#e8573f" - ); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); - assert_eq!( - group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), - Some(super::default_series_color(0)) - ); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] - .fill - .as_ref() - .and_then(Paint::color) - .unwrap() - .to_hex(), - "#e8573f" - ); - } - - /// A fill that a series' marks or a value axis number have already is one undo step when the graph - /// keeps no fill for them (as after a command that paints only the art): the art stays as it was - /// and the graph keeps the fill. Given again, the fill records no undo step. - #[test] - fn a_fill_the_art_has_already_is_kept_by_the_graph_in_one_step() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - let label = text_id(&s, id, "Axes", "4"); - for target in [series_id, label] { - let fill = json!({"ids": [target.0], "color": "#e8573f"}); - s.execute("paint.setFill", &fill).unwrap(); - // The graph keeps nothing, as after a command that paints only the art. - s.edit("Forget", |d, _| { - let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); - g.series_paints.clear(); - g.label_styles = vectorcraft_doc::LabelStyles::default(); - Ok(()) - }) - .unwrap(); - let art = s.doc().unwrap().doc.node(target).unwrap().clone(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("paint.setFill", &fill).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{target:?}"); - assert_eq!(s.doc().unwrap().doc.node(target).unwrap(), &art, "the art is as it was"); - let g = spec(&s, id); - let kept = if target == series_id { g.series_paints[0].fill.clone() } else { g.label_styles.values.map(|st| st.fill) }; - assert_eq!(kept.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#e8573f".into()), "{target:?}"); - s.execute("paint.setFill", &fill).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing left to keep: {target:?}"); - } - } - - #[test] - fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; - s.execute("paint.setFill", &json!({"ids": [bar.0], "color": "#12ab34"})).unwrap(); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let node = s.doc().unwrap().doc.node(id).unwrap(); - let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); - assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); - assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); - } - - /// A series index read from a file past the graph's series is not a series: painting its art - /// paints the art alone and stores nothing (no allocation sized by the file). - #[test] - fn a_series_index_past_the_graphs_series_is_ignored() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.edit("Junk", |d, _| { - d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); - Ok(()) - }) - .unwrap(); - let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; - s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); - let node = s.doc().unwrap().doc.node(id).unwrap(); - assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); - assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); - } - - #[test] - fn painting_a_line_series_stroke_sets_its_persistent_colour() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "line"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); - let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); - assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), - Some(blue) - ); - assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - assert!( - group(s.doc().unwrap().doc.node(id).unwrap(), "2024") - .children() - .unwrap() - .iter() - .any(|n| n.appearance.stroke_paint().color() == Some(blue)) - ); - } - - #[test] - fn series_colours_survive_native_save_open_and_export() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let first = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id; - s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap(); - s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap(); - let dir = vectorcraft_testkit::temp_dir("graph-palette"); - let path = dir.join("palette.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; - assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - let swatch = group(n, "2025").children().unwrap().last().unwrap(); - assert_eq!(swatch.appearance.fill_paint().color().unwrap().to_hex(), "#008080"); - let third = group(n, "Series 3").children().unwrap().first().unwrap(); - assert_eq!(third.appearance.fill_paint().color().unwrap().to_hex(), "#cccccc"); - let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); - let svg = svg["text"].as_str().unwrap(); - assert!(svg.contains("#ff0000") && svg.contains("#008080")); - let png = dir.join("palette.png"); - s.execute("document.export", &json!({"format": "png", "path": png})).unwrap(); - let pixels = image::open(&png).unwrap().to_rgba8(); - assert!(pixels.pixels().any(|p| p.0 == [255, 0, 0, 255])); - assert!(pixels.pixels().any(|p| p.0 == [0, 128, 128, 255])); - let old: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]]})).unwrap(); - assert!(old.series_paints.is_empty(), "older files keep the greyscale default"); - } - - #[test] - fn a_column_stroke_stays_a_stroke_and_a_marker_fill_does_not_recolour_its_line() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); - assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); - assert!(spec.series_paints[0].fill.is_none()); - let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; - assert_eq!(bar.appearance.fill_paint().color(), Some(super::default_series_color(0))); - assert_eq!(bar.appearance.stroke_paint(), blue); - - let id = graph(&mut s, "line"); - let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; - s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - assert!(spec.series_paints[0].stroke.is_none()); - let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); - assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); - assert_eq!(series[1].appearance.fill_paint().color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))); - } - - #[test] - fn a_gradient_fill_survives_regeneration() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute( - "paint.setFill", - &json!({"ids": [series_id.0], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), - ) - .unwrap(); - s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); - let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; - assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); - } - - #[test] - fn a_series_named_legend_keeps_its_own_index_and_an_empty_series_is_omitted() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let csv = ",Legend,Axes\nQ1,3,2\nQ2,5,4"; - let id = NodeId(s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "csv": csv})).unwrap()["id"].as_u64().unwrap()); - let legend_id = { - let n = s.doc().unwrap().doc.node(id).unwrap(); - let legend = series(n, 0); - assert_eq!(legend.name.as_deref(), Some("Legend")); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); - legend.id - }; - s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); - s.execute("graph.setData", &json!({"csv": csv})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); - assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); - assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); - assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - - let id = graph(&mut s, "pie"); - s.execute("graph.setType", &json!({"id": id.0, "legend": false})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "csv": ",Keep,Drop\nQ1,4,0\nQ2,2,0"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Keep"))); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); - let keep = group(n, "Keep").id; - s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(group(n, "Keep").extras().series_index, Some(0)); - assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); - } - - #[test] - fn csv_parsing() { - let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n"); - assert_eq!(s, vec!["2024", "2025"]); - assert_eq!(c, vec!["Q1", "Q2"]); - assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); - let (s, c, r) = super::parse_csv("1,2\n3,4"); - assert!(s.is_empty() && c.is_empty()); - assert_eq!(r.len(), 2); - assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0)); - } - - #[test] - fn numeric_csv_cells_round_trip_without_rounding_or_saturation() { - let mut g = GraphSpec::default(); - g.set_cells(vec![vec![Some(1.2345), Some(-1.2345)], vec![Some(1e-6), Some(-1e-6)], vec![Some(1e20), Some(-1e20)], vec![Some(0.0), None]]); - let csv = super::to_csv(&g); - let (_, _, cells) = super::parse_csv(&csv); - assert_eq!(cells, g.cells(), "{csv}"); - } - - #[test] - fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]); - let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]); - let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]); - let r = s - .execute( - "graph.create", - &json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}), - ) - .unwrap(); - let id = r["id"].as_u64().unwrap(); - let before = s.execute("graph.setData", &json!({"id": id})).unwrap(); - assert_eq!(before["series"], series); - assert_eq!(before["categories"], categories); - s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap(); - let after = s.execute("graph.setData", &json!({"id": id})).unwrap(); - assert_eq!(after["series"], before["series"], "{}", before["csv"]); - assert_eq!(after["categories"], before["categories"], "{}", before["csv"]); - assert_eq!(after["rows"], before["rows"], "{}", before["csv"]); - } - - #[test] - fn empty_cells_are_blank_and_quoted_numbers_are_labels() { - // Quoted years label the rows; empty and non-numeric cells are blank values, not zeros. - let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4"); - assert_eq!(s, vec!["a", "b"]); - assert_eq!(c, vec!["2023", "2024", "2025"]); - assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]); - // Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same. - let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() }; - g.set_cells(r); - assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]); - let csv = super::to_csv(&g); - assert!(csv.contains("\"2024\",,"), "{csv}"); - let (s2, c2, r2) = super::parse_csv(&csv); - assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells())); - } - - #[test] - fn a_row_with_an_empty_label_keeps_its_values_in_place() { - // The label column is the first column of every row once any row has a label. - let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4"); - assert_eq!(c, vec!["Q1", ""]); - assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); - // And a data window round trip keeps it (the empty label is written as ""). - let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() }; - g.set_cells(r); - assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells()); - // Without series or labels, a first row starting with a blank is still data after a round trip. - let mut g = GraphSpec::default(); - g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]); - let (s, c, r) = super::parse_csv(&super::to_csv(&g)); - assert!(s.is_empty() && c.is_empty()); - assert_eq!(r, g.cells()); - // A label with spaces around a number stays a label, spaces and all. - let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() }; - g.set_cells(vec![vec![Some(1.0)]]); - assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]); - } - - #[test] - fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]])); - // A label followed by empty cells is a header with no series names, as before blanks existed. - let (s, c, r) = super::parse_csv("Year,,\n2023,1,2"); - assert!(s.is_empty()); - assert_eq!(c, Vec::::new()); - assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]); - } - - #[test] - fn a_huge_ragged_grid_is_capped() { - let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() }; - assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES)); - } - - #[test] - fn a_label_only_row_stays_with_blank_values() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = - s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); - assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); - assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); - } - - #[test] - fn blank_cells_leave_out_their_columns_and_break_lines() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]); - let r = s - .execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows})) - .unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len(); - // Three columns each (one blank), plus the legend swatch. - assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); - s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); - assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); - // Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three - // markers and the legend swatch. - s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - assert_eq!(count(&s, 0), 1 + 3 + 1); - // Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch. - assert_eq!(count(&s, 1), 1 + 3 + 1); - // A data window round trip keeps the blanks. - let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); - assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); - assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); - // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads - // numbers (as zeros); a graph without blanks writes no `blanks` at all. - let v = serde_json::to_value(&spec).unwrap(); - assert_eq!(v["rows"][1], json!([0.0, 2.0])); - assert_eq!(v["blanks"], json!([[1, 0], [2, 1]])); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none()); - } - - fn spec(s: &Session, id: NodeId) -> GraphSpec { - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() - } - - /// Bounds of a series' marks, without its legend swatch (the last child). - fn marks(s: &Session, id: NodeId, index: u32) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let ch = series(&n, index).children().unwrap().to_vec(); - ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect() - } - - #[test] - fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let one_column = marks(&s, id, 0)[0].width(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line)); - // The columns left take the whole cluster; the line runs through the category centres, one marker a value. - let cols = marks(&s, id, 0); - assert_eq!(cols.len(), 3); - assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width()); - let line = marks(&s, id, 1); - assert_eq!(line.len(), 4, "a line and three markers"); - assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); - // The line series is drawn after (in front of) the column series. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); - assert_eq!(order, [0, 1]); - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); - assert_eq!(order, [1, 0]); - // Back to the graph's own type: nothing is stored for that series. - assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); - // One undo per change. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]); - } - - #[test] - fn group_selecting_a_series_retypes_only_that_series() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let grp = series(&n, 1).id; - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - // The dialog opens on the series' type, and its OK (every option sent back) keeps it. - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); - s.execute("graph.setType", &json!({"type": "area"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area)); - let mut all = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!(all["type"], "area"); - all["ticks"] = json!(4); - s.execute("graph.setType", &all).unwrap(); - assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area); - // The series stays selected through each change. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); - // A series and something outside it selected, or an explicit `id`: the whole graph. - s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column"); - // The whole graph selected: the graph's type changes, the series keeps its own. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area)); - } - - #[test] - fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - for ty in ["pie", "radar", "scatter", "bar"] { - assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}"); - } - for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] { - assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}"); - } - assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type"); - assert!(spec(&s, id).series_kinds.is_empty()); - // A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can. - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - let g = spec(&s, id); - assert_eq!(g.series_kind(1), GraphKind::Pie); - s.execute("graph.setType", &json!({"type": "column"})).unwrap(); - assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line); - } - - #[test] - fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap(); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a"); - // a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top. - assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]); - // With a plain column too, the stack is the cluster's last slot, beside it. - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap(); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]); - } - - #[test] - fn areas_mixed_with_columns_put_their_points_at_the_category_centres() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); - let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1)); - assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); - // The area is drawn behind the columns. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); - assert_eq!(order, [0, 1]); - // An area graph alone still spans the plot. - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); - assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6); - } - - #[test] - fn bar_graphs_take_stacked_bar_series() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err()); - s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap(); - let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2)); - // b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line. - assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]); - assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]); - } - - #[test] - fn series_types_survive_save_and_open_and_older_files_have_none() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]); - let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap(); - assert!(g.series_kinds.is_empty()); - assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none()); - // A stored type the graph can't take, or past its series, is drawn as the graph's type. - let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap(); - assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie)); - } - - /// The value axis labels: text and bounds of the texts in the Axes group that read as numbers. - fn value_labels(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_geom::Rect)> { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => Some((t.plain_text(), c.geometric_bounds().unwrap())), - _ => None, - }) - .filter(|(t, _)| t.parse::().is_ok()) - .collect() - } - - fn two_scale_graph(s: &mut Session) -> NodeId { - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b\nQ1,10,1000\nQ2,5,500"; - NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ) - } - - #[test] - fn the_value_axis_goes_on_the_left_the_right_or_both_sides() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let left: Vec = value_labels(&s, id).into_iter().inspect(|(_, b)| assert!(b.x1 < 100.0, "{b:?}")).map(|(t, _)| t).collect(); - assert_eq!(left, ["0", "200", "400", "600", "800", "1000"]); - s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); - let right = value_labels(&s, id); - assert!(right.iter().all(|(_, b)| b.x0 > 400.0), "{right:?}"); - assert_eq!(right.iter().map(|(t, _)| t.as_str()).collect::>(), left); - // Both sides, one scale: the same labels on each side, and the legend moves past the right ones. - s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); - let both = value_labels(&s, id); - assert_eq!(both.len(), 12); - let edge = both.iter().map(|(_, b)| b.x1).fold(0.0, f64::max); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let swatch = series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap(); - assert!(swatch.x0 > edge, "{swatch:?} vs {edge}"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both"); - assert!(s.execute("graph.setType", &json!({"valueAxis": "top"})).is_err()); - } - - #[test] - fn separate_scales_give_the_right_axis_series_a_scale_of_their_own() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - // Assigned, but drawn on one scale until separate scales are on with both sides. - assert_eq!(spec(&s, id).right_series, [1]); - s.execute("graph.setType", &json!({"separateScales": true})).unwrap(); - assert!((marks(&s, id, 0)[0].height() - 2.0).abs() < 1e-6, "a's 10 of 1000"); - s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); - // a's 10 and b's 1000 both reach the top: 0..10 on the left, 0..1000 on the right. - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].y0 - 100.0).abs() < 1e-6 && (b[0].y0 - 100.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); - let labels = value_labels(&s, id); - let side = |right: bool| labels.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); - assert_eq!(side(false), ["0", "2", "4", "6", "8", "10"]); - assert_eq!(side(true), ["0", "200", "400", "600", "800", "1000"]); - // The right axis' own tick values. - s.execute("graph.setType", &json!({"rightAxisMin": 0, "rightAxisMax": 2000, "rightTicks": 4})).unwrap(); - assert!((marks(&s, id, 1)[0].y0 - 200.0).abs() < 1e-6, "1000 of 2000"); - assert!((marks(&s, id, 0)[0].y0 - 100.0).abs() < 1e-6, "the left axis is unchanged"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["separateScales"].clone(), v["rightTicks"].clone(), v["rightAxisMax"].clone()), (json!(true), json!(4), json!(2000.0))); - // Stacked series stack on their own axis, each axis' stack in a slot of its own. - s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].y1 - 300.0).abs() < 1e-6 && (b[0].y1 - 300.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); - assert!(b[0].x0 >= a[0].x1 - 1e-6 && (a[0].width() - b[0].width()).abs() < 1e-6, "side by side: {:?} {:?}", a[0], b[0]); - // A line on the right axis follows the right scale. - s.execute("graph.setType", &json!({"type": "column"})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let line = marks(&s, id, 1); - assert!((line[1].center().y - 200.0).abs() < 1e-6 && (line[2].center().y - 250.0).abs() < 1e-6, "1000 and 500 of 2000: {line:?}"); - } - - #[test] - fn group_selected_series_go_on_the_chosen_axis() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "left"); - assert!(s.execute("graph.setType", &json!({"valueAxis": "both"})).is_err(), "a series takes one axis"); - s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.right_series.clone(), g.value_axis), (vec![1], vectorcraft_doc::ValueAxisSide::Left)); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "right"); - s.execute("graph.setType", &json!({"valueAxis": "left"})).unwrap(); - assert!(spec(&s, id).right_series.is_empty()); - } - - #[test] - fn bar_graphs_keep_their_value_axis_along_the_bottom() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "bar"); - let before = (value_labels(&s, id), marks(&s, id, 0)); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - assert_eq!((value_labels(&s, id), marks(&s, id, 0)), before); - } - - #[test] - fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert_eq!( - (g.value_axis, g.separate_scales, g.right_series, g.right_axis_max), - (vectorcraft_doc::ValueAxisSide::Both, true, vec![1], Some(2000.0)) - ); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in ["valueAxis", "separateScales", "rightSeries", "rightTicks", "rightAxisMin", "rightAxisMax"] { - assert!(v.get(k).is_none(), "{k}"); - } - } - - #[test] - fn the_dialog_round_trip_keeps_series_on_both_axes_where_they_are() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b,c\nQ1,10,1000,3\nQ2,5,500,4"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - for side in ["both", "left"] { - s.execute("graph.setType", &json!({"valueAxis": side, "separateScales": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - s.execute("select.set", &json!({"ids": [series(&n, 0).id.0, series(&n, 1).id.0]})).unwrap(); - let fields = s.execute("graph.setType", &json!({})).unwrap(); - assert!(fields["valueAxis"].is_null(), "{side}: {fields}"); - s.execute("graph.setType", &fields).unwrap(); - let g = spec(&s, id); - assert_eq!((g.right_series, g.value_axis.id()), (vec![1], side), "{side}"); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - } - } - - #[test] - fn an_axis_with_no_series_shows_the_other_scale() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); - let labels = |s: &Session| { - let l = value_labels(s, id); - let side = |right: bool| l.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); - (side(false), side(true)) - }; - let (left, right) = labels(&s); - assert_eq!(left, right); - assert_eq!(right.last().unwrap(), "1000"); - // Every series on the right: the left axis shows the right scale. - s.execute("graph.setType", &json!({"seriesIndexes": [0, 1], "valueAxis": "right", "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); - let (left, right) = labels(&s); - assert_eq!(left, right); - assert_eq!(right.last().unwrap(), "2000"); - } - - #[test] - fn right_axis_columns_start_at_the_right_scales_zero() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b\nQ1,10,-500\nQ2,5,500"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - // Right scale -600..600 (zero at y 200), left 0..10 (zero at y 300): b's columns meet at the right zero. - let b = marks(&s, id, 1); - assert!((b[0].y0 - 200.0).abs() < 1e-6 && (b[1].y1 - 200.0).abs() < 1e-6, "{b:?}"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let at_200 = group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Path { .. })) - .filter_map(|c| c.geometric_bounds()) - .any(|bb| bb.height() < 1e-6 && (bb.y0 - 200.0).abs() < 1e-6 && (bb.width() - 300.0).abs() < 1e-6); - assert!(at_200, "a zero line for the right scale"); - } - - #[test] - fn data_without_a_right_axis_series_drops_its_assignment() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - s.execute("graph.setData", &json!({"csv": ",a\nQ1,10\nQ2,5"})).unwrap(); - assert!(spec(&s, id).right_series.is_empty()); - s.execute("graph.setData", &json!({"csv": ",a,b\nQ1,10,1000\nQ2,5,500"})).unwrap(); - assert!(spec(&s, id).right_series.is_empty(), "a new series starts on the left"); - } - - /// Bounds of the straight lines in the Axes group. - fn axis_lines(s: &Session, id: NodeId) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Path { .. })) - .filter_map(|c| c.geometric_bounds()) - .collect() - } - - fn axis_texts(s: &Session, id: NodeId) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => Some(t.plain_text()), - _ => None, - }) - .collect() - } - - #[test] - fn value_axis_tick_marks_take_a_length_and_a_count_per_division() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let short = |s: &Session, x0: f64| axis_lines(s, id).iter().filter(|b| (b.x0 - x0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); - let across = |s: &Session| axis_lines(s, id).iter().filter(|b| b.height() < 1e-6 && (b.width() - 300.0).abs() < 1e-6).count(); - // Short (the default): one at each of the six labels; the zero line runs across. - assert_eq!((short(&s, 96.0), across(&s)), (6, 1)); - s.execute("graph.setType", &json!({"tickLength": "full"})).unwrap(); - // Across the plot at 200…1000; the one at 0 would be the zero line again. - assert_eq!((short(&s, 96.0), across(&s)), (0, 6)); - s.execute("graph.setType", &json!({"tickLength": "none"})).unwrap(); - assert_eq!((short(&s, 96.0), across(&s)), (0, 1)); - // Two per division: one more halfway between labels, none past the last label. - s.execute("graph.setType", &json!({"tickLength": "short", "tickMarks": 2})).unwrap(); - assert_eq!(short(&s, 96.0), 11); - assert!(axis_lines(&s, id).iter().any(|b| (b.x0 - 96.0).abs() < 1e-6 && (b.y0 - 280.0).abs() < 1e-6), "100 of 0..1000 sits at y 280"); - assert_eq!(axis_texts(&s, id).iter().filter(|t| t.parse::().is_ok()).count(), 6, "labels only at divisions"); - // The right axis has its own. - s.execute("graph.setType", &json!({"valueAxis": "both", "rightTickLength": "none"})).unwrap(); - assert_eq!((short(&s, 96.0), short(&s, 400.0)), (11, 0)); - s.execute("graph.setType", &json!({"rightTickLength": "short", "rightTickMarks": 4})).unwrap(); - assert_eq!(short(&s, 400.0), 21); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["tickLength"].clone(), v["tickMarks"].clone(), v["rightTickMarks"].clone()), (json!("short"), json!(2), json!(4))); - assert!(s.execute("graph.setType", &json!({"tickLength": "long"})).is_err()); - s.execute("graph.setType", &json!({"tickMarks": 1_000_000})).unwrap(); - assert_eq!(spec(&s, id).tick_marks, 20); - } - - #[test] - fn category_tick_marks_go_at_or_between_the_labels() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let below = |s: &Session| { - let mut xs: Vec = axis_lines(s, id) - .iter() - .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) - .map(|b| b.x0) - .collect(); - xs.sort_by(f64::total_cmp); - xs - }; - // None by default, as before. - assert!(below(&s).is_empty()); - s.execute("graph.setType", &json!({"categoryTickLength": "short"})).unwrap(); - assert_eq!(below(&s), [175.0, 325.0]); - s.execute("graph.setType", &json!({"ticksBetweenLabels": true})).unwrap(); - assert_eq!(below(&s), [100.0, 250.0, 400.0]); - s.execute("graph.setType", &json!({"categoryTickMarks": 2})).unwrap(); - assert_eq!(below(&s), [100.0, 175.0, 250.0, 325.0, 400.0]); - s.execute("graph.setType", &json!({"categoryTickLength": "full"})).unwrap(); - let full = axis_lines(&s, id).iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6 && b.x0 > 100.0 + 1e-6).count(); - assert_eq!(full, 4, "inside the plot: 175, 250, 325 and 400"); - // Bar graphs: the category axis is the left one. - let bar = graph(&mut s, "bar"); - s.execute("graph.setType", &json!({"id": bar.0, "categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); - let left = axis_lines(&s, bar).iter().filter(|b| b.height() < 1e-6 && (b.x0 - 96.0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); - assert_eq!(left, 4, "three categories, four edges"); - } - - #[test] - fn value_axis_numbers_take_a_prefix_and_a_suffix() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"prefix": "$", "suffix": " M", "valueAxis": "both", "separateScales": true})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right", "rightSuffix": "%"})).unwrap(); - let texts = axis_texts(&s, id); - assert!(texts.contains(&"$10 M".to_string()) && texts.contains(&"$0 M".to_string()), "{texts:?}"); - assert!(texts.contains(&"1000%".to_string()) && !texts.contains(&"$1000 M".to_string()), "{texts:?}"); - // One short line: control characters dropped, the length capped. - s.execute("graph.setType", &json!({"prefix": format!("a\nb{}", "x".repeat(500))})).unwrap(); - let p = spec(&s, id).prefix; - assert!(p.starts_with("ab") && p.chars().count() == 64, "{p}"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["suffix"].clone(), v["rightSuffix"].clone()), (json!(" M"), json!("%"))); - } - - #[test] - fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute( - "graph.setType", - &json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}), - ) - .unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert_eq!( - (g.tick_length, g.tick_marks, g.category_tick_length, g.ticks_between_labels, g.suffix.as_str()), - (vectorcraft_doc::TickLength::Full, 3, vectorcraft_doc::TickLength::Short, true, "%") - ); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in [ - "tickLength", - "tickMarks", - "rightTickLength", - "rightTickMarks", - "categoryTickLength", - "categoryTickMarks", - "ticksBetweenLabels", - "prefix", - "suffix", - "rightPrefix", - "rightSuffix", - ] { - assert!(v.get(k).is_none(), "{k}"); - } - // A file's huge values draw within the caps. - let g: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]], "tickMarks": 1_000_000_000u64, "prefix": "y".repeat(10_000)})).unwrap(); - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let art = super::generate(&mut d, &g); - let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap().children().unwrap().to_vec(); - assert!(axes.len() < 200, "{} axis parts", axes.len()); - for t in axes.iter().filter_map(|n| match &n.kind { - NodeKind::Text(t) => Some(t.plain_text()), - _ => None, - }) { - assert!(t.chars().count() < 80, "{} characters", t.chars().count()); - } - } - - #[test] - fn between_labels_ticks_on_an_edge_to_edge_graph_fall_halfway_between_its_labels() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "area"); - s.execute("graph.setType", &json!({"categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); - // Labels at 100, 250 and 400 (the plot's edges and middle): ticks at 175 and 325. - let mut xs: Vec = axis_lines(&s, id) - .iter() - .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) - .map(|b| b.x0) - .collect(); - xs.sort_by(f64::total_cmp); - assert_eq!(xs, [175.0, 325.0]); - } - - #[test] - fn bar_graph_value_ticks_run_up_the_plot_and_long_labels_push_the_legend() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "bar"); - let legend_x = |s: &Session| { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap().x0 - }; - let before = legend_x(&s); - assert!((before - 414.0).abs() < 1e-6, "a short last label leaves the legend where it was: {before}"); - s.execute("graph.setType", &json!({"tickLength": "full", "tickMarks": 2})).unwrap(); - let up: Vec<_> = axis_lines(&s, id).into_iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6).collect(); - // -2..6 in 2s: five labels and four halves; the one at 0 is the zero line already, the left end the axis. - assert_eq!(up.len(), 9, "{up:?}"); - s.execute("graph.setType", &json!({"suffix": " million tonnes"})).unwrap(); - let last = value_label_edge(&s, id); - assert!(legend_x(&s) >= last + 6.0 - 1e-6, "{} vs {last}", legend_x(&s)); - } - - /// The right end of the Axes group's texts. - fn value_label_edge(s: &Session, id: NodeId) -> f64 { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Text(_))) - .filter_map(|c| c.geometric_bounds()) - .map(|b| b.x1) - .fold(f64::MIN, f64::max) - } - - #[test] - fn counts_come_back_from_the_dialog_as_floats_and_are_capped() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3)); - s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0)); - // A file's values come back as drawn. - let mut fields = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!(fields["rightTickMarks"], 20); - fields["tickLength"] = json!("full"); - s.execute("graph.setType", &fields).unwrap(); - assert_eq!(spec(&s, id).tick_length, vectorcraft_doc::TickLength::Full); - } - - #[test] - fn a_long_category_axis_is_ticked_all_the_way_within_the_cap() { - let rows: Vec> = (0..5000).map(|i| vec![i as f64]).collect(); - let g = GraphSpec { rows, category_tick_length: vectorcraft_doc::TickLength::Short, category_tick_marks: 20, ..GraphSpec::default() }; - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let art = super::generate(&mut d, &g); - let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); - let ticks: Vec = axes - .children() - .unwrap() - .iter() - .filter_map(|c| c.geometric_bounds()) - .filter(|b| b.width() < 1e-9 && (b.height() - 4.0).abs() < 1e-9) - .map(|b| b.x0) - .collect(); - assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len()); - assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked"); - } - - /// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`. - fn save_design(s: &mut Session, name: &str) { - let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r, e]})).unwrap(); - s.execute("graph.design", &json!({"new": name})).unwrap(); - } - - /// The marks of series `index` (legend swatch last) and their bounds. - fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect() - } - - #[test] - fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); - let id = graph(&mut s, "line"); - let before = series_parts(&s, id, 1); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - let after = series_parts(&s, id, 1); - assert_eq!(after.len(), before.len(), "a line, three markers and the swatch"); - for (old, new) in before.iter().zip(&after).skip(1) { - let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap()); - assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art"); - // The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide. - assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}"); - assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}"); - } - // The series stays selected; the other series keeps its squares; the query names the design. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill"); - assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]); - // Back to the default square. - s.execute("graph.marker", &json!({"design": null})).unwrap(); - assert!(spec(&s, id).series_markers.is_empty()); - assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err()); - assert!(s.execute("graph.marker", &json!({"design": 3})).is_err()); - } - - #[test] - fn marker_designs_apply_to_markers_not_to_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - // Every series (none picked): the line series' markers and swatch; the columns and their swatch stay. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - let line = series_parts(&s, id, 1); - assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch"); - let swatch = line.last().unwrap().geometric_bounds().unwrap(); - assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}"); - // Scatter and radar graphs draw them too. - for ty in ["scatter", "radar"] { - let g = graph(&mut s, ty); - s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}"); - } - } - - #[test] - fn graph_designs_are_saved_pasted_and_deleted() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "nothing selected"); - save_design(&mut s, "Pill"); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "the name is taken"); - assert!(s.execute("graph.design", &json!({"new": " "})).is_err(), "no name"); - // Paste Design: a copy of the art, selected, with ids of its own. - let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap()); - assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]); - let art = s.doc().unwrap().doc.graph_designs[0].art.clone(); - let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone(); - assert_ne!(copy.id, art.id); - assert_eq!(copy.geometric_bounds(), art.geometric_bounds()); - assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err()); - // Deleting a design draws its graphs' default markers again; undo brings both back. - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); - assert!(s.doc().unwrap().doc.graph_designs.is_empty()); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. }))); - assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err()); - } - - #[test] - fn designs_and_markers_survive_save_and_open() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); - assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none()); - // A design whose art has no area draws the default square. - let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() }; - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - } - - #[test] - fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); - let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); - let mut graphs = 0; - art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); - assert_eq!(graphs, 0, "no graph inside a design"); - // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. - s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); - s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); - let mut found = 0; - s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); - assert_eq!(found, 1); - // A deleted design reads as none in the query, so the dialog opens on the default marker. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap(); - let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap(); - assert!(v["design"].is_null(), "{v}"); - } - - #[test] - fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // 1,500 objects at each of 200 points is past the budget. - let ids: Vec = (0..1500) - .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) - .collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": "Big"})).unwrap(); - let rows: Vec> = (0..200).map(|i| vec![i as f64]).collect(); - let id = NodeId( - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err()); - assert!(spec(&s, id).series_markers.is_empty()); - // A file asking for it anyway draws the default squares. - let mut g = spec(&s, id); - g.series_markers = vec![Some("Big".into())]; - let mut d = (*s.doc().unwrap().doc).clone(); - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - // Too many objects for one design. - let ids: Vec = (0..2001) - .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) - .collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - assert!(s.execute("graph.design", &json!({"new": "Huge"})).is_err()); - } - - #[test] - fn a_files_designs_are_tidied() { - let mut d = vectorcraft_doc::Document::new(100.0, 100.0); - let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); - let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); - for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { - let a = art(&mut d); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); - } - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) }); - d.tidy_graph_designs(); - let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); - assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); - assert!(d.graph_designs[2].art.extras().graph.is_none()); - } - - #[test] - fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); - // The design's paths keep the design's paint, before and after the graph is drawn again. - let design_fill = |s: &Session| { - let parts = series_parts(s, id, 0); - let mut fills = vec![]; - parts[1].walk(&mut |n| { - if let NodeKind::Path { .. } = n.kind { - fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex())); - } - }); - fills - }; - let painted = design_fill(&s); - assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}"); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert_eq!(design_fill(&s), painted); - // A design of one path too. - let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - s.execute("graph.design", &json!({"new": "Square"})).unwrap(); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Square"})).unwrap(); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); - assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]); - } - - #[test] - fn graph_design_params_are_names() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - for p in [json!({"new": 5}), json!({"paste": null}), json!({"delete": ["a"]})] { - assert!(s.execute("graph.design", &p).is_err(), "{p}"); - } - } - - #[test] - fn document_wide_passes_see_design_art() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - s.execute("graph.design", &json!({"new": "Blue"})).unwrap(); - s.execute("edit.clear", &json!({})).unwrap(); - let mut seen = false; - s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456")); - assert!(seen, "the colour is used by the design"); - } - - #[test] - fn new_design_rename_delete_and_select_unused() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - // New Design names it "New Design", numbered when that's taken. - assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design"); - assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap(); - let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap(); - assert_eq!(v["unused"], json!(["New Design 2"])); - // Renaming a design the graph draws with: the graph follows, still drawing it. - s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap(); - assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]); - assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken"); - assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err()); - // Delete several at once. - s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap(); - assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([])); - assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err()); - } - - /// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle - /// over the rest, with a horizontal guide line between them when `guide`, saved as `name`. - fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) { - let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| { - s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() - }; - let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)]; - ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top)); - if guide { - let line = - s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [line]})).unwrap(); - s.execute("view.guides.make", &json!({})).unwrap(); - ids.push(line); - } - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": name})).unwrap(); - } - - /// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero - /// line at y = 300. - fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId { - let rows: Vec> = values.iter().map(|v| vec![*v]).collect(); - let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows}); - let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap()); - s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap(); - id - } - - /// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it. - fn frames(n: &vectorcraft_doc::Node) -> Vec { - n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect() - } - - fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool { - [a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6) - } - - #[test] - fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = one_series(&mut s, "column", &[35.0]); - let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); - assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}"); - s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap(); - // 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value. - let column = series_parts(&s, id, 0)[0].clone(); - let parts = column.children().unwrap(); - assert_eq!(parts.len(), 4, "3.5 designs"); - let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1]; - let copies = frames(&column); - assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}"); - let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) }; - assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy"); - let (clip, copy) = - (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap()); - assert_eq!(clip.y0, 160.0, "cut off at the value"); - // Its sides and its foot, strokes included, are not cut. - assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}"); - // Nor is the brush art of a brushed path inside a group of the design. - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0)); - let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); - let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); - for item in &mut look.items { - if let vectorcraft_doc::AppearanceItem::Stroke(st) = item { - st.brush = Some("Wide".into()); - } - } - let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look); - let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]); - let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) }); - let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() }; - let g = GraphSpec { - rows: vec![vec![35.0]], - axis_min: Some(0.0), - axis_max: Some(50.0), - series_columns: vec![Some(options)], - ..GraphSpec::default() - }; - let out = super::generate(&mut d, &g); - let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); - let last = column.children().unwrap().last().unwrap().clone(); - let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) }; - let clip = children[0].geometric_bounds().unwrap(); - let mut reach = None; - children[1].walk(&mut |n| { - if vectorcraft_brush::has_brush(n) { - reach = vectorcraft_render::painted_bounds(&d, n); - } - }); - let reach = reach.unwrap(); - assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}"); - // Scale: the last copy half as tall instead. - s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap(); - let column = series_parts(&s, id, 0)[0].clone(); - let copies = frames(&column); - assert_eq!(copies.len(), 4); - assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}"); - let c = &spec(&s, id).series_columns[0]; - assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale))); - // A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line. - let bars = one_series(&mut s, "bar", &[-15.0]); - s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); - s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap(); - let bar = series_parts(&s, bars, 0)[0].clone(); - let copies = frames(&bar); - // The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250. - let (zero, unit) = (250.0, 3.0); - assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}"); - assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}"); - } - - #[test] - fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // 10 wide and 20 tall, the guide 5 points from the top. - save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true); - let id = one_series(&mut s, "column", &[35.0, 20.0]); - s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap(); - let parts = series_parts(&s, id, 0); - for (column, value_end) in parts.iter().zip([160.0, 220.0]) { - // The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands - // 12 points (2.4 times 5) below the value end. - let halves = column.children().unwrap(); - assert_eq!(halves.len(), 2, "{halves:?}"); - let half = |i: usize| { - let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) }; - (children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec()) - }; - let ((above, kept), (below, stretched)) = (half(0), half(1)); - let at = value_end + 12.0; - assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}"); - assert_eq!(kept.len(), 3, "the guide is not drawn"); - // Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end. - let top = kept[1].geometric_bounds().unwrap(); - assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}"); - // Below it the design is stretched down to the zero line. - let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap()); - assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}"); - assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}"); - } - // Vertically Scaled stretches the whole design, the top part included. - s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap(); - let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); - assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); - // Without a guide a sliding design is scaled vertically too. - save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false); - s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap(); - let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); - assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); - // Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before - // the line, are passed over. - let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); - let mut ids = vec![ - id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()), - id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()), - ]; - let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap()); - let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap()); - let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap()); - s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap(); - s.execute("view.guides.make", &json!({})).unwrap(); - ids.extend([square, tick, line]); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": "Boxed"})).unwrap(); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap(); - let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec(); - let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap(); - assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}"); - } - - #[test] - fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = one_series(&mut s, "column", &[35.0, 20.0]); - s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap(); - let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap(); - for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) { - let frame = frames(column)[0]; - assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}"); - assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6); - } - assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column"); - // A design twice as wide as it is tall reaches past the plot, and the legend starts right of it. - save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false); - let wide = one_series(&mut s, "column", &[20.0, 35.0]); - s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap(); - let parts = series_parts(&s, wide, 0); - let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap()); - assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns"); - // The legend swatch shows the design, its frame filling the 8-point square. - let swatch = series_parts(&s, id, 0).last().unwrap().clone(); - let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap(); - assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}"); - // Painting the series leaves the design's paint alone. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); - let mut red = false; - s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000")); - assert!(!red); - } - - #[test] - fn column_designs_survive_save_and_open_and_follow_their_design() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = graph(&mut s, "stackedColumn"); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - // Every series takes the design; only the stacked column series draws it. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}); - s.execute("graph.column", &p).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch"); - assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series"); - let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap(); - assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"})); - let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale }; - assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none()); - // Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again. - s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap(); - assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token")); - assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([])); - s.execute("graph.design", &json!({"delete": "Token"})).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null()); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. }))); - // Null goes back to plain columns. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap(); - assert!(spec(&s, id).series_columns.is_empty()); - } - - #[test] - fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = one_series(&mut s, "column", &[35.0]); - for p in [ - json!({"design": "Nope"}), - json!({"design": 3}), - json!({"design": "Coin", "columnType": "diagonal"}), - json!({"design": "Coin", "each": 0}), - json!({"design": "Coin", "each": -1}), - json!({"design": "Coin", "each": "10"}), - json!({"design": "Coin", "fractions": "round"}), - ] { - let mut p = p; - p["id"] = json!(id.0); - assert!(s.execute("graph.column", &p).is_err(), "{p}"); - } - assert!(spec(&s, id).series_columns.is_empty()); - // 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns. - let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001}); - assert!(s.execute("graph.column", &p).is_err()); - let mut g = spec(&s, id); - let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop }; - g.series_columns = vec![Some(options.clone())]; - let mut d = (*s.doc().unwrap().doc).clone(); - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - // Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each - // 0.1 is past the budget too. - let far = one_series(&mut s, "column", &[0.0, 0.0]); - s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap(); - assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err()); - assert!(spec(&s, far).series_columns.is_empty()); - // A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas: - // 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy. - for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] { - g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })]; - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - let column = s0.children().unwrap()[0].clone(); - assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}"); - assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}"); - assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped"); - } - } - - /// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a - /// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5 - /// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from. - fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] { - let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); - let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap()); - let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap()); - let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap()); - let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO, - k => panic!("type: {k:?}"), - }; - let points = [origin(s, above), origin(s, below)]; - s.execute("select.set", &json!({"ids": [votes, below]})).unwrap(); - let inner = id_of(s.execute("object.group", &json!({})).unwrap()); - let mut ids = vec![frame, above, inner]; - if guide { - let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap()); - s.execute("select.set", &json!({"ids": [line]})).unwrap(); - s.execute("view.guides.make", &json!({})).unwrap(); - ids.push(line); - } - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": name})).unwrap(); - points - } - - /// The text of each piece of type inside `n`, in paint order. - fn texts_in(n: &vectorcraft_doc::Node) -> Vec { - let mut out = vec![]; - n.walk(&mut |c| { - if let NodeKind::Text(t) = &c.kind { - out.push(t.plain_text()); - } - }); - out - } - - /// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size. - fn totals_of(nodes: &[std::sync::Arc]) -> Vec<(String, vectorcraft_geom::Point)> { - nodes - .iter() - .map(|n| match &n.kind { - NodeKind::Text(t) => { - let [a, b, c, d, x, y] = t.xf.as_coeffs(); - assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf); - (t.plain_text(), vectorcraft_geom::Point::new(x, y)) - } - k => panic!("a total: {k:?}"), - }) - .collect() - } - - fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool { - (a - b).hypot() < 1e-6 - } - - #[test] - fn column_design_totals_show_each_columns_value_at_their_own_size() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let [above, below] = save_totals_design(&mut s, "Ballot", false); - let id = one_series(&mut s, "column", &[35.0, 7.256]); - s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap(); - let check = |s: &Session, want: [[&str; 2]; 2]| { - let parts = series_parts(s, id, 0); - for (column, want) in parts.iter().zip(want) { - let children = column.children().unwrap(); - // The design's copy keeps the type without a total, stretched with the design, and leaves the totals out. - assert_eq!(texts_in(&children[0]), ["Votes"]); - let frame = frames(column)[0]; - let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); - let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); - let totals = totals_of(&children[1..]); - assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), want); - // Each set from where the design's copy puts the point it was set from. - assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); - } - // The legend swatch keeps the type without a total and leaves the totals out. - assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]); - }; - check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]); - // New data draws the new values. - s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap(); - check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]); - // The design in the Graph Design list is left as it was. - assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]); - // A bar below zero shows its minus sign, its totals moved with the design along the bar. - let bars = one_series(&mut s, "bar", &[-15.0]); - s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); - s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap(); - let bar = series_parts(&s, bars, 0)[0].clone(); - let frame = frames(&bar)[0]; - let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); - let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); - let totals = totals_of(&bar.children().unwrap()[1..]); - assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["-015.0", "-15.00 and -15"]); - assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); - // A design that is a single piece of type with a total shows the value alone, and its legend swatch is the - // default square. - let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [one]})).unwrap(); - s.execute("graph.design", &json!({"new": "Count"})).unwrap(); - let count = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap(); - let parts = series_parts(&s, count, 0); - assert_eq!(texts_in(&parts[0]), ["35"]); - assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1); - assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); - // Repeating, with a last copy to chop, it still shows the value. - s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap(); - let column = series_parts(&s, count, 0)[0].clone(); - assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind); - } - - #[test] - fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let [above, below] = save_totals_design(&mut s, "Ballot", true); - let id = one_series(&mut s, "column", &[35.0]); - let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap(); - let column = series_parts(&s, id, 0)[0].clone(); - let children = column.children().unwrap(); - // Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut. - assert_eq!(children.len(), 6, "{children:?}"); - assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped"); - assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"])); - // Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column. - let sx = plain.width() / 10.0; - let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0); - let totals = totals_of(&children[4..]); - assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["035.0", "35.00 and 35"]); - assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}"); - // Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the - // design with the part stretched down to the zero line. - s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap(); - let column = series_parts(&s, id, 0)[0].clone(); - let children = column.children().unwrap(); - assert_eq!(children.len(), 4, "two halves and two totals: {children:?}"); - let k = (140.0 - 5.0 * sx) / 5.0; - let totals = totals_of(&children[2..]); - let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx); - let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k); - assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}"); - // A total's pattern fill moves with it. - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); - let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); - let origin = vectorcraft_geom::Point::new(0.0, -2.0); - let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default()); - let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; - total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into(); - let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) }); - let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() }; - let g = GraphSpec { - rows: vec![vec![35.0]], - axis_min: Some(0.0), - axis_max: Some(50.0), - series_columns: vec![Some(options)], - ..GraphSpec::default() - }; - let out = super::generate(&mut d, &g); - let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); - let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0); - assert_eq!(text, "35"); - let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") }; - assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}"); - } - - #[test] - fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); - let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap()); - s.execute("select.set", &json!({"ids": [total]})).unwrap(); - let faded = id_of(s.execute("object.group", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [faded]})).unwrap(); - s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); - s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap(); - s.execute("graph.design", &json!({"new": "Faded"})).unwrap(); - let id = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap(); - // The total stays in its half-opaque group: each of the four copies shows the value there. - let parts = series_parts(&s, id, 0); - let copies = parts[0].children().unwrap(); - assert_eq!(copies.len(), 4, "{copies:?}"); - let mut faded_groups = 0; - for c in copies { - assert_eq!(texts_in(c), ["35"]); - c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6)); - } - assert_eq!(faded_groups, 4); - // The legend swatch shows the design without it. - let swatch = parts.last().unwrap(); - assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind); - // Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out - // what it clips. - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); - let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); - let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap()); - s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap(); - let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap(); - s.execute("graph.design", &json!({"new": "Masked"})).unwrap(); - let masked = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap(); - let parts = series_parts(&s, masked, 0); - let clip_groups = |n: &vectorcraft_doc::Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if let NodeKind::Group { clip: true, children } = &c.kind { - out.push(children.first().map(|m| texts_in(m)).unwrap_or_default()); - } - }); - out - }; - assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value"); - let swatch = parts.last().unwrap(); - let mut drawn = 0; - swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. }))); - assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}"); - // Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves - // them out. - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap()); - let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap()); - let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap()); - s.execute("select.set", &json!({"ids": [first, second]})).unwrap(); - let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap(); - s.execute("graph.design", &json!({"new": "Blended"})).unwrap(); - let blended = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap(); - let parts = series_parts(&s, blended, 0); - let mut blends = vec![]; - parts[0].walk(&mut |n| { - if matches!(n.kind, NodeKind::Blend { .. }) { - blends.push(texts_in(n)); - } - }); - assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]); - assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap()); - // A design that is that blend alone gets the default legend swatch. - s.execute("select.set", &json!({"ids": [blend]})).unwrap(); - s.execute("graph.design", &json!({"new": "Keys"})).unwrap(); - let keys = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap(); - let parts = series_parts(&s, keys, 0); - assert_eq!(texts_in(&parts[0]), ["35", "35"]); - assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); - // A design of a total inside two plain groups shows the value, and its legend swatch is the default square. - let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap()); - s.execute("select.set", &json!({"ids": [total]})).unwrap(); - let inner = id_of(s.execute("object.group", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [inner]})).unwrap(); - let outer = id_of(s.execute("object.group", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [outer]})).unwrap(); - s.execute("graph.design", &json!({"new": "Nested"})).unwrap(); - let nested = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap(); - let parts = series_parts(&s, nested, 0); - assert_eq!(texts_in(&parts[0]), ["35"]); - assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); - } - - #[test] - fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() { - for (v, before, after, want) in [ - (122.0, 3, 0, "122"), - (122.0, 0, 0, "122"), - (5.0, 3, 1, "005.0"), - (1234.0, 2, 0, "1234"), - (2.5, 0, 0, "3"), - (-2.5, 0, 0, "-3"), - (0.25, 0, 0, "0"), - (0.25, 0, 2, "0.25"), - (1.005, 0, 2, "1.01"), - (0.1 + 0.2, 0, 2, "0.30"), - (9.995, 0, 2, "10.00"), - (99.96, 0, 1, "100.0"), - (-0.004, 0, 2, "0.00"), - (-7.25, 2, 1, "-07.3"), - (7.0, 0, 9, "7.000000000"), - (1e21, 0, 0, "1000000000000000000000"), - ] { - assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}"); - } - // Each `%` and two digits, left to right; any other `%` stays. - let style = CharStyle::default; - let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style()); - assert!(super::fill_totals(&mut t, 7.25)); - assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%"); - // A total split between two runs takes the style of its `%`. - let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() }); - t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)]; - assert!(super::fill_totals(&mut t, 7.25)); - assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::>(), [("a 007.3", 10.0), (" b", 20.0)]); - // Type without a total is left as it is. - let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style()); - assert!(!super::fill_totals(&mut t, 7.25)); - assert_eq!(t.plain_text(), "Votes: 50%"); - } - - fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId { - NodeId( - s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ) - } - - /// Where each wedge of series `index` starts: its angle from 12 o'clock, clockwise, in degrees (the legend - /// swatch, last, left out). - fn wedge_starts(s: &Session, id: NodeId, index: u32) -> Vec { - let parts = series_parts(s, id, index); - parts[..parts.len() - 1] - .iter() - .filter_map(|n| match &n.kind { - NodeKind::Path { path, .. } => { - let bp = path.to_bezpath(); - let pts: Vec<_> = bp.elements().iter().filter_map(|e| e.end_point()).collect(); - let (c, p) = (pts.first()?, pts.get(1)?); - let deg = (p.x - c.x).atan2(-(p.y - c.y)).to_degrees(); - Some(if deg < -1e-6 { deg + 360.0 } else { deg }) - } - _ => None, - }) - .collect() - } - - #[test] - fn a_bar_in_a_label_breaks_the_line() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",Total|Sales|2023,b\nQ1|first,3,2\nQ2,5,4"); - let texts = axis_texts(&s, id); - assert!(texts.contains(&"Q1\nfirst".to_string()), "{texts:?}"); - // The legend: a label of three lines, and the next row below all three. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); - let NodeKind::Text(t) = &legend[0].kind else { panic!() }; - assert_eq!(t.plain_text(), "Total\nSales\n2023"); - let (a, b) = ( - series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), - series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(), - ); - assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}"); - } - - #[test] - fn transpose_swaps_rows_and_columns_and_switch_xy_swaps_scatter_pairs() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",a,b\nQ1,1,2\nQ2,3,"); - let before = spec(&s, id); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.series.clone(), g.categories.clone()), (vec!["Q1".to_string(), "Q2".into()], vec!["a".to_string(), "b".into()])); - assert_eq!(g.cells(), [[Some(1.0), Some(3.0)], [Some(2.0), None]]); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - assert_eq!(spec(&s, id).cells(), before.cells()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spec(&s, id).cells(), g.cells()); - assert!(s.execute("graph.setData", &json!({"switchXY": true})).is_err(), "scatter graphs only"); - let sc = make(&mut s, "scatter", ",y1,x1,y2,x2\nP,1,10,2,20"); - s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); - let g = spec(&s, sc); - assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); - assert_eq!(g.series, ["x1", "y1", "x2", "y2"]); - } - - #[test] - fn pie_wedges_sort_largest_first_in_each_pie_or_in_the_first_pies_order() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b,c\nP1,1,3,2\nP2,3,1,2"); - // None (data order): a starts at 12 o'clock in both pies. - assert_eq!(wedge_starts(&s, id, 0).iter().map(|d| d.round()).collect::>(), [0.0, 0.0]); - s.execute("graph.setType", &json!({"pieSort": "all"})).unwrap(); - // P1: b (3) first; P2: a (3) first. - let (a, b) = (wedge_starts(&s, id, 0), wedge_starts(&s, id, 1)); - assert!(b[0].abs() < 1e-6 && a[1].abs() < 1e-6, "{a:?} {b:?}"); - s.execute("graph.setType", &json!({"pieSort": "first"})).unwrap(); - // P1's order (b, c, a) in both: b first in P2 too. - let b = wedge_starts(&s, id, 1); - assert!(b.iter().all(|d| d.abs() < 1e-6), "{b:?}"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["pieSort"], "first"); - assert!(s.execute("graph.setType", &json!({"pieSort": "random"})).is_err()); - } - - #[test] - fn pies_sized_by_their_totals_side_by_side_or_stacked() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20"); - let pie = |s: &Session, c: usize| { - let wedges = |i: u32| { - let parts = series_parts(s, id, i); - parts[..parts.len() - 1].to_vec() - }; - let rects: Vec<_> = (0..2).flat_map(wedges).filter_map(|n| n.geometric_bounds()).collect(); - rects.into_iter().skip(c).step_by(2).reduce(|a, b| a.union(b)).unwrap() - }; - let even = (pie(&s, 0), pie(&s, 1)); - assert!((even.0.width() - even.1.width()).abs() < 1e-6); - s.execute("graph.setType", &json!({"piePosition": "ratio"})).unwrap(); - let (small, big) = (pie(&s, 0), pie(&s, 1)); - // Areas in proportion to the totals: 10 and 40, so radii 1 : 2. - assert!((big.width() / small.width() - 2.0).abs() < 1e-6, "{small:?} {big:?}"); - assert!((big.width() - even.1.width()).abs() < 1e-6, "the biggest keeps the full size"); - s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); - // Drawn largest first, so the big pie's wedges come first in each series. - let (big, small) = (pie(&s, 0), pie(&s, 1)); - assert!((small.center() - big.center()).hypot() < 1e-6 && (big.width() / small.width() - 2.0).abs() < 1e-6); - // The big pie is drawn first, under the small one. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let wedges: Vec = series(&n, 0).children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect(); - assert!(wedges[0] > wedges[1], "{wedges:?}"); - } - - #[test] - fn legends_in_wedges_put_the_series_labels_inside_the_pie() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",North,South|East\nP,1,1"); - s.execute("graph.setType", &json!({"pieLegend": "wedges"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let pie = - series(&n, 0).children().unwrap()[0].geometric_bounds().unwrap().union(series(&n, 1).children().unwrap()[0].geometric_bounds().unwrap()); - let labels: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); - assert_eq!(labels.len(), 2); - for l in &labels { - let b = l.geometric_bounds().unwrap(); - assert!(pie.contains(b.center()), "{b:?} inside {pie:?}"); - } - // No swatches beside the pie: each series group holds only its wedge. - assert_eq!((series(&n, 0).children().unwrap().len(), series(&n, 1).children().unwrap().len()), (1, 1)); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["pieLegend"].clone(), v["piePosition"].clone()), (json!("wedges"), json!("even"))); - assert!(v.get("legend").is_none(), "a pie's Legend stands for the checkbox"); - // The labels are drawn over the wedges, and on the black first wedge in white. - let order: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect(); - assert_eq!(order.last().map(String::as_str), Some("Legend"), "{order:?}"); - let colour = |l: &vectorcraft_doc::Node| match &l.kind { - NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), - _ => panic!(), - }; - assert_eq!((colour(&labels[0]), colour(&labels[1])), ("#ffffff".to_string(), "#000000".to_string())); - // No Legend wins over a legend checkbox sent along (as the dialog does), and Standard comes back without wedges. - s.execute("graph.setType", &json!({"pieLegend": "none", "legend": true})).unwrap(); - assert!(!spec(&s, id).legend); - s.execute("graph.setType", &json!({"legend": true})).unwrap(); - assert!(!spec(&s, id).pie_legend_in_wedges); - assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err()); - let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft"); - s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap(); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert_eq!( - (g.pie_legend_in_wedges, g.pie_position, g.pie_sort), - (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All) - ); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in ["pieLegendInWedges", "piePosition", "pieSort"] { - assert!(v.get(k).is_none(), "{k}"); - } - } - - #[test] - fn stacked_pies_are_rings_so_none_covers_another() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20\nSame,20,20"); - s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - // Series b's wedges: of the two equal big pies one is left out, the other a ring outside the small pie - // (clear of the centre), and the small pie's whole. - let b: Vec<_> = series(&n, 1).children().unwrap().iter().map(|c| (**c).clone()).collect(); - assert_eq!(b.len(), 3, "two wedges and the swatch"); - let small = b[1].geometric_bounds().unwrap(); - let NodeKind::Path { path, .. } = &b[0].kind else { panic!() }; - let c = small.center(); - let near: Vec = path.to_bezpath().elements().iter().filter_map(|e| e.end_point()).map(|p| (p - c).hypot()).collect(); - let min = near.iter().copied().fold(f64::MAX, f64::min); - assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); - // The names are drawn over the pies: after every series group. - let kids = n.children().unwrap(); - let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); - let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); - assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); - assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); - } - - #[test] - fn transpose_keeps_within_the_series_cap_and_drops_stale_axis_series() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let rows: Vec> = (0..300).map(|i| vec![i as f64]).collect(); - let id = NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - assert!(s.execute("graph.setData", &json!({"transpose": true})).is_err(), "300 categories can't become series"); - assert_eq!(spec(&s, id).cells().len(), 300); - let g = make(&mut s, "column", ",a,b,c\nQ1,1,2,3"); - s.execute("graph.setType", &json!({"seriesIndexes": [2], "valueAxis": "right"})).unwrap(); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - assert!(spec(&s, g).right_series.is_empty(), "one series left"); - // An all-blank series becomes no category. - let h = make(&mut s, "column", ",a,b\nQ1,1,\nQ2,2,"); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - assert_eq!(spec(&s, h).categories, ["a", "b"], "b keeps its label"); - assert!(s.execute("graph.setData", &json!({"id": h.0, "transpose": false})).unwrap().get("csv").is_some(), "no change: the query"); - } - - #[test] - fn switch_xy_moves_the_labels_with_their_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "scatter", ",y1,x1,y2\nP,1,10,2,20"); - s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); - let g = spec(&s, id); - assert_eq!(g.series, ["x1", "y1", "", "y2"]); - assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); - } - - #[test] - fn a_label_of_only_bars_is_blank_and_non_pie_graphs_list_no_pie_options() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",||,b\nQ1,1,2"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert_eq!(group(&n, "Legend").children().unwrap().len(), 1, "only b"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert!(v.get("pieLegend").is_none() && v.get("legend").is_some()); - // Bar and radar labels with a `|` centre their lines on the category. - for ty in ["bar", "radar"] { - let g = make(&mut s, ty, ",a\nOne|Two|Three,1\nQ2,2\nQ3,3"); - assert!(axis_texts(&s, g).contains(&"One\nTwo\nThree".to_string()), "{ty}"); - } - } - - #[test] - fn drop_shadows_sit_behind_the_columns_lines_and_wedges() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let kids = n.children().unwrap(); - let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); - assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); - let shadow = &kids[at]; - assert!((shadow.opacity - 0.35).abs() < 1e-6); - let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); - let cols: Vec<_> = (0..2).flat_map(|i| marks(&s, id, i)).collect(); - assert_eq!(copies.len(), cols.len(), "one per column, none for the swatches"); - for c in &cols { - assert!(copies.iter().any(|b| (b.x0 - c.x0 - 2.0).abs() < 1e-6 && (b.y0 - c.y0 - 2.0).abs() < 1e-6), "{c:?}"); - } - let black = shadow.children().unwrap().iter().all(|c| c.appearance.fill_paint().color().map(|c| c.to_hex()) == Some("#000000".into())); - assert!(black); - // Lines cast one too (not their markers); pies one per wedge. - let line = graph(&mut s, "line"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(line).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 2, "one per series line"); - let pie = graph(&mut s, "pie"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 5, "the wedges with a value above 0"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["dropShadow"], true); - s.execute("graph.setType", &json!({"dropShadow": false})).unwrap(); - let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow"))); - } - - #[test] - fn the_legend_across_top_runs_in_rows_above_the_plot() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); - let swatch = |s: &Session, i: u32| series_parts(s, id, i).last().unwrap().geometric_bounds().unwrap(); - let (a, b) = (swatch(&s, 0), swatch(&s, 1)); - assert!(a.y1 <= 100.0 - 8.0 + 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && b.x0 > a.x1 && (a.x0 - 100.0).abs() < 1e-6, "{a:?} {b:?}"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["legendAcrossTop"], true); - // Long labels wrap into rows within the plot's width, the last row just above it. - let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); - let csv = format!(",{}\nQ1,1,2,3,4,5,6", labels.join(",")); - s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); - let ys: Vec = (0..6).map(|i| swatch(&s, i).y0).collect(); - assert!(ys.windows(2).any(|w| w[1] > w[0]), "a second row: {ys:?}"); - assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}"); - let xs: Vec = (0..6).map(|i| swatch(&s, i).x1).collect(); - assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}"); - let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert!(g.legend_across_top && g.drop_shadow); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - assert!(v.get("dropShadow").is_none() && v.get("legendAcrossTop").is_none()); - } - - #[test] - fn each_layer_keeps_its_shadows_just_behind_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",a,b,c\nQ1,3,2,1\nQ2,5,,2\nQ3,4,6,3"); - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n - .children() - .unwrap() - .iter() - .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) - .collect(); - // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns - // and the line. - let at = |k: &str| order.iter().position(|o| o == k).unwrap(); - let shadows: Vec = order.iter().enumerate().filter(|(_, o)| *o == "Drop Shadow").map(|(i, _)| i).collect(); - assert_eq!(shadows.len(), 2, "{order:?}"); - assert!(at("s0") < shadows[0] && shadows[0] < at("s1") && at("s1") < shadows[1] && shadows[1] < at("s2"), "{order:?}"); - // b's blank leaves two columns; a line with a blank casts a shadow per run. - let line = make(&mut s, "line", ",a\nQ1,1\nQ2,\nQ3,3\nQ4,4"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(line).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1, "Q1 alone draws no line; Q3–Q4 one"); - // Scatter and radar lines cast one too; a stacked segment of 0 doesn't. - for (ty, want) in [("scatter", 1), ("radar", 2)] { - let g = graph(&mut s, ty); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(g).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), want, "{ty}"); - } - let st = make(&mut s, "stackedColumn", ",a,b\nQ1,0,2"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(st).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1); - } - - #[test] - fn the_legend_across_top_keeps_above_labels_over_the_plot() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "radar", ",a,b\nTop|of|chart,1,2\nQ2,2,3\nQ3,3,1"); - s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min); - let legend_bottom = - group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max); - assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}"); - // No legend: nothing across the top either. - s.execute("graph.setType", &json!({"legend": false})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Legend"))); - // Labels of a row other than the last stay within the plot's width. - let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); - let col = make(&mut s, "column", &format!(",{}\nQ1,1,2,3,4,5,6", labels.join(","))); - s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); - let n = s.doc().unwrap().doc.node(col).unwrap().clone(); - let texts: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); - let last_row = texts.iter().map(|b| b.y0).fold(f64::MIN, f64::max); - assert!(texts.iter().filter(|b| b.y0 < last_row - 1e-6).all(|b| b.x1 <= 400.0 + 1e-6), "{texts:?}"); - } - - #[test] - fn widths_past_100_overlap_and_the_first_row_or_column_goes_in_front() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"columnWidth": 250, "clusterWidth": 300})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.column_width, g.cluster_width), (250.0, 300.0)); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); - assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); - assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); - let order = |s: &Session| -> Vec { - s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() - }; - assert_eq!(order(&s), [0, 1]); - s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); - assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); - // First Row in Front: each series' first category drawn last. - let xs = |s: &Session| marks(s, id, 0).iter().map(|b| b.x0).collect::>(); - let before = xs(&s); - assert!(before.windows(2).all(|w| w[0] < w[1])); - s.execute("graph.setType", &json!({"firstRowInFront": true})).unwrap(); - let after = xs(&s); - assert!(after.windows(2).all(|w| w[0] > w[1]), "{after:?}"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["firstRowInFront"].clone(), v["firstColumnInFront"].clone()), (json!(true), json!(true))); - } - - #[test] - fn filled_lines_are_bands_in_the_series_colour() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "line", ",a\nQ1,2\nQ2,2\nQ3,2"); - let thin = marks(&s, id, 0)[0]; - assert!(thin.height() < 1e-6, "a flat 1 pt line"); - s.execute("graph.setType", &json!({"filledLines": true})).unwrap(); - let parts = series_parts(&s, id, 0); - let band = parts[0].geometric_bounds().unwrap(); - assert!((band.height() - 3.0).abs() < 1e-6 && (band.width() - thin.width()).abs() < 1e-6, "{band:?}"); - assert_eq!(parts[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000", "the first series' colour"); - assert_eq!(parts[0].appearance.stroke_width(), 0.25); - // A series colour set with Group Selection fills the band. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap(); - assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft"); - s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap(); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert!(g.filled_lines && g.first_row_in_front && g.first_column_in_front); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in ["filledLines", "firstRowInFront", "firstColumnInFront"] { - assert!(v.get(k).is_none(), "{k}"); - } - } - - #[test] - fn filled_lines_keep_the_line_colour_and_fall_back_to_thin_lines() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); - // A colour given to the thin line (its stroke) fills the band. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setStroke", &json!({"ids": [grp.0], "color": "#00ff00"})).unwrap(); - s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); - let band = &series_parts(&s, id, 0)[0]; - assert_eq!(band.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); - // A radar series of zeros has every point at the centre: no band to make, so a thin line. - let radar = make(&mut s, "radar", ",a\nQ1,0\nQ2,0\nQ3,0"); - s.execute("graph.setType", &json!({"id": radar.0, "filledLines": true})).unwrap(); - let line = &series_parts(&s, radar, 0)[0]; - assert!(line.appearance.fill_paint().is_none(), "a stroked line"); - // Past the budget, thin lines. - let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); - let big = NodeId( - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"id": big.0, "filledLines": true, "markPoints": false})).unwrap(); - assert!(series_parts(&s, big, 0)[0].appearance.fill_paint().is_none()); - } - - #[test] - fn overlapping_columns_keep_clear_of_the_legend_and_options_follow_the_graph_type() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"columnWidth": 1000, "clusterWidth": 1000})).unwrap(); - let reach = (0..2).flat_map(|i| marks(&s, id, i)).map(|b| b.x1).fold(f64::MIN, f64::max); - assert!(reach > 400.0, "past the plot: {reach}"); - let swatch = series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(); - assert!(swatch.x0 >= reach + 14.0 - 1e-6, "{swatch:?} after {reach}"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert!(v.get("firstRowInFront").is_some() && v.get("filledLines").is_none()); - let line = graph(&mut s, "line"); - let v = s.execute("graph.setType", &json!({"id": line.0})).unwrap(); - assert!(v.get("firstRowInFront").is_none() && v.get("filledLines").is_some()); - } - - /// The Axes and Legend texts with their size and fill, after the graph is drawn again. - fn label_looks(s: &mut Session, id: NodeId) -> Vec<(String, f64, String)> { - s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let mut out = vec![]; - for g in ["Axes", "Legend"] { - for c in group(&n, g).children().unwrap() { - if let NodeKind::Text(t) = &c.kind { - let st = &t.runs[0].style; - out.push((t.plain_text(), st.size, st.fill.color().map(|c| c.to_hex()).unwrap_or_default())); - } - } - } - out - } - - fn text_id(s: &Session, id: NodeId, grp: &str, text: &str) -> NodeId { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, grp).children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap().id - } - - #[test] - fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 12})).unwrap(); - let looks = label_looks(&mut s, id); - assert!(looks.iter().all(|(_, size, _)| (*size - 12.0).abs() < 1e-6), "{looks:?}"); - let g = spec(&s, id); - assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_some() && g.label_styles.legend.is_some()); - // One undo takes the data change back, the next the formatting and the kept styles together. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(spec(&s, id).label_styles.values.is_some()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); - } - - #[test] - fn formatting_one_label_keeps_that_kind_of_label() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - // A legend label (Group Selection), recoloured with the Character panel's fill. - let l = text_id(&s, id, "Legend", "2025"); - s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); - s.execute("text.setStyle", &json!({"fill": "#ff0000"})).unwrap(); - // A category label, resized. - let c = text_id(&s, id, "Axes", "Q2"); - s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 14})).unwrap(); - // A value axis number, painted with the Appearance fill. - let v = text_id(&s, id, "Axes", "4"); - s.execute("paint.setFill", &json!({"ids": [v.0], "color": "#0000ff"})).unwrap(); - let looks = label_looks(&mut s, id); - let look = |t: &str| looks.iter().find(|(x, _, _)| x == t).cloned().unwrap(); - assert_eq!(look("2024"), ("2024".into(), 9.0, "#ff0000".into()), "every legend label"); - assert_eq!(look("Q1").1, 14.0, "every category label"); - assert_eq!(look("Q1").2, "#000000"); - assert_eq!(look("0").2, "#0000ff", "every value axis number"); - assert_eq!(look("0").1, 9.0); - // Kept through save and open; nothing written when unset. - let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); - } - - #[test] - fn pie_names_and_wedge_labels_keep_their_style() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b\nP1,1,2"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let name = n.children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "P1")).unwrap().id; - s.execute("select.set", &json!({"ids": [name.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 16})).unwrap(); - assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 16.0); - s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); - let w = text_id(&s, id, "Legend", "a"); - s.execute("select.set", &json!({"ids": [w.0]})).unwrap(); - s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1]]})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let fills: Vec = group(&n, "Legend") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => t.runs[0].style.fill.color().map(|c| c.to_hex()), - _ => None, - }) - .collect(); - assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge"); - } - - #[test] - fn labels_are_told_apart_by_where_they_come_not_by_their_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // Categories named like the value axis numbers. - let id = make(&mut s, "column", ",a\n2,1\n4,3\n6,5"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let texts: Vec<(NodeId, String)> = group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => Some((c.id, t.plain_text())), - _ => None, - }) - .collect(); - // The value axis "2" comes before the category "2". - let value_two = texts.iter().find(|(_, t)| t == "2").unwrap().0; - s.execute("select.set", &json!({"ids": [value_two.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 7})).unwrap(); - let g = spec(&s, id); - assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none(), "{:?}", g.label_styles); - // A scatter graph's numbers are all values. - let sc = graph(&mut s, "scatter"); - s.execute("select.set", &json!({"ids": [sc.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 8})).unwrap(); - let g = spec(&s, sc); - assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none()); - // Part of a label's text: nothing kept. - let col = graph(&mut s, "column"); - let q = text_id(&s, col, "Axes", "Q2"); - s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 20, "start": 0, "end": 1})).unwrap(); - assert!(spec(&s, col).label_styles.categories.is_none()); - // Only the change is kept: a size given to the whole graph leaves the label colours as they were. - let pie = make(&mut s, "pie", ",a,b\nP,1,1"); - s.execute("graph.setType", &json!({"id": pie.0, "pieLegend": "wedges"})).unwrap(); - s.execute("select.set", &json!({"ids": [pie.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 11})).unwrap(); - let legend = spec(&s, pie).label_styles.legend.unwrap(); - assert_eq!((legend.size, legend.fill.color().unwrap().to_hex()), (11.0, "#000000".into())); - s.execute("graph.setData", &json!({"id": pie.0, "rows": [[2, 1]]})).unwrap(); - let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); - let fill = match &group(&n, "Legend").children().unwrap()[0].kind { - NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), - _ => panic!(), - }; - assert_eq!(fill, "#ffffff", "still white on the black wedge"); - } - - #[test] - fn character_formats_are_kept_and_bigger_labels_space_out() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("text.setFormat", &json!({"underline": true, "baselineShift": 1})).unwrap(); - let g = spec(&s, id); - let st = g.label_styles.legend.unwrap(); - assert!(st.underline && (st.baseline_shift - 1.0).abs() < 1e-9); - // 20 pt legend labels: the rows move apart so they don't overlap. - s.execute("text.setStyle", &json!({"size": 20})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "csv": ",a,b,c\nQ1,1,2,3"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let mut rows: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); - rows.sort_by(|a, b| a.y0.total_cmp(&b.y0)); - assert!(rows.windows(2).all(|w| w[1].y0 >= w[0].y1 - 1e-6), "{rows:?}"); - // A file's absurd style is kept within the Character limits. - let mut g = spec(&s, id); - g.label_styles.values = Some(CharStyle { size: 1e300, tracking: f64::INFINITY, ..CharStyle::default() }); - let mut d = (*s.doc().unwrap().doc).clone(); - let art = super::generate(&mut d, &g); - let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); - for t in axes.children().unwrap() { - if let NodeKind::Text(t) = &t.kind { - assert!(t.runs[0].style.size <= 1296.0 && t.runs[0].style.tracking.is_finite()); - } - } - } -} +use axis::{nice_axis, tick_values}; +#[cfg(test)] +use columns::{fill_totals, total_text}; +pub use commands::specs; +pub(crate) use data::apply_csv; +#[cfg(test)] +use data::{fmt_value, parse_csv, to_csv}; +#[cfg(test)] +use generate::generate; +#[cfg(test)] +use series::default_series_color; +pub(crate) use series::{capture_label_styles, capture_series_paints, paint_targets}; + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs new file mode 100644 index 000000000..aa9198163 --- /dev/null +++ b/crates/engine/src/cmd/graph/axis.rs @@ -0,0 +1,64 @@ +use super::*; + +pub(super) const LABEL_SIZE: f64 = 9.0; + +/// Most tick marks per division (Graph Type › Tick Marks), and most category tick marks a graph draws. +pub(super) const MAX_TICK_MARKS: usize = 20; + +pub(super) const MAX_CATEGORY_TICKS: usize = 10_000; + +/// Longest axis label prefix or suffix (Graph Type › Add Labels). +const MAX_AFFIX: usize = 64; + +/// An axis label prefix or suffix: one line (no control characters or line separators), at most [`MAX_AFFIX`] long. +pub(super) fn one_line(s: &str) -> String { + s.chars().filter(|c| !c.is_control() && !matches!(c, '\u{2028}' | '\u{2029}')).take(MAX_AFFIX).collect() +} + +/// A count parameter: a whole number, also as the float a dialog sends back (3.0), at most `max`. +pub(super) fn count_param(p: &Value, key: &str, max: u64) -> Option { + let v = p.get(key)?; + let n = v.as_u64().or_else(|| v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).map(|x| x.round().min(max as f64) as u64))?; + usize::try_from(n.min(max)).ok() +} + +/// A "nice" axis: (min, max, step). +pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) { + let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) }; + let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64; + let raw = (hi - lo) / n; + let mag = 10f64.powf(raw.log10().floor()); + let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw - 1e-12).unwrap_or(10.0 * mag); + ((lo / step).floor() * step, (hi / step).ceil() * step, step) +} + +/// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that +/// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound +/// was given. +pub(super) fn value_axis(ticks: usize, axis_min: Option, axis_max: Option, lo: f64, hi: f64) -> (f64, f64, f64) { + match (axis_min, axis_max) { + (Some(min), Some(max)) if max > min && (max - min).is_finite() => { + let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64; + (min, max, (max - min) / n) + } + _ => { + // An override too wide for f64 (or NaN) gives a non-finite axis, which would reach `clamp` with NaN bounds + // and panic: fall back to the data, then to 0..1. + let finite = |(a, b, s): (f64, f64, f64)| a.is_finite() && b.is_finite() && s.is_finite() && b > a && s > 0.0; + [nice_axis(axis_min.unwrap_or(lo), axis_max.unwrap_or(hi), ticks), nice_axis(lo, hi, ticks)] + .into_iter() + .find(|&a| finite(a)) + .unwrap_or((0.0, 1.0, 0.2)) + } + } +} + +/// The tick values from `lo` to `hi` every `step`, both ends included: counted, not accumulated, so the last tick +/// lands on `hi` and a step too small for the range (or not finite) can't loop forever. At most 1000 ticks. +pub(super) fn tick_values(lo: f64, hi: f64, step: f64) -> Vec { + let n = ((hi - lo) / step).round(); + if !(lo.is_finite() && step.is_finite() && step > 0.0 && n.is_finite() && n >= 0.0) { + return vec![]; + } + (0..=(n as usize).min(1000)).map(|i| lo + i as f64 * step).collect() +} diff --git a/crates/engine/src/cmd/graph/columns.rs b/crates/engine/src/cmd/graph/columns.rs new file mode 100644 index 000000000..19a0e71e7 --- /dev/null +++ b/crates/engine/src/cmd/graph/columns.rs @@ -0,0 +1,497 @@ +use super::data::fmt_value; +use super::designs::design_names; +use super::edit::{regenerate, spec_of, target}; +use super::generate::generate_with; +use super::markers::{design_frame, fit_affine}; +use super::series::{picked_series, reselect_series}; +use super::*; + +/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy). +const COLUMN_BUDGET: usize = 200_000; + +/// A series' column design (Object › Graph › Column): its options; its art without guide lines and without the totals +/// taken out of it ([`without_totals`]); the bounds of those guides; those totals; its frame (the art's without guides, +/// totals included, [`design_frame`]); whether its art still holds totals (inside groups that do more than group, or +/// inside other objects that hold objects), which are filled where they are; and its art without any total for the +/// legend swatch, `None` when that draws nothing. +pub(super) struct ColumnArt { + options: ColumnDesign, + art: Node, + guides: Vec, + totals: Vec, + frame: Option, + in_place: bool, + pub(super) legend: Option, +} + +impl ColumnArt { + /// A copy of `art` with the design's frame fitted to `cell`, as [`fit_design`] fits it. Fresh ids. + pub(super) fn fit(&self, d: &mut Document, art: &Node, cell: Rect) -> Option> { + let art = crate::cmd::place::transformed(art.clone(), fit_affine(self.frame?, cell)); + Some(Arc::new(d.reid(&art))) + } +} + +/// The column design totals in `s` (Add totals to a column design): each `%` followed by two digits, as its byte offset, +/// the places before the decimal point (the first digit) and the places after it (the second), left to right. A `%` not +/// followed by two digits is plain text. +fn total_spans(s: &str) -> Vec<(usize, usize, usize)> { + let b = s.as_bytes(); + let mut out = vec![]; + let mut i = 0; + while let Some(w) = b.get(i..i + 3) { + match *w { + [b'%', x, y] if x.is_ascii_digit() && y.is_ascii_digit() => { + out.push((i, usize::from(x - b'0'), usize::from(y - b'0'))); + i += 3; + } + _ => i += 1, + } + } + out +} + +/// Value `v` as a column design total shows it: rounded half away from zero, from its shortest decimal form, to `after` +/// places after the decimal point, zeros added as needed; and with at least `before` places before the point, zeros +/// added in front (0: as many as the value needs). The places before the point are never cut, and a value that rounds +/// to 0 has no minus sign. +pub(super) fn total_text(v: f64, before: usize, after: usize) -> String { + if !v.is_finite() { + return fmt_value(v); + } + let s = format!("{}", v.abs()); + let (int, frac) = s.split_once('.').unwrap_or((s.as_str(), "")); + // The digits kept, the point `after` places from the end. + let mut digits: Vec = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(after)).collect(); + if frac.as_bytes().get(after).is_some_and(|d| *d >= b'5') { + let mut carry = true; + for d in digits.iter_mut().rev() { + if *d == b'9' { + *d = b'0'; + } else { + *d += 1; + carry = false; + break; + } + } + if carry { + digits.insert(0, b'1'); + } + } + let (whole, places) = digits.split_at(digits.len().saturating_sub(after)); + let mut out = String::new(); + if v < 0.0 && digits.iter().any(|d| *d != b'0') { + out.push('-'); + } + out.extend(std::iter::repeat_n('0', before.saturating_sub(whole.len()))); + out.extend(whole.iter().map(|d| char::from(*d))); + if after > 0 { + out.push('.'); + out.extend(places.iter().map(|d| char::from(*d))); + } + out +} + +/// Type `t` with each total in it ([`total_spans`]) replaced by value `v` ([`total_text`]), styled as its `%` was; true +/// when it held one. +pub(super) fn fill_totals(t: &mut TextObject, v: f64) -> bool { + let spans = total_spans(&t.plain_text()); + // Last first, so the offsets of earlier ones stay valid. + for &(at, before, after) in spans.iter().rev() { + let s = total_text(v, before, after); + t.splice_paras(at, at + 3, &s); + vectorcraft_text::edit::replace_range(&mut t.runs, at, at + 3, &s); + } + if !spans.is_empty() { + refresh_bounds(t); + } + !spans.is_empty() +} + +/// Is `n` type that holds a total ([`total_spans`])? +fn holds_total(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Text(t) if !total_spans(&t.plain_text()).is_empty()) +} + +/// Does group `n` only group its children: no clipping, hiding, opacity, blend mode, isolation, knockout, appearance of +/// its own or opacity mask, so that type taken out of it draws as it did inside it? +fn only_groups(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.visible + && n.opacity == 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.knockout == vectorcraft_doc::Knockout::default() + && n.appearance.items.is_empty() + && n.appearance.effects.is_empty() + && n.extras().mask.is_none() +} + +/// Is `n` a clipping group whose clipping path is type that holds a total? +fn clipped_by_total(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Group { clip: true, children } if children.first().is_some_and(|c| holds_total(c))) +} + +/// `art` without the type in it that holds a total, inside groups at any depth that only group ([`only_groups`]), and +/// that type, pushed to `totals` in paint order (Add totals to a column design); with `all`, without every total inside +/// any group or other object that holds objects (a blend, compound shape, envelope, repeat or intertwine), and with +/// each group that such type clips left empty. A design that is such type alone leaves an empty group. +fn without_totals(art: &Node, all: bool, totals: &mut Vec) -> Node { + if holds_total(art) || (all && clipped_by_total(art)) { + totals.push(art.clone()); + return Node::group(art.id, vec![]); + } + let mut n = art.clone(); + let take = all || only_groups(&n); + if take && let Some(children) = n.children_mut() { + for c in std::mem::take(children) { + if holds_total(&c) { + totals.push((*c).clone()); + } else if c.children().is_some() { + children.push(Arc::new(without_totals(&c, all, totals))); + } else { + children.push(c); + } + } + } + n +} + +/// `n` with the totals in it at any depth, in groups and the other objects that hold objects, showing value `v` where +/// they are ([`fill_totals`]). +fn fill_in_place(n: &mut Node, v: f64) { + if let NodeKind::Text(t) = &mut n.kind { + fill_totals(t, v); + } else if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + fill_in_place(Arc::make_mut(c), v); + } + } +} + +/// `art` without the paths made into guides, at any depth. +fn without_guides(art: &Node) -> Node { + let mut n = art.clone(); + if let Some(ch) = n.children_mut() { + let old = std::mem::take(ch); + *ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect(); + } + n +} + +/// Each series' column design, when the document has the design. +pub(super) fn column_designs(d: &Document, g: &GraphSpec) -> Vec> { + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + (0..nser) + .map(|s| { + let options = g.series_columns.get(s)?.as_ref()?; + let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art; + let mut guides = vec![]; + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) + && let Some(b) = n.geometric_bounds() + { + guides.push(b); + } + }); + let art = without_guides(art); + let frame = design_frame(&art); + let legend = without_totals(&art, true, &mut vec![]); + let mut draws = false; + legend.walk(&mut |n| draws |= n.children().is_none()); + let mut totals = vec![]; + let art = without_totals(&art, false, &mut totals); + let mut in_place = false; + art.walk(&mut |n| in_place |= holds_total(n)); + Some(ColumnArt { options: options.clone(), art, guides, totals, frame, in_place, legend: draws.then_some(legend) }) + }) + .collect() +} + +/// The value one copy of a repeating design stands for, when the design repeats and the value is usable. +fn repeat_each(c: &ColumnDesign) -> Option { + (c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each) +} + +/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than +/// was left, and the graph is then drawn with plain columns. +pub(super) struct Allowance { + left: usize, + pub(super) short: bool, +} + +impl Allowance { + pub(super) fn new() -> Self { + Self { left: COLUMN_BUDGET, short: false } + } + /// Spend `n` objects, when that many are left. + pub(super) fn take(&mut self, n: usize) -> bool { + match self.left.checked_sub(n) { + Some(rest) => { + self.left = rest; + true + } + None => { + self.short = true; + false + } + } + } +} + +/// Column or bar `rect` of value `value` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, +/// its copies spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a +/// column above zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot +/// points per unit of value. The design's totals show `value` and come last: each keeps its size and is moved to where +/// the design's copy puts the point it is set from; in a repeating design they are drawn once, where a whole copy ending +/// at the value end puts them, and a sliding design moves each with the part of the design on its side of the guide. +/// Totals left in the art ([`ColumnArt`]) show `value` in each copy, drawn with it. `None` for art with no area, for a +/// column of no length and when `left` runs out, which are drawn plain. +pub(super) fn column_art( + d: &mut Document, + c: &ColumnArt, + (rect, value): (Rect, f64), + (horizontal, far_low): (bool, bool), + per_unit: f64, + left: &mut Allowance, +) -> Option> { + let frame = c.frame?; + let length = |r: Rect| if horizontal { r.width() } else { r.height() }; + let thickness = |r: Rect| if horizontal { r.height() } else { r.width() }; + if length(rect) <= 1e-9 || !length(rect).is_finite() { + return None; + } + let cost = c.art.count_with_masks(); + // A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved + // by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when + // `keep_low` (two objects more). + let copy = |d: &mut Document, a: Affine| { + let mut art = d.reid(&crate::cmd::place::transformed(c.art.clone(), a)); + if c.in_place { + fill_in_place(&mut art, value); + } + art + }; + let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| left.take(cost).then(|| Arc::new(copy(d, fit_affine(frame, cell)))); + let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| { + let art = left.take(cost + 2).then(|| copy(d, a))?; + // The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed + // stroke counts only in its own object's box. + let mut b = vectorcraft_render::painted_bounds(d, &art); + art.walk(&mut |n| { + if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) { + b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n)); + } + }); + // A copy that draws nothing (a design of totals alone) needs no clip. + let Some(b) = b.or_else(|| art.geometric_bounds()) else { return Some(Arc::new(art)) }; + let keep = match (along_x, keep_low) { + (true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1), + (true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1), + (false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)), + (false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1), + }; + Some(Arc::new(crate::cmd::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art))) + }; + // The part of the column from `a` to `b` points out from its zero end. + let piece = |a: f64, b: f64| match (horizontal, far_low) { + (true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1), + (true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1), + (false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a), + (false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b), + }; + // The column's group: `parts`, then the totals, each showing `value` at its own size, moved as `a` moves the point it + // is set from; with `sliding` (the kept part's map, the stretched part's map and the guide, as [`slide`] gives them), + // as the map of the part on its side of the guide moves it. One object each. + let group = |d: &mut Document, mut parts: Vec>, a: Affine, sliding: Option<(Affine, Affine, f64)>, left: &mut Allowance| { + for t in &c.totals { + if !left.take(1) { + return None; + } + let mut n = t.clone(); + let mut by = Affine::IDENTITY; + if let NodeKind::Text(text) = &mut n.kind { + // A move only, so the type keeps its size; pattern paints move with it. + let p = text.xf * Point::ZERO; + let a = match sliding { + Some((kept, stretched, at)) => { + let q = kept * p; + if (horizontal && q.x >= at) || (!horizontal && q.y <= at) { kept } else { stretched } + } + None => a, + }; + fill_totals(text, value); + by = Affine::translate(a * p - p); + } + parts.push(Arc::new(d.reid(&crate::cmd::place::transformed(n, by)))); + } + Some(Arc::new(Node::group(d.alloc_id(), parts))) + }; + let mut parts = vec![]; + let (mut label, mut sliding) = (fit_affine(frame, rect), None); + match c.options.column_type { + ColumnType::Uniform => { + // Scaled both ways to the column's length, centered across it. + let t = thickness(frame) * length(rect) / length(frame) / 2.0; + let cell = if horizontal { + Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t) + } else { + Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1) + }; + parts.push(fit(d, cell, left)?); + label = fit_affine(frame, cell); + } + ColumnType::Repeating => { + // When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design + // is drawn vertically scaled. + let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else { + parts.push(fit(d, rect, left)?); + return group(d, parts, label, None, left); + }; + // The totals go where a whole copy ending at the value end puts them. + label = fit_affine(frame, piece(length(rect) - unit, length(rect))); + // The whole copies, and the points of the column left for the last one (none under a millionth of a point). + let whole = ((length(rect) + 1e-6) / unit).floor(); + let rest = length(rect) - whole * unit; + // Each copy is spent from `left`, which stops the loop. + for i in 0..whole as usize { + parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?); + } + if rest > 1e-6 { + let start = whole * unit; + match c.options.fractions { + Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?), + Fractions::Chop => { + // The last copy cut off where the column ends, and nowhere else. + let cut = match (horizontal, far_low) { + (true, true) => (true, false, rect.x0), + (true, false) => (true, true, rect.x1), + (false, true) => (false, false, rect.y0), + (false, false) => (false, true, rect.y1), + }; + parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?); + } + } + } + } + ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) { + Some((kept, stretched, at)) => { + // Each copy cut at the guide, on its own side. + parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?); + parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?); + sliding = Some((kept, stretched, at)); + } + None => parts.push(fit(d, rect, left)?), + }, + ColumnType::Vertical => parts.push(fit(d, rect, left)?), + } + group(d, parts, label, sliding, left) +} + +/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first +/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and +/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left +/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too +/// short for the part kept in proportion. +fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> { + // A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across. + let inside = |g: &&Rect| { + if horizontal { + g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1 + } else { + g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1 + } + }; + let g = guides.iter().find(inside)?.center(); + if horizontal { + let s = rect.height() / frame.height(); + let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0); + let rest = rect.width() - kept * s; + if !(rest.is_finite() && rest > 1e-9) { + return None; + } + let k = rest / stretched; + let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]); + let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]); + Some((kept_map, stretched_map, rect.x1 - kept * s)) + } else { + let s = rect.width() / frame.width(); + let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y); + let rest = rect.height() - kept * s; + if !(rest.is_finite() && rest > 1e-9) { + return None; + } + let k = rest / stretched; + let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]); + let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]); + Some((kept_map, stretched_map, rect.y0 + kept * s)) + } +} + +pub(super) fn set_column(s: &mut Session, p: &Value) -> Result { + const C: &str = "graph.column"; + let id = target(s, p, C)?; + let mut spec = spec_of(s, id)?; + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + let picked = picked_series(s, p, id, count, C)?; + let names = design_names(&s.doc()?.doc); + let all: Vec = if picked.is_empty() { (0..count).collect() } else { picked.clone() }; + // The column design the picked series (all of them with none picked) share; a design deleted since doesn't count, + // as those series draw plain columns. + let used: Vec> = + all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect(); + let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned(); + let Some(v) = p.get("design") else { + let options = shared.clone().unwrap_or_default(); + return Ok(json!({ + "designs": names, + "design": shared.map(|c| c.design), + "columnType": options.column_type.id(), + "each": options.each, + "fractions": options.fractions.id(), + })); + }; + let design = match v { + Value::Null => None, + Value::String(n) if names.contains(n) => Some(n.clone()), + Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))), + _ => return Err(bad(C, "`design` is a design name, or null for plain columns")), + }; + let mut options = shared.unwrap_or_default(); + if let Some(t) = p.get("columnType") { + options.column_type = + t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?; + } + if let Some(e) = p.get("each") { + options.each = + e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?; + } + if let Some(f) = p.get("fractions") { + options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?; + } + let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; + spec.series_columns.truncate(count); + spec.series_columns.resize(count, None); + for i in all { + if let Some(c) = spec.series_columns.get_mut(i) { + *c = design.clone().map(|design| ColumnDesign { design, ..options.clone() }); + } + } + while spec.series_columns.last().is_some_and(Option::is_none) { + spec.series_columns.pop(); + } + // Drawn once on a copy of the document, to refuse designs past the budget before the edit. + let mut probe = (*s.doc()?.doc).clone(); + let looks = column_designs(&probe, &spec); + if generate_with(&mut probe, &spec, &looks).is_none() { + return Err(bad(C, "the design is too big to draw in every column of this graph")); + } + s.edit("Graph Column", |d, sel| { + regenerate(d, id, spec)?; + if !reselect.is_empty() { + reselect_series(d, sel, id, &reselect); + } + Ok(()) + })?; + Ok(json!({ "id": id.0 })) +} diff --git a/crates/engine/src/cmd/graph/commands.rs b/crates/engine/src/cmd/graph/commands.rs new file mode 100644 index 000000000..fc799b9a3 --- /dev/null +++ b/crates/engine/src/cmd/graph/commands.rs @@ -0,0 +1,65 @@ +use super::columns::set_column; +use super::data::set_data; +use super::designs::design; +use super::edit::{create, set_type}; +use super::markers::set_marker; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "graph.create", + "Create Graph", + [], + None, + "{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label", + has_doc, + create + ), + cmd!( + "graph.setData", + "Data…", + ["Object", "Graph"], + None, + "{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty); then one row per category: label; values…; an empty cell or a null is a blank value; a quoted number a label; a `|` in a label a line break, transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; no data → the current {csv, series, categories, rows}", + has_selection, + set_data + ), + cmd!( + "graph.setType", + "Type…", + ["Object", "Graph"], + None, + "{id?, type?, seriesIndexes?: [index], columnWidth?: % (1–1000; past 100 columns overlap), clusterWidth?: % (1–1000), legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, firstRowInFront?: bool, firstColumnInFront?: bool (which overlapping columns are drawn on top: the first category over the next within each series, the first series over the others), filledLines?: bool (Draw Filled Lines: connecting lines as 3 pt bands filled with the series colour), dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); no options → the current ones", + has_selection, + set_type + ), + cmd!( + "graph.design", + "Design…", + ["Object", "Graph"], + None, + "{new?: name|true (New Design: the selected art becomes a graph design, named \"New Design\" when true; put a rectangle at the back to set the size it draws at), rename?: name, to?: new name (the graphs drawing it follow), paste?: name (Paste Design: a copy of the design's art into the document, selected, to edit and save again), delete?: name|[names] (graphs drawing them go back to their default marks), ids?|id?: object ids (default: the selection), selectUnused?: bool (Select Unused: just the list)}; no params or selectUnused → {designs: [names], unused: [the names no graph draws with]}", + has_doc, + design + ), + cmd!( + "graph.column", + "Column…", + ["Object", "Graph"], + None, + "{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); type in the design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits, the first digit giving the least number of places before the decimal point (zeros added in front; 0 = as many as the value needs) and the second digit giving the places after it (rounded half away from zero), at the type's own size where the design's copy puts it (a repeating design shows it once, where a whole copy ending at the value end puts it; type inside a group that clips, is hidden or has its own opacity, blend mode, isolation, knockout, appearance or opacity mask, or inside a blend, compound shape, envelope, repeat or intertwine, stays there and is filled in each copy, drawn with the design; type inside a symbol stays as typed), and is left out of the legend swatch (a design of such type alone gets the default swatch); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}", + has_selection, + set_column + ), + cmd!( + "graph.marker", + "Marker…", + ["Object", "Graph"], + None, + "{id?, seriesIndexes?: [index], design?: name|null} draw the data points and legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, scaled so its backmost object fills the default marker's square; null = the default marker. Line, scatter and radar series. No design → {designs: [names], design: the selected series' design or null}", + has_selection, + set_marker + ), + ] +} diff --git a/crates/engine/src/cmd/graph/data.rs b/crates/engine/src/cmd/graph/data.rs new file mode 100644 index 000000000..f991a7d93 --- /dev/null +++ b/crates/engine/src/cmd/graph/data.rs @@ -0,0 +1,194 @@ +use super::edit::{regenerate, spec_of, target}; +use super::series::{switch_xy, transpose}; +use super::*; + +// ---------- data ---------- + +/// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value). +struct Cell { + text: String, + quoted: bool, +} + +impl Cell { + fn number(&self) -> Option { + if self.quoted { None } else { self.text.parse::().ok().filter(|v| v.is_finite()) } + } +} + +/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside). +fn split_unquoted(s: &str, sep: char) -> Vec<&str> { + let mut parts = vec![]; + let mut quoted = false; + let mut start = 0; + for (i, ch) in s.char_indices() { + if ch == '"' { + quoted = !quoted; + } else if ch == sep && !quoted { + parts.push(s.get(start..i).unwrap_or_default()); + start = i + ch.len_utf8(); + } + } + parts.push(s.get(start..).unwrap_or_default()); + parts +} + +/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed. +fn cell(c: &str) -> Cell { + let c = c.trim(); + match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) { + Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true }, + None => Cell { text: c.trim_matches('"').to_string(), quoted: false }, + } +} + +pub(super) fn parse_csv(csv: &str) -> (Vec, Vec, Vec>>) { + // Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes. + let rows: Vec> = split_unquoted(csv, '\n') + .into_iter() + .map(|l| l.trim_end_matches('\r')) + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let tabs = split_unquoted(l, '\t'); + let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') }; + parts.into_iter().map(cell).collect() + }) + .collect(); + let Some(first) = rows.first() else { return (vec![], vec![], vec![]) }; + // A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value. + let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty()); + // A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`). + // All-empty series names mean no legend. + let empty = |c: &Cell| c.text.is_empty() && !c.quoted; + let header = first.first().is_some_and(empty) + || first.iter().skip(1).any(label) + || (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty)); + let mut series: Vec = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] }; + if series.iter().all(String::is_empty) { + series.clear(); + } + let data = rows.get(usize::from(header)..).unwrap_or_default(); + // The first column holds the category labels if any row starts with one; then it does for every row, so a + // row with an empty label doesn't shift its values one place left. + let labelled = data.iter().any(|r| r.first().is_some_and(label)); + let mut categories = vec![]; + let mut values = vec![]; + for r in data { + if labelled { + categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default()); + } + values.push(r.iter().skip(labelled as usize).map(Cell::number).collect()); + } + (series, categories, values) +} + +pub(super) fn to_csv(g: &GraphSpec) -> String { + g.to_csv() +} + +/// Apply CSV graph data to the graph root `id` and rebuild its art (Variables data merge). +pub(crate) fn apply_csv(d: &mut Document, id: NodeId, csv: &str) -> Result<()> { + let Some(node) = d.node(id) else { + return Err(EngineError::NoNode(id)); + }; + let Some(mut spec) = node.extras().graph.as_deref().cloned() else { + return Err(bad("graph.applyCsv", format!("node {} is not a graph", id.0))); + }; + let (series, categories, rows) = parse_csv(csv); + spec.series = series; + spec.categories = categories; + spec.set_cells(rows); + regenerate(d, id, spec) +} + +pub(super) fn apply_data(g: &mut GraphSpec, p: &Value) -> bool { + let mut changed = false; + if let Some(csv) = str_param(p, "csv") { + let (s, c, r) = parse_csv(csv); + g.series = s; + g.categories = c; + g.set_cells(r); + changed = true; + } + let strings = + |v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::>()); + if let Some(s) = p.get("series").and_then(strings) { + g.series = s; + changed = true; + } + if let Some(c) = p.get("categories").and_then(strings) { + g.categories = c; + changed = true; + } + if let Some(rows) = p.get("rows").and_then(Value::as_array) { + // `null` (or anything that isn't a finite number) is a blank cell. + g.set_cells( + rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(), + ); + changed = true; + } + drop_empty_rows(g); + changed +} + +/// Drop rows with neither a value nor a label; a label-only row stays, its values blank. +fn drop_empty_rows(g: &mut GraphSpec) { + let mut cells = g.cells(); + let keep: Vec = (0..cells.len()) + .map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty())) + .collect(); + if keep.contains(&false) || cells.len() < g.rows.len() { + let mut k = keep.iter(); + cells.retain(|_| k.next().copied().unwrap_or(false)); + let mut k = keep.iter(); + g.categories.retain(|_| k.next().copied().unwrap_or(true)); + g.set_cells(cells); + } +} + +pub(super) fn fmt_value(v: f64) -> String { + if (v - v.round()).abs() < 1e-9 { + // Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so + // `{:.0}` prints its exact digits. + if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") } + } else { + format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string() + } +} + +pub(super) fn set_data(s: &mut Session, p: &Value) -> Result { + const C: &str = "graph.setData"; + let id = target(s, p, C)?; + let mut spec = spec_of(s, id)?; + let flip = bool_or(p, "transpose", false); + let swap = bool_or(p, "switchXY", false); + if swap && spec.kind != GraphKind::Scatter { + return Err(bad(C, "Switch x/y is for scatter graphs")); + } + // Transpose and Switch x/y act after any new data in the same call. + let changed = apply_data(&mut spec, p); + if flip { + // The categories become series: at most as many as a graph has series. + if spec.cells().len() > vectorcraft_doc::MAX_GRAPH_SERIES { + return Err(bad( + C, + format!("a graph has at most {} series, so it transposes with at most that many categories", vectorcraft_doc::MAX_GRAPH_SERIES), + )); + } + transpose(&mut spec); + drop_empty_rows(&mut spec); + } + if swap { + switch_xy(&mut spec); + } + if !(changed || flip || swap) { + // Blank cells come back as null, so the rows can be edited and sent back as they are. + return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() })); + } + // Series assigned to the right axis that the new data no longer has are dropped, so a series added later starts + // on the left. + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0); + spec.right_series.retain(|i| *i < count); + s.edit("Graph Data", |d, _| regenerate(d, id, spec))?; + Ok(json!({ "id": id.0 })) +} diff --git a/crates/engine/src/cmd/graph/designs.rs b/crates/engine/src/cmd/graph/designs.rs new file mode 100644 index 000000000..716810b07 --- /dev/null +++ b/crates/engine/src/cmd/graph/designs.rs @@ -0,0 +1,163 @@ +use super::edit::regenerate; +use super::*; + +/// The document's graph design names, in order. +pub(super) fn design_names(d: &Document) -> Vec { + d.graph_designs.iter().map(|x| x.name.clone()).collect() +} + +/// Whether graph `g` draws a series with design `name`, as its marker or its columns. +fn draws_with(g: &GraphSpec, name: &str) -> bool { + g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name) +} + +/// Regenerate the graphs whose series use design `name` (it was added or removed). +fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { + let mut ids = vec![]; + d.walk(|n| { + if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) { + ids.push(n.id); + } + }); + for id in ids { + if let Some(spec) = d.node(id).and_then(|n| n.extras().graph.as_deref().cloned()) { + regenerate(d, id, spec)?; + } + } + Ok(()) +} + +/// The designs no graph's series draw with (Select Unused). +fn unused_designs(d: &Document) -> Vec { + let mut used = std::collections::HashSet::new(); + d.walk(|n| { + if let Some(g) = n.extras().graph.as_deref() { + used.extend(g.series_markers.iter().flatten().cloned()); + used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone())); + } + }); + d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect() +} + +/// Design names from param `key`: one name or a list of names. +fn names_param(p: &Value, key: &str, cmd: &str) -> Result>> { + match p.get(key) { + None => Ok(None), + Some(Value::String(n)) => Ok(Some(vec![n.clone()])), + Some(Value::Array(a)) if !a.is_empty() => a + .iter() + .map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(cmd, format!("`{key}` is a design name or a list of them")))) + .collect::>() + .map(Some), + Some(_) => Err(bad(cmd, format!("`{key}` is a design name or a list of them"))), + } +} + +pub(super) fn design(s: &mut Session, p: &Value) -> Result { + const C: &str = "graph.design"; + for key in ["paste", "rename", "to"] { + if p.get(key).is_some_and(|v| !v.is_string()) { + return Err(bad(C, format!("`{key}` is a design name"))); + } + } + let taken = |s: &Session, name: &str| s.doc().is_ok_and(|st| st.doc.graph_designs.iter().any(|x| x.name == name)); + // New Design: the selected art, named `new` or "New Design" (numbered when that's taken). + if let Some(v) = p.get("new") { + let name = match v { + Value::String(n) => GraphDesign::clean_name(n).ok_or_else(|| bad(C, "a design needs a name"))?, + Value::Bool(true) | Value::Null => crate::cmd::unique_name("New Design", |n| taken(s, n)), + _ => return Err(bad(C, "`new` is a design name, or true")), + }; + if taken(s, &name) { + return Err(bad(C, format!("there is already a design named `{name}`"))); + } + if s.doc()?.doc.graph_designs.len() >= GraphDesign::MAX { + return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX))); + } + let art = crate::cmd::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?; + if art.count_with_masks() > GraphDesign::MAX_NODES { + return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES))); + } + // A graph saved as a design is kept as the art it draws. + let art = GraphDesign::plain_art(&art); + let saved = name.clone(); + s.edit("New Design", |d, _| { + let art = d.reid(&art); + d.graph_designs.push(GraphDesign { name: saved.clone(), art: Arc::new(art) }); + refresh_graphs_using(d, &saved) + })?; + return Ok(json!({ "name": name })); + } + // Rename: the graphs drawing the design follow it to its new name. + if let Some(old) = str_param(p, "rename") { + if !taken(s, old) { + return Err(bad(C, format!("no design named `{old}`"))); + } + let to = str_param(p, "to").and_then(GraphDesign::clean_name).ok_or_else(|| bad(C, "`to` is the design's new name"))?; + if to == old { + return Ok(json!({ "name": to })); + } + if taken(s, &to) { + return Err(bad(C, format!("there is already a design named `{to}`"))); + } + let (old, new) = (old.to_string(), to.clone()); + s.edit("Rename Design", |d, _| { + if let Some(x) = d.graph_designs.iter_mut().find(|x| x.name == old) { + x.name = new.clone(); + } + let mut graphs = vec![]; + d.walk(|n| { + if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) { + graphs.push(n.id); + } + }); + for id in graphs { + if let Some(g) = d.node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { + for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) { + *m = new.clone(); + } + for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) { + c.design = new.clone(); + } + } + } + Ok(()) + })?; + return Ok(json!({ "name": to })); + } + if let Some(name) = str_param(p, "paste") { + let art = s + .doc()? + .doc + .graph_designs + .iter() + .find(|x| x.name == name) + .map(|x| x.art.clone()) + .ok_or_else(|| bad(C, format!("no design named `{name}`")))?; + let parent = s.doc()?.insertion_parent(); + let id = s.edit("Paste Design", |d, sel| { + let n = d.reid(&art); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + })?; + return Ok(json!({ "id": id.0 })); + } + if let Some(names) = names_param(p, "delete", C)? { + if let Some(n) = names.iter().find(|n| !taken(s, n)) { + return Err(bad(C, format!("no design named `{n}`"))); + } + s.edit("Delete Design", |d, _| { + d.graph_designs.retain(|x| !names.contains(&x.name)); + for name in &names { + refresh_graphs_using(d, name)?; + } + Ok(()) + })?; + return ok(); + } + // The list, with the designs no graph draws with (Select Unused picks those). + let d = &s.doc()?.doc; + Ok(json!({ "designs": design_names(d), "unused": unused_designs(d) })) +} diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs new file mode 100644 index 000000000..d6204c687 --- /dev/null +++ b/crates/engine/src/cmd/graph/edit.rs @@ -0,0 +1,298 @@ +use super::axis::{MAX_TICK_MARKS, count_param, one_line}; +use super::data::apply_data; +use super::generate::{children_bounds, generate}; +use super::series::{picked_series, reselect_series}; +use super::*; + +/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last +/// generation, which is recorded in the spec's `placed` bounds). +pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) + && placed.width() > 1e-9 + && placed.height() > 1e-9 + && (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6 + { + let a = Affine::translate(now.origin().to_vec2()) + * Affine::scale_non_uniform(now.width() / placed.width(), now.height() / placed.height()) + * Affine::translate(-placed.origin().to_vec2()); + spec.rect = a.transform_rect_bbox(spec.rect); + } + let children = generate(d, &spec); + spec.placed = children_bounds(&children); + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + if let Some(ch) = n.children_mut() { + *ch = children; + } + n.extras_mut().graph = Some(Box::new(spec)); + Ok(()) +} + +pub(super) fn create(s: &mut Session, p: &Value) -> Result { + const C: &str = "graph.create"; + let kind = + str_param(p, "type").map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))).transpose()?.unwrap_or_default(); + let (x, y) = (f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let (w, h) = (f64_or(p, "width", 200.0).abs().max(1.0), f64_or(p, "height", 150.0).abs().max(1.0)); + let mut spec = GraphSpec { kind, rect: Rect::new(x, y, x + w, y + h), ..GraphSpec::default() }; + if !apply_data(&mut spec, p) { + // Starter data so the new graph shows something (edit it with Object → Graph → Data…). + spec.series = vec!["Series 1".into(), "Series 2".into()]; + spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; + spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]]; + } + let parent = s.doc()?.target_parent()?; + let id = s.edit("Graph", |d, sel| { + let gid = d.alloc_id(); + let mut g = Node::group(gid, vec![]); + g.name = Some(format!("{} Graph", kind.label())); + d.insert(parent, usize::MAX, g)?; + regenerate(d, gid, spec)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The selected graph (or the graph containing the selection). +pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let start = id_param(p, "id").into_iter().chain(st.selection.objects.iter().copied()); + for id in start { + let mut cur = Some(id); + while let Some(c) = cur { + if st.doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { + return Ok(c); + } + cur = st.doc.parent_of(c); + } + } + Err(bad(cmd, "select a graph")) +} + +pub(super) fn spec_of(s: &Session, id: NodeId) -> Result { + s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) +} + +pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { + const C: &str = "graph.setType"; + let id = target(s, p, C)?; + let mut spec = spec_of(s, id)?; + // The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group + // Selection). Anything else selected, the graph itself for one, is the whole graph. + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + // A file's list of right-axis series, kept to series the graph has, at most once each. + spec.right_series.retain(|i| *i < count); + spec.right_series.sort_unstable(); + spec.right_series.dedup(); + if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() { + return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`")); + } + let picked = picked_series(s, p, id, count, C)?; + let keys = [ + "type", + "seriesIndexes", + "columnWidth", + "clusterWidth", + "legend", + "markPoints", + "connectPoints", + "edgeToEdge", + "ticks", + "axisMin", + "axisMax", + "valueAxis", + "separateScales", + "rightTicks", + "rightAxisMin", + "rightAxisMax", + "tickLength", + "tickMarks", + "rightTickLength", + "rightTickMarks", + "categoryTickLength", + "categoryTickMarks", + "ticksBetweenLabels", + "prefix", + "suffix", + "rightPrefix", + "rightSuffix", + "dropShadow", + "legendAcrossTop", + "firstRowInFront", + "firstColumnInFront", + "filledLines", + "pieLegend", + "piePosition", + "pieSort", + ]; + if !keys.iter().any(|k| p.get(*k).is_some()) { + // With series picked, the type and value axis they share, so the dialog's OK keeps them. + let kinds: Vec = picked.iter().map(|i| spec.series_kind(*i)).collect(); + let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind); + // Series on both axes give no value axis (null), so OK leaves each where it is. + let sides: Vec = picked.iter().map(|i| spec.right_series.binary_search(i).is_ok()).collect(); + let value_axis = match sides.first() { + None => Some(spec.value_axis.id()), + Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }), + Some(_) => None, + }; + let mut fields = json!({ + "type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend, + "markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks, + "axisMin": spec.axis_min, "axisMax": spec.axis_max, "valueAxis": value_axis, "separateScales": spec.separate_scales, + "rightTicks": spec.right_ticks, "rightAxisMin": spec.right_axis_min, "rightAxisMax": spec.right_axis_max, + "tickLength": spec.tick_length.id(), "tickMarks": spec.tick_marks.clamp(1, MAX_TICK_MARKS), "rightTickLength": spec.right_tick_length.id(), + "rightTickMarks": spec.right_tick_marks.clamp(1, MAX_TICK_MARKS), "categoryTickLength": spec.category_tick_length.id(), + "categoryTickMarks": spec.category_tick_marks.clamp(1, MAX_TICK_MARKS), "ticksBetweenLabels": spec.ticks_between_labels, + "prefix": one_line(&spec.prefix), "suffix": one_line(&spec.suffix), "rightPrefix": one_line(&spec.right_prefix), + "rightSuffix": one_line(&spec.right_suffix), "dropShadow": spec.drop_shadow, "legendAcrossTop": spec.legend_across_top, + "firstRowInFront": spec.first_row_in_front, "firstColumnInFront": spec.first_column_in_front, "filledLines": spec.filled_lines, + }); + // Only the options this graph's types use: overlap order for columns and bars, filled lines for lines. + let kinds: Vec = (0..count.max(1)).map(|i| spec.series_kind(i)).collect(); + let has = |ks: &[GraphKind]| kinds.iter().any(|k| ks.contains(k)); + if let Some(o) = fields.as_object_mut() { + if !has(&[GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar]) { + o.remove("firstRowInFront"); + o.remove("firstColumnInFront"); + } + if !has(&[GraphKind::Line, GraphKind::Scatter, GraphKind::Radar]) { + o.remove("filledLines"); + } + } + // Pie graphs: Legend, Position and Sort (their Legend stands for the legend checkbox). + if let Some(o) = fields.as_object_mut().filter(|_| spec.kind == GraphKind::Pie) { + o.remove("legend"); + o.insert( + "pieLegend".into(), + json!(if !spec.legend { + "none" + } else if spec.pie_legend_in_wedges { + "wedges" + } else { + "standard" + }), + ); + o.insert("piePosition".into(), json!(spec.pie_position.id())); + o.insert("pieSort".into(), json!(spec.pie_sort.id())); + } + return Ok(fields); + } + // Series picked with Group Selection stay selected through the regeneration (their groups are new nodes). + let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; + if let Some(t) = str_param(p, "type") { + let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?; + if picked.is_empty() { + spec.kind = kind; + } else { + if !kind.combines_with(spec.kind) { + return Err(bad(C, format!("a {} series can't go in a {} graph", kind.label(), spec.kind.label()))); + } + spec.series_kinds.resize(count, None); + for &i in &picked { + if let Some(k) = spec.series_kinds.get_mut(i) { + *k = (kind != spec.kind).then_some(kind); + } + } + while spec.series_kinds.last().is_some_and(Option::is_none) { + spec.series_kinds.pop(); + } + } + } + if let Some(v) = str_param(p, "valueAxis") { + let side = ValueAxisSide::parse(v).ok_or_else(|| bad(C, format!("unknown value axis `{v}` (left, right or both)")))?; + if picked.is_empty() { + spec.value_axis = side; + } else { + // A series goes on one axis. + if side == ValueAxisSide::Both { + return Err(bad(C, "a series goes on the left or the right value axis")); + } + spec.right_series.retain(|i| *i < count && !picked.contains(i)); + if side == ValueAxisSide::Right { + spec.right_series.extend(&picked); + spec.right_series.sort_unstable(); + } + } + } + spec.separate_scales = bool_or(p, "separateScales", spec.separate_scales); + for (key, slot) in [ + ("tickLength", &mut spec.tick_length), + ("rightTickLength", &mut spec.right_tick_length), + ("categoryTickLength", &mut spec.category_tick_length), + ] { + if let Some(v) = str_param(p, key) { + *slot = TickLength::parse(v).ok_or_else(|| bad(C, format!("unknown {key} `{v}` (none, short or full)")))?; + } + } + for (key, slot) in + [("tickMarks", &mut spec.tick_marks), ("rightTickMarks", &mut spec.right_tick_marks), ("categoryTickMarks", &mut spec.category_tick_marks)] + { + *slot = count_param(p, key, MAX_TICK_MARKS as u64).map_or(*slot, |n| n.max(1)); + } + spec.ticks_between_labels = bool_or(p, "ticksBetweenLabels", spec.ticks_between_labels); + if let Some(v) = str_param(p, "piePosition") { + spec.pie_position = + vectorcraft_doc::PiePosition::parse(v).ok_or_else(|| bad(C, format!("unknown piePosition `{v}` (even, ratio or stacked)")))?; + } + if let Some(v) = str_param(p, "pieSort") { + spec.pie_sort = vectorcraft_doc::PieSort::parse(v).ok_or_else(|| bad(C, format!("unknown pieSort `{v}` (none, all or first)")))?; + } + for (key, slot) in + [("prefix", &mut spec.prefix), ("suffix", &mut spec.suffix), ("rightPrefix", &mut spec.right_prefix), ("rightSuffix", &mut spec.right_suffix)] + { + if let Some(v) = str_param(p, key) { + // A label, not a paragraph: one line, at most MAX_AFFIX characters. + *slot = one_line(v); + } + } + spec.right_ticks = count_param(p, "rightTicks", 100).unwrap_or(spec.right_ticks); + if let Some(v) = p.get("rightAxisMin") { + spec.right_axis_min = v.as_f64(); + } + if let Some(v) = p.get("rightAxisMax") { + spec.right_axis_max = v.as_f64(); + } + spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0); + spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 1000.0); + spec.legend = bool_or(p, "legend", spec.legend); + spec.drop_shadow = bool_or(p, "dropShadow", spec.drop_shadow); + spec.first_row_in_front = bool_or(p, "firstRowInFront", spec.first_row_in_front); + spec.first_column_in_front = bool_or(p, "firstColumnInFront", spec.first_column_in_front); + spec.filled_lines = bool_or(p, "filledLines", spec.filled_lines); + spec.legend_across_top = bool_or(p, "legendAcrossTop", spec.legend_across_top); + // After `legend`, which the Graph Type dialog sends too. + if let Some(v) = str_param(p, "pieLegend") { + (spec.legend, spec.pie_legend_in_wedges) = match v.to_ascii_lowercase().as_str() { + "none" => (false, false), + "standard" => (true, false), + "wedges" => (true, true), + _ => return Err(bad(C, format!("unknown pieLegend `{v}` (none, standard or wedges)"))), + }; + } + spec.mark_points = bool_or(p, "markPoints", spec.mark_points); + spec.connect_points = bool_or(p, "connectPoints", spec.connect_points); + spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge); + spec.ticks = count_param(p, "ticks", 100).unwrap_or(spec.ticks); + if let Some(v) = p.get("axisMin") { + spec.axis_min = v.as_f64(); + } + if let Some(v) = p.get("axisMax") { + spec.axis_max = v.as_f64(); + } + let label = format!("{} Graph", spec.kind.label()); + s.edit("Graph Type", |d, sel| { + regenerate(d, id, spec)?; + if !reselect.is_empty() { + reselect_series(d, sel, id, &reselect); + } + if let Some(n) = d.node_mut(id) + && n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph")) + { + n.name = Some(label); + } + Ok(()) + })?; + Ok(json!({ "id": id.0 })) +} diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs new file mode 100644 index 000000000..336b0857d --- /dev/null +++ b/crates/engine/src/cmd/graph/generate.rs @@ -0,0 +1,704 @@ +use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, tick_values, value_axis}; +use super::columns::{Allowance, ColumnArt, column_art, column_designs}; +use super::data::fmt_value; +use super::labels::{ + FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of, +}; +use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; +use super::series::series_marks; +use super::*; + +/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it, +/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects. +pub(super) fn generate(d: &mut Document, g: &GraphSpec) -> Vec> { + let column_looks = column_designs(d, g); + match generate_with(d, g, &column_looks) { + Some(out) => out, + None => generate_with(d, g, &[]).unwrap_or_default(), + } +} + +/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects. +pub(super) fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option]) -> Option>> { + let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width); + // Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all + // when the designs would draw more than MARKER_BUDGET objects. + let mut designs = marker_designs(d, g); + if marker_cost(g, &designs) > MARKER_BUDGET { + designs.clear(); + } + let design = |s: usize| designs.get(s).and_then(Option::as_ref); + let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref); + let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() }; + // The labels' sizes (9 pt unless formatted), which their spacing follows. + let (vs, cs, ls) = + (label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend)); + let r = g.rect; + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1); + let ncat = g.rows.len().max(1); + let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default(); + let mut series: Vec>> = vec![vec![]; nser]; + // A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points + // break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them. + let cells = g.cells(); + let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten(); + let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0); + // Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own. + let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); + let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind); + // Series drawn as columns or bars, which column designs apply to. + let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar); + let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar); + // Where the art right of the plot ends (the right value axis' labels), so the legend starts after it. + let mut right_edge = r.x1; + + // Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names. + let mut wedge_labels = vec![]; + let mut on_top = vec![]; + // Draw Filled Lines, up to FILLED_LINE_BUDGET data points (the outlines are rebuilt on every change). + let filled = g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET; + // Where columns and bars reach (past the plot when their widths are over 100%), so the legend keeps clear. + let mut bars_reach: Option = None; + // Add Drop Shadow: the columns, bars, lines and wedges to cast one. + let mut shadowed: Vec<(usize, Arc)> = vec![]; + match g.kind { + GraphKind::Pie => { + // One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size + // (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back. + let totals: Vec = (0..ncat).map(|c| (0..nser).map(|s| val(c, s).max(0.0)).sum()).collect(); + let biggest = totals.iter().copied().fold(0.0, f64::max); + let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked; + let w = if stacked { r.width() } else { r.width() / ncat as f64 }; + let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0); + let radius = |total: f64| match g.pie_position { + vectorcraft_doc::PiePosition::Even => full, + _ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0), + _ => full, + }; + // Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere. + let order = |c: usize| { + let mut o: Vec = (0..nser).collect(); + let by = match g.pie_sort { + vectorcraft_doc::PieSort::None => return o, + vectorcraft_doc::PieSort::All => c, + vectorcraft_doc::PieSort::First => 0, + }; + o.sort_by(|a, b| val(by, *b).max(0.0).total_cmp(&val(by, *a).max(0.0))); + o + }; + let mut pies: Vec = (0..ncat).collect(); + if stacked { + pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0))); + } + // Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller + // one, so a pie never covers another (a pie as big as the next one has no ring and is left out). + let inner: Vec = if stacked { + (0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect() + } else { + vec![0.0; pies.len()] + }; + for (k, c) in pies.into_iter().enumerate() { + let total = totals.get(c).copied().unwrap_or(0.0); + let rad = radius(total); + let hole = inner.get(k).copied().unwrap_or(0.0); + if hole >= rad - 1e-9 { + continue; + } + let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) }; + let mut a0 = -std::f64::consts::FRAC_PI_2; + for s in order(c) { + let v = val(c, s).max(0.0); + if total <= 0.0 || v <= 0.0 { + continue; + } + let sweep = v / total * std::f64::consts::TAU; + let mut bp = BezPath::new(); + let dir = |a: f64| vectorcraft_geom::Vec2::new(a.cos(), a.sin()); + let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0); + if hole > 0.0 { + // A ring segment: out along the outer arc, back along the inner one. + bp.move_to(centre + dir(a0) * rad); + arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + bp.line_to(centre + dir(a0 + sweep) * hole); + let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0); + back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + } else { + bp.move_to(centre); + bp.line_to(centre + dir(a0) * rad); + arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + } + bp.close_path(); + let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.5); + // White text on a dark wedge. + let dark = fill.color().is_some_and(|c| { + let [r, g, b] = c.to_rgb(); + 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 + }); + let n = b.path(PathData::from_bezpath(&bp), fill, stroke, sw); + shadowed.push((s, n.clone())); + if let Some(items) = series.get_mut(s) { + items.push(n); + } + // Legends in Wedges: the series label inside its wedge, centred two thirds of the way out. + if g.legend && g.pie_legend_in_wedges { + let label = series_label(s); + if !blank_label(&label) { + // Two thirds of the way out (of the ring, for a stacked pie). + let mid = a0 + sweep / 2.0; + let at = centre + dir(mid) * (hole + (rad - hole) * 0.62); + let t = b.label(Role::Legend, Point::new(at.x, at.y + ls * 0.35), &label, Justify::Center, true); + // White on a dark wedge, unless the legend labels were given a colour of their own. + let own_colour = g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); + wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); + } + } + a0 += sweep; + } + if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { + if stacked { + // Just above its own outline, all its lines, and drawn over the pies. + let lines = cat.split('|').count() as f64; + let at = Point::new(centre.x, centre.y - rad - cs * 0.4 - (lines - 1.0) * cs * 1.2); + let t = b.label(Role::Category, at, cat, Justify::Center, false); + on_top.push(t); + } else { + let t = b.label(Role::Category, Point::new(centre.x, centre.y + rad + cs * 1.6), cat, Justify::Center, false); + b.out.push(t); + } + } + } + } + GraphKind::Radar => { + let centre = r.center(); + let rad = (r.width().min(r.height()) / 2.0).max(1.0); + let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); + let at = |c: usize, v: f64| { + let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; + centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) + }; + let mut axes = vec![]; + for c in 0..ncat { + axes.push(b.line(centre, at(c, hi))); + if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { + let p = at(c, hi) + (at(c, hi) - centre).normalize() * (cs * 1.2); + axes.push(b.label(Role::Category, Point::new(p.x, p.y + cs * 0.35), cat, Justify::Center, true)); + } + } + for v in tick_values(lo, hi, step).into_iter().skip(1) { + let ring: Vec = (0..ncat).map(|c| at(c, v)).collect(); + axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25)); + } + let ax = b.group("Axes", axes); + b.out.push(ax); + for (s, items) in series.iter_mut().enumerate() { + let pts: Vec = (0..ncat).map(|c| at(c, val(c, s))).collect(); + if g.connect_points && pts.len() > 1 { + let n = b.data_line(g, s, polyline(&pts, true), filled); + shadowed.push((s, n.clone())); + items.push(n); + } + if g.mark_points { + for p in pts { + let paint = mark(s, true, Paint::None, 0.0); + items.push(b.marker(design(s), Rect::from_center_size(p, (4.0, 4.0)), paint)); + } + } + } + } + _ => { + let scatter = g.kind == GraphKind::Scatter; + // Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale + // of their own there (axis 1); every other series, and every series without separate scales, uses axis 0. + let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; + let mut on_right = vec![false; nser]; + if separate { + for i in &g.right_series { + if let Some(x) = on_right.get_mut(*i) { + *x = true; + } + } + } + let axis = |s: usize| usize::from(on_right.get(s).copied().unwrap_or(false)); + // Value range per axis (stacked graphs stack positives and negatives separately). + let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); + let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); + for c in 0..ncat { + if !scatter { + for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { + // Stacked columns (or bars) stack together, and so do areas; other series count one value each. + for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { + let members = || (0..nser).filter(|s| axis(*s) == a && stack.contains(&kind(*s))); + *hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum()); + *lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum()); + } + let single = |s: &usize| axis(*s) == a && matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); + for v in (0..nser).filter(single).filter_map(|s| cell(c, s)) { + *hi = hi.max(v); + *lo = lo.min(v); + } + } + } else { + for s in (0..nser).step_by(2) { + if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) { + hi[0] = hi[0].max(y); + lo[0] = lo[0].min(y); + xlo = xlo.min(x); + xhi = xhi.max(x); + } + } + } + } + // An axis with no series of its own shows the other one's scale. + let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]); + let used = |a: usize| (0..nser).any(|s| axis(s) == a); + let (left, right) = if !separate || !used(1) { + (left, left) + } else { + let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]); + if used(0) { (left, right) } else { (right, right) } + }; + let scale = |a: usize| if a == 1 { right } else { left }; + // Value → coordinate along value axis `a` (y for columns/lines, x for bars), and that axis' zero (or the + // end nearest it). + let vpos = |a: usize, v: f64| { + let (lo, hi, _) = scale(a); + let t = (v - lo) / (hi - lo); + if horizontal { r.x0 + t * r.width() } else { r.y1 - t * r.height() } + }; + let zero = |a: usize| { + let (lo, hi, _) = scale(a); + vpos(a, 0.0f64.clamp(lo, hi)) + }; + // Plot points per unit of value along value axis `a`. + let per_unit = |a: usize| { + let (lo, hi, _) = scale(a); + (if horizontal { r.width() } else { r.height() }) / (hi - lo) + }; + let mut axes = vec![]; + // Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right + // one shows the right scale). + let (on_left_side, on_right_side) = + if horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) }; + // Add Labels text per axis, kept to one short line whatever the file holds. + let affix = |s: &str| one_line(s); + let affixes = [(affix(&g.prefix), affix(&g.suffix)), (affix(&g.right_prefix), affix(&g.right_suffix))]; + // Each label's tick mark, then the extra tick marks of its division (Tick Marks per division). + let value_ticks = |b: &mut Gen, axes: &mut Vec>, a: usize, v: f64, step: f64, hi: f64| { + let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) }; + let n = marks.clamp(1, MAX_TICK_MARKS); + for k in 0..n { + let w = v + step * k as f64 / n as f64; + if k > 0 && w >= hi - step * 1e-9 { + break; + } + let q = vpos(a, w); + // A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing. + let repeat = a == 1 && on_left_side && g.tick_length == TickLength::Full && right == left; + let len = if len == TickLength::Full && ((q - zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len }; + let ends = match (len, horizontal, a == 1) { + (TickLength::None, ..) => None, + (TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))), + (TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))), + (TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))), + (TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))), + (TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))), + }; + if let Some((p0, p1)) = ends { + axes.push(b.line(p0, p1)); + } + if k == 0 { + // The label goes right after its own tick mark. + let (prefix, suffix) = affixes.get(a).map_or(("", ""), |(p, s)| (p.as_str(), s.as_str())); + let label = format!("{prefix}{}{suffix}", fmt_value(v)); + let t = if horizontal { + b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &label, Justify::Center) + } else if a == 1 { + b.text(Role::Value, Point::new(r.x1 + 6.0, q + vs * 0.35), &label, Justify::Left) + } else { + b.text(Role::Value, Point::new(r.x0 - 6.0, q + vs * 0.35), &label, Justify::Right) + }; + axes.push(t); + } + } + }; + if on_left_side { + let (lo, hi, step) = left; + for v in tick_values(lo, hi, step) { + value_ticks(&mut b, &mut axes, 0, v, step, hi); + } + } + if on_right_side { + let (lo, hi, step) = right; + for v in tick_values(lo, hi, step) { + value_ticks(&mut b, &mut axes, 1, v, step, hi); + } + } + // The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar + // graph's last bottom label running past the plot (6 points on; a short number stays within the gap). + for t in &axes { + if matches!(t.kind, NodeKind::Text(_)) + && let Some(bb) = t.geometric_bounds() + && bb.x1 > r.x1 + { + right_edge = right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 }); + } + } + if horizontal { + axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1))); + axes.push(b.line(Point::new(zero(0), r.y0), Point::new(zero(0), r.y1))); + } else { + if on_left_side { + axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1))); + } + if on_right_side { + axes.push(b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1))); + } + axes.push(b.line(Point::new(r.x0, zero(0)), Point::new(r.x1, zero(0)))); + // The right scale's zero, where its columns start, when it isn't the left one's. + if (zero(1) - zero(0)).abs() > 1e-9 { + axes.push(b.line(Point::new(r.x0, zero(1)), Point::new(r.x1, zero(1)))); + } + } + // Category positions. + let span = if horizontal { r.height() } else { r.width() }; + let cat_w = span / ncat as f64; + let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w }; + // Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every + // series puts its points: at the edges when all of them span the plot, else at the category centres. + let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge); + let mid = |c: usize, edges: bool| { + if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 } + }; + let label_edges = kinds.iter().all(|k| spans(*k)); + let cat_mid = |c: usize| mid(c, label_edges); + if !scatter { + for c in 0..ncat { + if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { + let t = if horizontal { + b.label(Role::Category, Point::new(r.x0 - 6.0, cat_mid(c) + cs * 0.35), cat, Justify::Right, true) + } else { + b.label(Role::Category, Point::new(cat_mid(c), r.y1 + cs * 1.4), cat, Justify::Center, false) + }; + axes.push(t); + } + } + // Category tick marks (none by default): at the labels, or between them (at the category edges), + // with the extra tick marks of each division between those. + let len = g.category_tick_length; + if len != TickLength::None { + let shown = ncat.min(MAX_CATEGORY_TICKS); + let base: Vec = match (g.ticks_between_labels, label_edges) { + // Labels at the plot's edges: halfway between neighbouring labels. + (true, true) => (1..shown).map(|c| (cat_mid(c - 1) + cat_mid(c)) / 2.0).collect(), + (true, false) => (0..=shown).map(cat_start).collect(), + (false, _) => (0..shown).map(cat_mid).collect(), + }; + // Fewer per division on a long axis, so the cap still ticks all of it. + let n = g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1); + let at = base.iter().enumerate().flat_map(|(i, q)| { + let next = base.get(i + 1).copied(); + (0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) }) + }); + // A full-length tick on a drawn axis line adds nothing. + let on_axis_line = |q: f64| { + if horizontal { + (q - r.y1).abs() < 1e-9 + } else { + ((q - r.x0).abs() < 1e-9 && on_left_side) || ((q - r.x1).abs() < 1e-9 && on_right_side) + } + }; + for q in at.take(MAX_CATEGORY_TICKS) { + if len == TickLength::Full && on_axis_line(q) { + continue; + } + let (p0, p1) = match (len, horizontal) { + (TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)), + (TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)), + (_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)), + (_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)), + }; + axes.push(b.line(p0, p1)); + } + } + } else { + // Scatter: a horizontal value axis too. + // Like the value axis: finite, and a counted (capped) list of ticks, so data near f64::MAX can't loop forever. + let x_lo = if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }; + let x_hi = if xhi == f64::MIN { 1.0 } else { xhi }; + let (xl, xh, xs) = value_axis(g.ticks, None, None, x_lo, x_hi); + for x in tick_values(xl, xh, xs) { + let q = r.x0 + (x - xl) / (xh - xl) * r.width(); + axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))); + axes.push(b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &fmt_value(x), Justify::Center)); + } + for (si, s) in (0..nser).step_by(2).enumerate() { + let pts: Vec> = (0..ncat) + .map(|c| match (cell(c, s), cell(c, s + 1)) { + (Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(0, y))), + _ => None, + }) + .collect(); + if g.connect_points { + for run in runs(&pts) { + let n = b.data_line(g, si, polyline(&run, false), filled); + shadowed.push((si, n.clone())); + series[si].push(n); + } + } + if g.mark_points { + for p in pts.iter().flatten() { + let paint = mark(si, true, Paint::None, 0.0); + let n = b.marker(design(si), Rect::from_center_size(*p, (5.0, 5.0)), paint); + series[si].push(n); + } + } + } + } + let ax = b.group("Axes", axes); + b.out.push(ax); + // Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with + // no plain columns, a width of their own. + let columns: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect(); + let stacks: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect(); + // One stack per value axis that has stacked series, each in a slot of its own. + let stack_axes: Vec = (0..2).filter(|a| stacks.iter().any(|s| axis(*s) == *a)).collect(); + let own_width = columns.is_empty() && stack_axes.len() < 2; + let slots = columns.len() + stack_axes.len(); + // Widths past 100% make the columns (or clusters) overlap. + let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 10.0); + let slot = cluster / slots.max(1) as f64; + let bar = slot * (g.column_width / 100.0).clamp(0.01, 10.0); + let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| { + if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) } + }; + // First Row in Front: the first category drawn last, over the next. + let cats: Vec = if g.first_row_in_front { (0..ncat).rev().collect() } else { (0..ncat).collect() }; + for c in cats { + let c0 = cat_start(c) + (cat_w - cluster) / 2.0; + for (i, &s) in columns.iter().enumerate() { + let Some(v) = cell(c, s) else { continue }; + let a = c0 + slot * i as f64 + (slot - bar) / 2.0; + let (fill, stroke, w) = mark(s, true, Paint::None, 0.0); + let (v0, v1) = (zero(axis(s)), vpos(axis(s), v)); + let rect = bar_rect(a, bar, v0, v1); + bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); + // A column drawn with a design casts no shadow. + let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) + { + Some(n) => { + // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. + if let Some(art) = n.geometric_bounds() { + bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); + } + n + } + None => { + let n = b.path(shapes::rectangle(rect), fill, stroke, w); + shadowed.push((s, n.clone())); + n + } + }; + if let Some(items) = series.get_mut(s) { + items.push(n); + } + } + let place = |ax: usize| { + if own_width { + // Up to the category's width, or past it when the widths are set past 100%. + let (col, clus) = ((g.column_width / 100.0).clamp(0.01, 10.0), (g.cluster_width / 100.0).clamp(0.01, 10.0)); + let w = (cat_w * col * clus * 1.2).min(cat_w * (col * clus).max(1.0)); + (cat_start(c) + (cat_w - w) / 2.0, w) + } else { + let i = columns.len() + stack_axes.iter().position(|a| *a == ax).unwrap_or(0); + (c0 + slot * i as f64 + (slot - bar) / 2.0, bar) + } + }; + // Each axis stacks its own series. + let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]); + for &s in &stacks { + let Some(v) = cell(c, s) else { continue }; + let ax = axis(s); + let (a, w) = place(ax); + let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue }; + let (v0, v1) = (vpos(ax, *base), vpos(ax, *base + v)); + *base += v; + let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25); + let rect = bar_rect(a, w, v0, v1); + bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); + let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) { + Some(n) => { + if let Some(art) = n.geometric_bounds() { + bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); + } + n + } + None => { + let n = b.path(shapes::rectangle(rect), fill, stroke, sw); + // A segment of 0 has no area to shade. + if v != 0.0 { + shadowed.push((s, n.clone())); + } + n + } + }; + if let Some(items) = series.get_mut(s) { + items.push(n); + } + } + } + // Cumulative area bands, each from the previous area total on its axis up to its own. + let mut belows: [Vec; 2] = [vec![0.0; ncat], vec![0.0; ncat]]; + for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) { + let a = axis(s); + let Some(below) = belows.get_mut(a) else { continue }; + let above: Vec = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect(); + // Mixed with series at the category centres, areas take their points there too. + let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(a, v.get(c).copied().unwrap_or(0.0))); + let mut pts: Vec = (0..ncat).map(|c| at(c, &above)).collect(); + pts.extend((0..ncat).rev().map(|c| at(c, below))); + let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25); + let n = b.path(polyline(&pts, true), fill, stroke, w); + if let Some(items) = series.get_mut(s) { + items.push(n); + } + *below = above; + } + for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) { + let pts: Vec> = + (0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(axis(s), v)))).collect(); + if g.connect_points { + for run in runs(&pts) { + let n = b.data_line(g, s, polyline(&run, false), filled); + shadowed.push((s, n.clone())); + if let Some(items) = series.get_mut(s) { + items.push(n); + } + } + } + if g.mark_points { + for p in pts.iter().flatten() { + let paint = mark(s, true, Paint::None, 0.0); + let n = b.marker(design(s), Rect::from_center_size(*p, (5.0, 5.0)), paint); + if let Some(items) = series.get_mut(s) { + items.push(n); + } + } + } + } + } + } + // Legend: a swatch + label per series, right of the plot; the swatch joins its series group. + let legend_series = if g.kind == GraphKind::Scatter { nser.div_ceil(2) } else { nser }; + let mut labels = vec![]; + let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges); + if legend_on && g.legend_across_top { + // Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row + // just above it. + let entries: Vec<(usize, String)> = (0..legend_series) + .map(|s| (s, if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) })) + .filter(|(_, l)| !blank_label(l)) + .collect(); + let mut rows: Vec> = vec![vec![]]; + let mut used = 0.0; + for (s, label) in entries { + let w = 12.0 + label_width(&g.label_styles, &label) + 12.0; + // The gap after an entry may run past the plot's edge. + if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) { + rows.push(vec![]); + used = 0.0; + } + used += w; + if let Some(row) = rows.last_mut() { + row.push((s, label, w)); + } + } + let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; + // Above the plot and anything drawn over it (radar and stacked-pie labels). + let top = b.out.iter().chain(on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); + let top = bars_reach.map_or(top, |b| top.min(b.y0)); + let mut y = top - 8.0 - rows.iter().map(|row| height(row)).sum::(); + for row in &rows { + let mut x = r.x0; + for (s, label, w) in row { + let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line; + let paint = mark(*s, true, Paint::None, 0.0); + let swatch = + b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint); + if let Some(items) = series.get_mut(*s) { + items.push(swatch); + } + labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)); + x += w; + } + y += height(row); + } + } else if legend_on { + let x = bars_reach.map_or(right_edge, |b| right_edge.max(b.x1)) + 14.0; + // Each row as tall as its label's lines (a `|` in a label starts a new line). + let mut y = r.y0; + for (s, items) in series.iter_mut().enumerate().take(legend_series) { + let label = if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) }; + let row_y = y; + if blank_label(&label) { + y += ls * 1.8; + continue; + } + y += ls * 1.8 * label.split('|').count() as f64; + let y = row_y; + // Series drawn with markers show their marker design in the legend, and series drawn with a column design + // that design. + let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line; + let paint = mark(s, true, Paint::None, 0.0); + items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint)); + labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false)); + } + } + // Areas at the back, lines and points in front of columns; one graph type keeps the series order. + let depth = |s: usize| match kind(s) { + GraphKind::Area => 0, + GraphKind::Line => 2, + _ => 1, + }; + // Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just + // behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one + // behind. Left out past SHADOW_BUDGET marks. + if !g.drop_shadow || shadowed.len() > SHADOW_BUDGET { + shadowed.clear(); + } + let mut series: Vec<(usize, Vec>)> = series.into_iter().enumerate().collect(); + // First Column in Front: the first series drawn last in its layer, over the others. + if g.first_column_in_front { + series.reverse(); + } + series.sort_by_key(|(s, _)| depth(*s)); + let mut layer = None; + for (s, items) in series { + if items.is_empty() { + continue; + } + if layer != Some(depth(s)) { + layer = Some(depth(s)); + let copies: Vec> = shadowed.iter().filter(|(t, _)| depth(*t) == depth(s)).map(|(_, n)| shadow_of(b.d, n)).collect(); + if !copies.is_empty() { + let mut grp = Node::group(b.d.alloc_id(), copies); + grp.name = Some("Drop Shadow".into()); + grp.opacity = SHADOW_OPACITY; + b.out.push(Arc::new(grp)); + } + } + let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); + let grp = b.series(&name, s, items); + b.out.push(grp); + } + b.out.append(&mut on_top); + labels.append(&mut wedge_labels); + if !labels.is_empty() { + let grp = b.group("Legend", labels); + b.out.push(grp); + } + (!b.columns.short).then_some(b.out) +} + +pub(super) fn children_bounds(children: &[Arc]) -> Option { + children.iter().filter_map(|c| c.geometric_bounds()).reduce(|a, b| a.union(b)) +} diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs new file mode 100644 index 000000000..9e53c8ddd --- /dev/null +++ b/crates/engine/src/cmd/graph/labels.rs @@ -0,0 +1,198 @@ +use super::axis::LABEL_SIZE; +use super::columns::{Allowance, ColumnArt}; +use super::markers::fit_design; +use super::series::{default_series_paint, series_marks}; +use super::*; + +pub(super) struct Gen<'a> { + pub(super) d: &'a mut Document, + pub(super) out: Vec>, + /// The labels' kept character styles. + pub(super) styles: &'a vectorcraft_doc::LabelStyles, + /// What the column designs may still add. + pub(super) columns: Allowance, +} + +/// What a graph label shows, for its kept character style. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Role { + Value, + Category, + Legend, +} + +/// The character style labels of `role` are set in: the kept one, else 9 pt black. +pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> CharStyle { + let kept = match role { + Role::Value => &styles.values, + Role::Category => &styles.categories, + Role::Legend => &styles.legend, + }; + let mut st = kept.clone().unwrap_or_else(|| CharStyle { size: LABEL_SIZE, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }); + // A file's style within the limits the Character controls keep to (it is copied onto every label). + let within = |v: f64, lo: f64, hi: f64, or: f64| if v.is_finite() { v.clamp(lo, hi) } else { or }; + st.size = within(st.size, 0.1, 1296.0, LABEL_SIZE); + st.leading = st.leading.map(|l| within(l, 0.1, 5000.0, LABEL_SIZE * 1.2)); + st.tracking = within(st.tracking, -1000.0, 10000.0, 0.0); + st.h_scale = within(st.h_scale, 1.0, 10000.0, 100.0); + st.v_scale = within(st.v_scale, 1.0, 10000.0, 100.0); + st.stroke_width = within(st.stroke_width, 0.0, 1000.0, 0.0); + st.font_family = st.font_family.chars().take(256).collect(); + st.font_style = st.font_style.chars().take(256).collect(); + st +} + +/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. +pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { + label_style(styles, role).size +} + +impl Gen<'_> { + pub(super) fn path(&mut self, pd: PathData, fill: Paint, stroke: Paint, w: f64) -> Arc { + Arc::new(Node::path(self.d.alloc_id(), pd, Appearance::basic(fill, stroke, w))) + } + pub(super) fn line(&mut self, a: Point, b: Point) -> Arc { + let mut bp = BezPath::new(); + bp.move_to(a); + bp.line_to(b); + self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5) + } + pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { + let style = label_style(self.styles, role); + let mut t = TextObject::point(at, s, style); + t.para.justify = justify; + t.system_layout = self.d.setup.system_layout; + refresh_bounds(&mut t); + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + } + /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred + /// on `at` (labels beside an axis or in a wedge) instead of running down from it. + pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { + let lines = s.split('|').count(); + let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at }; + self.text(role, at, &s.replace('|', "\n"), justify) + } + pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { + let mut g = Node::group(self.d.alloc_id(), children); + g.name = Some(name.into()); + Arc::new(g) + } + /// A series' line joining its data points (Connect Data Points): a 1 pt stroke in the series colour, or with Draw + /// Filled Lines a band FILLED_LINE_WIDTH wide filled with it and outlined in black. + pub(super) fn data_line(&mut self, g: &GraphSpec, s: usize, pd: PathData, filled: bool) -> Arc { + let band = if filled { + vectorcraft_pathops::outline_stroke(&pd, FILLED_LINE_WIDTH, vectorcraft_pathops::Cap::Butt, vectorcraft_pathops::Join::Miter, 4.0) + } else { + PathData::default() + }; + // A run the outline can't be made for (its points all in one place) stays a thin line. + if !band.is_empty() { + // The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline. + let slot = g.series_paints.get(s); + let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s)); + return self.path(band, fill, Paint::solid(Color::BLACK), 0.25); + } + let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0); + self.path(pd, fill, stroke, w) + } + /// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint. + pub(super) fn marker(&mut self, design: Option<&Arc>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc { + match design.and_then(|a| fit_design(self.d, a, cell)) { + Some(n) => self.design_copy(n), + None => self.path(shapes::rectangle(cell), fill, stroke, w), + } + } + /// A legend swatch filling `cell`: the series' column design with its totals left out, else, and when the design + /// draws nothing else, its [`Self::marker`]. + pub(super) fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc { + let fitted = match column.and_then(|c| Some((c, c.legend.as_ref()?))) { + Some((c, legend)) if self.columns.take(legend.count()) => c.fit(self.d, legend, cell), + _ => None, + }; + match fitted { + Some(n) => self.design_copy(n), + None => self.marker(marker, cell, paint), + } + } + /// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the + /// paths directly inside its group and the design keeps its own paint. + fn design_copy(&mut self, n: Arc) -> Arc { + if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n } + } + /// A series group. Empty series are omitted by the caller; the index is stored on the group + /// so later paint edits find the series without counting siblings. + pub(super) fn series(&mut self, name: &str, index: usize, children: Vec>) -> Arc { + let mut g = Node::group(self.d.alloc_id(), children); + g.name = Some(name.into()); + g.extras_mut().series_index = u32::try_from(index).ok(); + Arc::new(g) + } +} + +/// Add Drop Shadow: how far the shadow sits down and right of its mark, and how dark it is. +const SHADOW_OFFSET: f64 = 2.0; + +pub(super) const SHADOW_OPACITY: f32 = 0.35; + +/// Draw Filled Lines: how wide a filled line is. +const FILLED_LINE_WIDTH: f64 = 3.0; + +pub(super) const FILLED_LINE_BUDGET: usize = 20_000; + +/// Most marks a graph draws shadows for (each is a copy). +pub(super) const SHADOW_BUDGET: usize = 200_000; + +/// Mark `n`'s shadow: a black copy moved down and right, its fill and stroke (whichever it has) black. +pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc { + // Generated marks carry only paint and a stroke width, so that's all a shadow keeps. + let mut c = n.clone(); + c.transform(Affine::translate((SHADOW_OFFSET, SHADOW_OFFSET)), false); + let fill = if c.appearance.fill_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) }; + let stroke = if c.appearance.stroke_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) }; + c.appearance = Appearance::basic(fill, stroke, c.appearance.stroke_width()).into(); + Arc::new(d.reid(&c)) +} + +/// How wide label `s` is set (its widest line), without adding it to a document. +pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { + let style = label_style(styles, Role::Legend); + let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style); + refresh_bounds(&mut t); + Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width()) +} + +/// A label with nothing to show (empty, or only `|` line breaks). +pub(super) fn blank_label(s: &str) -> bool { + s.chars().all(|c| c == '|') +} + +/// Text node `t` with its characters painted `colour`. +pub(super) fn recolour_text(t: &Arc, colour: Color) -> Arc { + let mut n = (**t).clone(); + if let NodeKind::Text(text) = &mut n.kind { + for run in &mut text.runs { + run.style.fill = Paint::solid(colour); + } + } + Arc::new(n) +} + +/// The stretches of two or more points between blank cells, which a line connects. +pub(super) fn runs(pts: &[Option]) -> Vec> { + pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::>()).filter(|r| r.len() > 1).collect() +} + +pub(super) fn polyline(pts: &[Point], closed: bool) -> PathData { + let mut bp = BezPath::new(); + for (i, p) in pts.iter().enumerate() { + if i == 0 { + bp.move_to(*p); + } else { + bp.line_to(*p); + } + } + if closed { + bp.close_path(); + } + PathData::from_bezpath(&bp) +} diff --git a/crates/engine/src/cmd/graph/markers.rs b/crates/engine/src/cmd/graph/markers.rs new file mode 100644 index 000000000..91da6088d --- /dev/null +++ b/crates/engine/src/cmd/graph/markers.rs @@ -0,0 +1,97 @@ +use super::designs::design_names; +use super::edit::{regenerate, spec_of, target}; +use super::series::{picked_series, reselect_series}; +use super::*; + +/// Most objects a graph's marker designs may add (each data point and legend swatch draws a copy of its design). +pub(super) const MARKER_BUDGET: usize = 200_000; + +/// Each series' marker design art, when the document has the design. +pub(super) fn marker_designs(d: &Document, g: &GraphSpec) -> Vec>> { + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + (0..nser) + .map(|s| { + let name = g.series_markers.get(s)?.as_deref()?; + d.graph_designs.iter().find(|x| x.name == name).map(|x| x.art.clone()) + }) + .collect() +} + +/// How many objects the marker designs `designs` add to graph `g`: a copy per data point and legend swatch. +pub(super) fn marker_cost(g: &GraphSpec, designs: &[Option>]) -> usize { + let points = g.rows.len().min(vectorcraft_doc::MAX_GRAPH_CATEGORIES) + 1; + designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add) +} + +/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a +/// single object). `None` for art with no area. +pub(super) fn design_frame(art: &Node) -> Option { + let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite(); + let group = matches!(art.kind, NodeKind::Group { .. }); + let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable); + backmost.or_else(|| art.geometric_bounds().filter(usable)) +} + +/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is +/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it. +/// Fresh ids. `None` for art with no area. +pub(super) fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option> { + let a = fit_affine(design_frame(art)?, cell); + // Strokes, effects and pattern tiles scale with the art. + let art = crate::cmd::place::transformed(art.clone(), a); + Some(Arc::new(d.reid(&art))) +} + +/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]). +pub(super) fn fit_affine(frame: Rect, cell: Rect) -> Affine { + Affine::translate(cell.origin().to_vec2()) + * Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height()) + * Affine::translate(-frame.origin().to_vec2()) +} + +pub(super) fn set_marker(s: &mut Session, p: &Value) -> Result { + const C: &str = "graph.marker"; + let id = target(s, p, C)?; + let mut spec = spec_of(s, id)?; + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + let picked = picked_series(s, p, id, count, C)?; + let names = design_names(&s.doc()?.doc); + let Some(v) = p.get("design") else { + // The design the picked series (all of them with none picked) share, or null. + let all: Vec = if picked.is_empty() { (0..count).collect() } else { picked }; + // A design deleted since doesn't count: those series draw the default marker. + let used: Vec> = + all.iter().map(|i| spec.series_markers.get(*i).and_then(Option::as_ref).filter(|n| names.contains(n))).collect(); + let shared = used.first().copied().flatten().filter(|d| used.iter().all(|x| *x == Some(*d))); + return Ok(json!({ "designs": names, "design": shared })); + }; + let design = match v { + Value::Null => None, + Value::String(n) if names.contains(n) => Some(n.clone()), + Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))), + _ => return Err(bad(C, "`design` is a design name, or null for the default marker")), + }; + let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; + let targets: Vec = if picked.is_empty() { (0..count).collect() } else { picked }; + spec.series_markers.truncate(count); + spec.series_markers.resize(count, None); + for i in targets { + if let Some(m) = spec.series_markers.get_mut(i) { + *m = design.clone(); + } + } + while spec.series_markers.last().is_some_and(Option::is_none) { + spec.series_markers.pop(); + } + if marker_cost(&spec, &marker_designs(&s.doc()?.doc, &spec)) > MARKER_BUDGET { + return Err(bad(C, "the design is too big to draw at every data point of this graph")); + } + s.edit("Graph Marker", |d, sel| { + regenerate(d, id, spec)?; + if !reselect.is_empty() { + reselect_series(d, sel, id, &reselect); + } + Ok(()) + })?; + Ok(json!({ "id": id.0 })) +} diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs new file mode 100644 index 000000000..56ed7eee5 --- /dev/null +++ b/crates/engine/src/cmd/graph/series.rs @@ -0,0 +1,253 @@ +use super::labels::{Role, label_style}; +use super::*; + +// ---------- generation ---------- + +/// Series colours: black, then greys (graphs start in greyscale). +pub(super) fn default_series_color(i: usize) -> Color { + const LEVELS: [f32; 8] = [0.0, 0.55, 0.8, 0.3, 0.68, 0.15, 0.9, 0.42]; + let l = LEVELS[i % LEVELS.len()]; + Color::rgb(l, l, l) +} + +pub(super) fn default_series_paint(i: usize) -> Paint { + Paint::solid(default_series_color(i)) +} + +/// Fill and stroke for one generated mark of series `i`. A stored fill is used only where the +/// generator paints with the series colour (`series_fill`). A stored stroke replaces that part's +/// own stroke, so a column's stroke stays a stroke and a line's markers do not repaint its line. +pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: Paint, width: f64) -> (Paint, Paint, f64) { + let slot = g.series_paints.get(i); + let fill = if series_fill { slot.and_then(|p| p.fill.clone()).unwrap_or_else(|| default_series_paint(i)) } else { Paint::None }; + let stroke = slot.and_then(|p| p.stroke.clone()).unwrap_or(stroke); + let width = if slot.is_some_and(|p| p.stroke.is_some()) { slot.and_then(|p| p.stroke_width).unwrap_or(width) } else { width }; + (fill, stroke, width) +} + +/// Where a node inside a graph's generated series art belongs: the graph, the series group and +/// the series index stored on that group. +#[derive(Clone, Copy)] +struct SeriesRef { + graph: NodeId, + group: NodeId, + index: usize, +} + +/// Every node in a graph's series groups (the groups too), found in one pass over the document. +/// Axes, the legend and groups whose index is past the graph's series (the index comes from the +/// file) are left out. +fn series_members(doc: &Document) -> HashMap { + let mut out = HashMap::new(); + doc.walk(|n| { + let Some(spec) = n.extras().graph.as_deref() else { return }; + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + for group in n.children().into_iter().flatten() { + let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; + let r = SeriesRef { graph: n.id, group: group.id, index }; + group.walk(&mut |m| { + out.insert(m.id, r); + }); + } + }); + out +} + +/// Group Selection on a graph series targets its constituent marks and legend swatch, not a +/// group-level appearance which the generated art does not inherit. Other ids pass through. +pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { + let members = series_members(doc); + if members.is_empty() { + return ids.to_vec(); + } + let mut out = Vec::new(); + for id in ids { + // The series' own marks: the art of a marker design inside it keeps the design's paint (painting it would + // last only until the graph is drawn again), so edit the design to change it. + match members.get(id) { + Some(r) if r.group == *id => { + if let Some(group) = doc.node(*id) { + out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id)); + } + } + Some(r) if doc.parent_of(*id) != Some(r.group) => {} + _ => out.push(*id), + } + } + out.sort_unstable(); + out.dedup(); + out +} + +/// Copy the paint just applied to generated series art into that series' fill or stroke. Called +/// inside the same document edit, so the visible paint and future regeneration undo together. +/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour. +/// Returns whether a series' captured fill or stroke changed. +pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool { + let members = series_members(doc); + if members.is_empty() { + return false; + } + let captures: Vec<_> = ids + .iter() + .filter_map(|id| { + // Only a series' own marks set its paint, not the art of a marker design. + let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; + let node = doc.node(*id)?; + let paint = node.appearance.paint_at(None, fill)?.clone(); + let width = (!fill).then(|| node.appearance.stroke_width()); + Some((r.graph, r.index, paint, width)) + }) + .collect(); + let mut changed = false; + for (graph, index, paint, width) in captures { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let was = spec.series_paints.clone(); + // `index` is below the graph's series count (`series_members`). + if spec.series_paints.len() <= index { + spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); + } + let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + if fill { + slot.fill = Some(paint); + } else { + slot.stroke = Some(paint); + slot.stroke_width = width; + } + changed |= spec.series_paints != was; + } + changed +} + +/// Graph Data › Transpose row/column: categories become series and series categories. +pub(super) fn transpose(g: &mut GraphSpec) { + let cells = g.cells(); + let width = cells.iter().map(Vec::len).max().unwrap_or(0); + let flipped: Vec>> = (0..width).map(|s| cells.iter().map(|row| row.get(s).copied().flatten()).collect()).collect(); + std::mem::swap(&mut g.series, &mut g.categories); + g.set_cells(flipped); +} + +/// Graph Data › Switch x/y (scatter graphs): each series' y and x columns trade places, with their labels. +pub(super) fn switch_xy(g: &mut GraphSpec) { + let mut cells = g.cells(); + // Labels for every column, so each pair's labels trade places with its data. + let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len()); + g.series.resize(width, String::new()); + for row in &mut cells { + for pair in row.as_chunks_mut::<2>().0 { + pair.swap(0, 1); + } + } + for pair in g.series.as_chunks_mut::<2>().0 { + pair.swap(0, 1); + } + while g.series.last().is_some_and(String::is_empty) { + g.series.pop(); + } + g.set_cells(cells); +} + +/// Every generated label of the document's graphs: its graph and what it shows. Inside a graph's Axes group the +/// value axis numbers come first and the category labels (one per category with a label) last; a scatter graph's +/// are all numbers, a radar graph's all categories. Legend groups hold legend labels, and a pie graph's own texts +/// name its pies. +fn graph_labels(doc: &Document) -> HashMap { + let mut out = HashMap::new(); + doc.walk(|n| { + let Some(g) = n.extras().graph.as_deref() else { return }; + for c in n.children().into_iter().flatten() { + match (&c.kind, c.name.as_deref(), c.extras().series_index) { + (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { + out.insert(c.id, (n.id, Role::Category)); + } + (NodeKind::Group { .. }, Some("Legend"), None) => { + for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) { + out.insert(t.id, (n.id, Role::Legend)); + } + } + (NodeKind::Group { .. }, Some("Axes"), None) => { + let texts: Vec = + c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect(); + let cats = match g.kind { + GraphKind::Scatter => 0, + GraphKind::Radar => texts.len(), + _ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()), + }; + for (i, t) in texts.iter().enumerate() { + out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value })); + } + } + _ => {} + } + } + }); + out +} + +/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or +/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so +/// they undo together. Returns whether a kept style changed. +pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { + if ids.is_empty() { + return false; + } + let labels = graph_labels(doc); + if labels.is_empty() { + return false; + } + let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); + kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); + kinds.dedup(); + let mut changed = false; + for (graph, role) in kinds { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let mut style = label_style(&spec.label_styles, role); + change(&mut style); + let slot = match role { + Role::Value => &mut spec.label_styles.values, + Role::Category => &mut spec.label_styles.categories, + Role::Legend => &mut spec.label_styles.legend, + }; + changed |= slot.as_ref() != Some(&style); + *slot = Some(style); + } + changed +} + +/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a +/// selection made only of this graph's series (Group Selection). Empty: the whole graph. +pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result> { + Ok(match p.get("seriesIndexes") { + Some(v) => { + let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`seriesIndexes` is a non-empty list of series indexes"))?; + a.iter() + .map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(cmd, format!("no series {i}")))) + .collect::>()? + } + None if p.get("id").is_none() => { + let st = s.doc()?; + let members = series_members(&st.doc); + let picked: Option> = st.selection.objects.iter().map(|n| members.get(n).filter(|r| r.graph == id).map(|r| r.index)).collect(); + let mut out = picked.unwrap_or_default(); + out.sort_unstable(); + out.dedup(); + out + } + None => vec![], + }) +} + +/// Select the series groups `indexes` of graph `id` (they are new nodes after a regeneration). +pub(super) fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeId, indexes: &[u32]) { + let groups: Vec = d + .node(id) + .and_then(Node::children) + .into_iter() + .flatten() + .filter(|c| c.extras().series_index.is_some_and(|i| indexes.contains(&i))) + .map(|c| c.id) + .collect(); + sel.set(groups); +} diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs new file mode 100644 index 000000000..36c9589cd --- /dev/null +++ b/crates/engine/src/cmd/graph/tests.rs @@ -0,0 +1,2728 @@ +use serde_json::json; +use vectorcraft_color::Paint; +use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; + +use crate::{NodeId, Session}; + +#[test] +fn fmt_value_does_not_saturate_beyond_i64() { + assert_eq!(super::fmt_value(1e19), "10000000000000000000"); + assert_eq!(super::fmt_value(2e19), "20000000000000000000"); + assert_eq!(super::fmt_value(-2e19), "-20000000000000000000"); + assert_eq!(super::fmt_value(42.0), "42"); + assert_eq!(super::fmt_value(-0.0), "0"); + assert_eq!(super::fmt_value(2.5), "2.5"); +} + +fn graph(s: &mut Session, ty: &str) -> NodeId { + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + NodeId( + s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(), + ) +} + +fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node { + n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}")) +} + +/// The generated group stored for series `index`, not a sibling that happens to share its name. +fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { + n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) +} + +#[test] +fn every_graph_type_generates_series_groups() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { + let id = graph(&mut s, ty); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.extras().graph.is_some(), "{ty}"); + if ty != "scatter" { + assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); + assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); + } + assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}"); + } +} + +#[test] +fn column_heights_follow_data_and_data_edits_regenerate_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let heights = |s: &Session| -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + // Bars only (the legend swatch is the 8 × 8 square at the end). + let g = group(&n, "2025"); + let ch = g.children().unwrap(); + ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect() + }; + let h = heights(&s); + assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}"); + // Move the graph, then change the data: it stays where it was moved. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let v = s.execute("graph.setData", &json!({})).unwrap(); + assert!(v["csv"].as_str().unwrap().contains("Q2,5,4")); + s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); + let h2 = heights(&s); + assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}"); + assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); + // Type change keeps the data; undo restores the columns. + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); + // Selecting a bar inside still targets the graph. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bar = group(&n, "2024").children().unwrap()[0].id; + s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); + assert!(s.execute("graph.setData", &json!({})).is_ok()); + assert!(matches!(n.kind, NodeKind::Group { .. })); +} + +/// The x of every marker of series `index`, left to right. +fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut xs: Vec = series(&n, index) + .children() + .unwrap() + .iter() + .map(|c| c.geometric_bounds().unwrap()) + .filter(|b| (b.width() - 5.0).abs() < 1e-6) + .map(|b| b.center().x) + .collect(); + xs.sort_by(f64::total_cmp); + xs +} + +#[test] +fn edge_to_edge_lines_off_puts_line_points_at_category_centres() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400. + let id = graph(&mut s, "line"); + assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false)); + s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap(); + assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]); + // A graph saved before the option existed keeps its edge-to-edge lines. + let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap(); + assert!(old.edge_to_edge); +} + +#[test] +fn radar_graphs_follow_mark_and_connect_data_points() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "radar"); + let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len(); + // The ring, three markers and the legend swatch. + assert_eq!(parts(&s), 5); + s.execute("graph.setType", &json!({"markPoints": false})).unwrap(); + assert_eq!(parts(&s), 2); + s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap(); + assert_eq!(parts(&s), 4); +} + +#[test] +fn an_overridden_value_axis_is_used_exactly() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let tallest = |s: &Session| -> f64 { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bars = series(&n, 0).children().unwrap(); + bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max) + }; + // Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot. + assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); + s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap(); + assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s)); + let o = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0))); + // One bound only keeps the calculated (nice) axis around it. + s.execute("graph.setType", &json!({"axisMax": null})).unwrap(); + assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); +} + +#[test] +fn a_scatter_x_axis_that_overflows_does_not_hang() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // Finite x values whose rounded axis end is infinite: this used to add ticks forever. Now the + // command returns: points that far off the plot can't be drawn, which is an error. + let csv = ",y1,x1\nP,1,1.7e308\nQ,2,1.5e308"; + let refused = |r: crate::Result| assert!(r.unwrap_err().to_string().contains("exceed the canvas")); + refused(s.execute("graph.create", &json!({"type": "scatter", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv}))); + // The same data through Graph Data, on an ordinary scatter graph: it stays as it was. + let axes = |s: &Session, id: NodeId| group(s.doc().unwrap().doc.node(id).unwrap(), "Axes").children().unwrap().len(); + let id = make(&mut s, "scatter", ",y1,x1\nP,1,10\nQ,2,20"); + let before = axes(&s, id); + refused(s.execute("graph.setData", &json!({"id": id.0, "csv": csv}))); + assert_eq!(axes(&s, id), before); +} + +#[test] +fn tick_values_end_on_the_maximum_and_never_run_away() { + // Counted, not accumulated: an offset range keeps its last tick. + let t = super::tick_values(1e10, 1e10 + 1.0, 0.01); + assert_eq!(t.len(), 101); + assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3); + // A range too wide for f64, or a step too small to move, gives no ticks or a capped list. + assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty()); + assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001); + assert!(super::tick_values(0.0, 1.0, 0.0).is_empty()); + // An override whose span overflows falls back to the calculated axis instead of hanging. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + graph(&mut s, "column"); + s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap(); +} + +#[test] +fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for ty in GraphKind::ALL { + let id = graph(&mut s, ty.id()); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); + assert_eq!(spec, restored); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); + let selected_group = group(s.doc().unwrap().doc.node(id).unwrap(), "2024"); + assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); + s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); + s.execute("graph.setType", &json!({"type": "line"})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); + assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); + let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); + assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); + } +} + +#[test] +fn painting_a_series_is_one_undoable_edit() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); + assert_eq!( + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), + "#e8573f" + ); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); + assert_eq!( + group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), + Some(super::default_series_color(0)) + ); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!( + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), + "#e8573f" + ); +} + +/// A fill that a series' marks or a value axis number have already is one undo step when the graph +/// keeps no fill for them (as after a command that paints only the art): the art stays as it was +/// and the graph keeps the fill. Given again, the fill records no undo step. +#[test] +fn a_fill_the_art_has_already_is_kept_by_the_graph_in_one_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + let label = text_id(&s, id, "Axes", "4"); + for target in [series_id, label] { + let fill = json!({"ids": [target.0], "color": "#e8573f"}); + s.execute("paint.setFill", &fill).unwrap(); + // The graph keeps nothing, as after a command that paints only the art. + s.edit("Forget", |d, _| { + let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); + g.series_paints.clear(); + g.label_styles = vectorcraft_doc::LabelStyles::default(); + Ok(()) + }) + .unwrap(); + let art = s.doc().unwrap().doc.node(target).unwrap().clone(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("paint.setFill", &fill).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{target:?}"); + assert_eq!(s.doc().unwrap().doc.node(target).unwrap(), &art, "the art is as it was"); + let g = spec(&s, id); + let kept = if target == series_id { g.series_paints[0].fill.clone() } else { g.label_styles.values.map(|st| st.fill) }; + assert_eq!(kept.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#e8573f".into()), "{target:?}"); + s.execute("paint.setFill", &fill).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing left to keep: {target:?}"); + } +} + +#[test] +fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [bar.0], "color": "#12ab34"})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let node = s.doc().unwrap().doc.node(id).unwrap(); + let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); + assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); + assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); +} + +/// A series index read from a file past the graph's series is not a series: painting its art +/// paints the art alone and stores nothing (no allocation sized by the file). +#[test] +fn a_series_index_past_the_graphs_series_is_ignored() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.edit("Junk", |d, _| { + d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); + Ok(()) + }) + .unwrap(); + let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); + let node = s.doc().unwrap().doc.node(id).unwrap(); + assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); + assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); +} + +#[test] +fn painting_a_line_series_stroke_sets_its_persistent_colour() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); + let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); + assert_eq!( + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), + Some(blue) + ); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!( + group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap().iter().any(|n| n.appearance.stroke_paint().color() == Some(blue)) + ); +} + +#[test] +fn series_colours_survive_native_save_open_and_export() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let first = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id; + s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap(); + let dir = vectorcraft_testkit::temp_dir("graph-palette"); + let path = dir.join("palette.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; + assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let swatch = group(n, "2025").children().unwrap().last().unwrap(); + assert_eq!(swatch.appearance.fill_paint().color().unwrap().to_hex(), "#008080"); + let third = group(n, "Series 3").children().unwrap().first().unwrap(); + assert_eq!(third.appearance.fill_paint().color().unwrap().to_hex(), "#cccccc"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["text"].as_str().unwrap(); + assert!(svg.contains("#ff0000") && svg.contains("#008080")); + let png = dir.join("palette.png"); + s.execute("document.export", &json!({"format": "png", "path": png})).unwrap(); + let pixels = image::open(&png).unwrap().to_rgba8(); + assert!(pixels.pixels().any(|p| p.0 == [255, 0, 0, 255])); + assert!(pixels.pixels().any(|p| p.0 == [0, 128, 128, 255])); + let old: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]]})).unwrap(); + assert!(old.series_paints.is_empty(), "older files keep the greyscale default"); +} + +#[test] +fn a_column_stroke_stays_a_stroke_and_a_marker_fill_does_not_recolour_its_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); + assert!(spec.series_paints[0].fill.is_none()); + let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; + assert_eq!(bar.appearance.fill_paint().color(), Some(super::default_series_color(0))); + assert_eq!(bar.appearance.stroke_paint(), blue); + + let id = graph(&mut s, "line"); + let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; + s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + assert!(spec.series_paints[0].stroke.is_none()); + let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); + assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); + assert_eq!(series[1].appearance.fill_paint().color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))); +} + +#[test] +fn a_gradient_fill_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute( + "paint.setFill", + &json!({"ids": [series_id.0], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ) + .unwrap(); + s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); + let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; + assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); +} + +#[test] +fn a_series_named_legend_keeps_its_own_index_and_an_empty_series_is_omitted() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let csv = ",Legend,Axes\nQ1,3,2\nQ2,5,4"; + let id = NodeId(s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "csv": csv})).unwrap()["id"].as_u64().unwrap()); + let legend_id = { + let n = s.doc().unwrap().doc.node(id).unwrap(); + let legend = series(n, 0); + assert_eq!(legend.name.as_deref(), Some("Legend")); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); + legend.id + }; + s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); + s.execute("graph.setData", &json!({"csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); + assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); + assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); + assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + + let id = graph(&mut s, "pie"); + s.execute("graph.setType", &json!({"id": id.0, "legend": false})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",Keep,Drop\nQ1,4,0\nQ2,2,0"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Keep"))); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); + let keep = group(n, "Keep").id; + s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!(group(n, "Keep").extras().series_index, Some(0)); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); +} + +#[test] +fn csv_parsing() { + let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n"); + assert_eq!(s, vec!["2024", "2025"]); + assert_eq!(c, vec!["Q1", "Q2"]); + assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); + let (s, c, r) = super::parse_csv("1,2\n3,4"); + assert!(s.is_empty() && c.is_empty()); + assert_eq!(r.len(), 2); + assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0)); +} + +#[test] +fn numeric_csv_cells_round_trip_without_rounding_or_saturation() { + let mut g = GraphSpec::default(); + g.set_cells(vec![vec![Some(1.2345), Some(-1.2345)], vec![Some(1e-6), Some(-1e-6)], vec![Some(1e20), Some(-1e20)], vec![Some(0.0), None]]); + let csv = super::to_csv(&g); + let (_, _, cells) = super::parse_csv(&csv); + assert_eq!(cells, g.cells(), "{csv}"); +} + +#[test] +fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]); + let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]); + let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]); + let r = s + .execute( + "graph.create", + &json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}), + ) + .unwrap(); + let id = r["id"].as_u64().unwrap(); + let before = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(before["series"], series); + assert_eq!(before["categories"], categories); + s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap(); + let after = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(after["series"], before["series"], "{}", before["csv"]); + assert_eq!(after["categories"], before["categories"], "{}", before["csv"]); + assert_eq!(after["rows"], before["rows"], "{}", before["csv"]); +} + +#[test] +fn empty_cells_are_blank_and_quoted_numbers_are_labels() { + // Quoted years label the rows; empty and non-numeric cells are blank values, not zeros. + let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4"); + assert_eq!(s, vec!["a", "b"]); + assert_eq!(c, vec!["2023", "2024", "2025"]); + assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]); + // Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same. + let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() }; + g.set_cells(r); + assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]); + let csv = super::to_csv(&g); + assert!(csv.contains("\"2024\",,"), "{csv}"); + let (s2, c2, r2) = super::parse_csv(&csv); + assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells())); +} + +#[test] +fn a_row_with_an_empty_label_keeps_its_values_in_place() { + // The label column is the first column of every row once any row has a label. + let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4"); + assert_eq!(c, vec!["Q1", ""]); + assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); + // And a data window round trip keeps it (the empty label is written as ""). + let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() }; + g.set_cells(r); + assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells()); + // Without series or labels, a first row starting with a blank is still data after a round trip. + let mut g = GraphSpec::default(); + g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]); + let (s, c, r) = super::parse_csv(&super::to_csv(&g)); + assert!(s.is_empty() && c.is_empty()); + assert_eq!(r, g.cells()); + // A label with spaces around a number stays a label, spaces and all. + let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() }; + g.set_cells(vec![vec![Some(1.0)]]); + assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]); +} + +#[test] +fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]])); + // A label followed by empty cells is a header with no series names, as before blanks existed. + let (s, c, r) = super::parse_csv("Year,,\n2023,1,2"); + assert!(s.is_empty()); + assert_eq!(c, Vec::::new()); + assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]); +} + +#[test] +fn a_huge_ragged_grid_is_capped() { + let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() }; + assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES)); +} + +#[test] +fn a_label_only_row_stays_with_blank_values() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); + assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); + assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); +} + +#[test] +fn blank_cells_leave_out_their_columns_and_break_lines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]); + let r = s + .execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows})) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len(); + // Three columns each (one blank), plus the legend swatch. + assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); + // Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three + // markers and the legend swatch. + s.execute("graph.setType", &json!({"type": "line"})).unwrap(); + assert_eq!(count(&s, 0), 1 + 3 + 1); + // Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch. + assert_eq!(count(&s, 1), 1 + 3 + 1); + // A data window round trip keeps the blanks. + let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); + assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); + assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); + // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads + // numbers (as zeros); a graph without blanks writes no `blanks` at all. + let v = serde_json::to_value(&spec).unwrap(); + assert_eq!(v["rows"][1], json!([0.0, 2.0])); + assert_eq!(v["blanks"], json!([[1, 0], [2, 1]])); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none()); +} + +fn spec(s: &Session, id: NodeId) -> GraphSpec { + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() +} + +/// Bounds of a series' marks, without its legend swatch (the last child). +fn marks(s: &Session, id: NodeId, index: u32) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let ch = series(&n, index).children().unwrap().to_vec(); + ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect() +} + +#[test] +fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let one_column = marks(&s, id, 0)[0].width(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line)); + // The columns left take the whole cluster; the line runs through the category centres, one marker a value. + let cols = marks(&s, id, 0); + assert_eq!(cols.len(), 3); + assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width()); + let line = marks(&s, id, 1); + assert_eq!(line.len(), 4, "a line and three markers"); + assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); + // The line series is drawn after (in front of) the column series. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); + assert_eq!(order, [0, 1]); + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); + assert_eq!(order, [1, 0]); + // Back to the graph's own type: nothing is stored for that series. + assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); + // One undo per change. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]); +} + +#[test] +fn group_selecting_a_series_retypes_only_that_series() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let grp = series(&n, 1).id; + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + // The dialog opens on the series' type, and its OK (every option sent back) keeps it. + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); + s.execute("graph.setType", &json!({"type": "area"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area)); + let mut all = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!(all["type"], "area"); + all["ticks"] = json!(4); + s.execute("graph.setType", &all).unwrap(); + assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area); + // The series stays selected through each change. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); + // A series and something outside it selected, or an explicit `id`: the whole graph. + s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column"); + // The whole graph selected: the graph's type changes, the series keeps its own. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setType", &json!({"type": "line"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area)); +} + +#[test] +fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + for ty in ["pie", "radar", "scatter", "bar"] { + assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}"); + } + for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] { + assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}"); + } + assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type"); + assert!(spec(&s, id).series_kinds.is_empty()); + // A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can. + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.series_kind(1), GraphKind::Pie); + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line); +} + +#[test] +fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap(); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a"); + // a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top. + assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]); + // With a plain column too, the stack is the cluster's last slot, beside it. + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap(); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]); +} + +#[test] +fn areas_mixed_with_columns_put_their_points_at_the_category_centres() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); + let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1)); + assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); + // The area is drawn behind the columns. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); + assert_eq!(order, [0, 1]); + // An area graph alone still spans the plot. + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); + assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6); +} + +#[test] +fn bar_graphs_take_stacked_bar_series() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(), + ); + assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err()); + s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap(); + let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2)); + // b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line. + assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]); + assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]); +} + +#[test] +fn series_types_survive_save_and_open_and_older_files_have_none() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]); + let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap(); + assert!(g.series_kinds.is_empty()); + assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none()); + // A stored type the graph can't take, or past its series, is drawn as the graph's type. + let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap(); + assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie)); +} + +/// The value axis labels: text and bounds of the texts in the Axes group that read as numbers. +fn value_labels(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_geom::Rect)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => Some((t.plain_text(), c.geometric_bounds().unwrap())), + _ => None, + }) + .filter(|(t, _)| t.parse::().is_ok()) + .collect() +} + +fn two_scale_graph(s: &mut Session) -> NodeId { + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b\nQ1,10,1000\nQ2,5,500"; + NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ) +} + +#[test] +fn the_value_axis_goes_on_the_left_the_right_or_both_sides() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let left: Vec = value_labels(&s, id).into_iter().inspect(|(_, b)| assert!(b.x1 < 100.0, "{b:?}")).map(|(t, _)| t).collect(); + assert_eq!(left, ["0", "200", "400", "600", "800", "1000"]); + s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); + let right = value_labels(&s, id); + assert!(right.iter().all(|(_, b)| b.x0 > 400.0), "{right:?}"); + assert_eq!(right.iter().map(|(t, _)| t.as_str()).collect::>(), left); + // Both sides, one scale: the same labels on each side, and the legend moves past the right ones. + s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); + let both = value_labels(&s, id); + assert_eq!(both.len(), 12); + let edge = both.iter().map(|(_, b)| b.x1).fold(0.0, f64::max); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let swatch = series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap(); + assert!(swatch.x0 > edge, "{swatch:?} vs {edge}"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both"); + assert!(s.execute("graph.setType", &json!({"valueAxis": "top"})).is_err()); +} + +#[test] +fn separate_scales_give_the_right_axis_series_a_scale_of_their_own() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + // Assigned, but drawn on one scale until separate scales are on with both sides. + assert_eq!(spec(&s, id).right_series, [1]); + s.execute("graph.setType", &json!({"separateScales": true})).unwrap(); + assert!((marks(&s, id, 0)[0].height() - 2.0).abs() < 1e-6, "a's 10 of 1000"); + s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); + // a's 10 and b's 1000 both reach the top: 0..10 on the left, 0..1000 on the right. + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].y0 - 100.0).abs() < 1e-6 && (b[0].y0 - 100.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); + let labels = value_labels(&s, id); + let side = |right: bool| labels.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); + assert_eq!(side(false), ["0", "2", "4", "6", "8", "10"]); + assert_eq!(side(true), ["0", "200", "400", "600", "800", "1000"]); + // The right axis' own tick values. + s.execute("graph.setType", &json!({"rightAxisMin": 0, "rightAxisMax": 2000, "rightTicks": 4})).unwrap(); + assert!((marks(&s, id, 1)[0].y0 - 200.0).abs() < 1e-6, "1000 of 2000"); + assert!((marks(&s, id, 0)[0].y0 - 100.0).abs() < 1e-6, "the left axis is unchanged"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["separateScales"].clone(), v["rightTicks"].clone(), v["rightAxisMax"].clone()), (json!(true), json!(4), json!(2000.0))); + // Stacked series stack on their own axis, each axis' stack in a slot of its own. + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].y1 - 300.0).abs() < 1e-6 && (b[0].y1 - 300.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); + assert!(b[0].x0 >= a[0].x1 - 1e-6 && (a[0].width() - b[0].width()).abs() < 1e-6, "side by side: {:?} {:?}", a[0], b[0]); + // A line on the right axis follows the right scale. + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + let line = marks(&s, id, 1); + assert!((line[1].center().y - 200.0).abs() < 1e-6 && (line[2].center().y - 250.0).abs() < 1e-6, "1000 and 500 of 2000: {line:?}"); +} + +#[test] +fn group_selected_series_go_on_the_chosen_axis() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "left"); + assert!(s.execute("graph.setType", &json!({"valueAxis": "both"})).is_err(), "a series takes one axis"); + s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.right_series.clone(), g.value_axis), (vec![1], vectorcraft_doc::ValueAxisSide::Left)); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "right"); + s.execute("graph.setType", &json!({"valueAxis": "left"})).unwrap(); + assert!(spec(&s, id).right_series.is_empty()); +} + +#[test] +fn bar_graphs_keep_their_value_axis_along_the_bottom() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "bar"); + let before = (value_labels(&s, id), marks(&s, id, 0)); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + assert_eq!((value_labels(&s, id), marks(&s, id, 0)), before); +} + +#[test] +fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!( + (g.value_axis, g.separate_scales, g.right_series, g.right_axis_max), + (vectorcraft_doc::ValueAxisSide::Both, true, vec![1], Some(2000.0)) + ); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in ["valueAxis", "separateScales", "rightSeries", "rightTicks", "rightAxisMin", "rightAxisMax"] { + assert!(v.get(k).is_none(), "{k}"); + } +} + +#[test] +fn the_dialog_round_trip_keeps_series_on_both_axes_where_they_are() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b,c\nQ1,10,1000,3\nQ2,5,500,4"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + for side in ["both", "left"] { + s.execute("graph.setType", &json!({"valueAxis": side, "separateScales": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + s.execute("select.set", &json!({"ids": [series(&n, 0).id.0, series(&n, 1).id.0]})).unwrap(); + let fields = s.execute("graph.setType", &json!({})).unwrap(); + assert!(fields["valueAxis"].is_null(), "{side}: {fields}"); + s.execute("graph.setType", &fields).unwrap(); + let g = spec(&s, id); + assert_eq!((g.right_series, g.value_axis.id()), (vec![1], side), "{side}"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + } +} + +#[test] +fn an_axis_with_no_series_shows_the_other_scale() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + let labels = |s: &Session| { + let l = value_labels(s, id); + let side = |right: bool| l.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); + (side(false), side(true)) + }; + let (left, right) = labels(&s); + assert_eq!(left, right); + assert_eq!(right.last().unwrap(), "1000"); + // Every series on the right: the left axis shows the right scale. + s.execute("graph.setType", &json!({"seriesIndexes": [0, 1], "valueAxis": "right", "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); + let (left, right) = labels(&s); + assert_eq!(left, right); + assert_eq!(right.last().unwrap(), "2000"); +} + +#[test] +fn right_axis_columns_start_at_the_right_scales_zero() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b\nQ1,10,-500\nQ2,5,500"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + // Right scale -600..600 (zero at y 200), left 0..10 (zero at y 300): b's columns meet at the right zero. + let b = marks(&s, id, 1); + assert!((b[0].y0 - 200.0).abs() < 1e-6 && (b[1].y1 - 200.0).abs() < 1e-6, "{b:?}"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let at_200 = group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter(|c| matches!(c.kind, NodeKind::Path { .. })) + .filter_map(|c| c.geometric_bounds()) + .any(|bb| bb.height() < 1e-6 && (bb.y0 - 200.0).abs() < 1e-6 && (bb.width() - 300.0).abs() < 1e-6); + assert!(at_200, "a zero line for the right scale"); +} + +#[test] +fn data_without_a_right_axis_series_drops_its_assignment() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + s.execute("graph.setData", &json!({"csv": ",a\nQ1,10\nQ2,5"})).unwrap(); + assert!(spec(&s, id).right_series.is_empty()); + s.execute("graph.setData", &json!({"csv": ",a,b\nQ1,10,1000\nQ2,5,500"})).unwrap(); + assert!(spec(&s, id).right_series.is_empty(), "a new series starts on the left"); +} + +/// Bounds of the straight lines in the Axes group. +fn axis_lines(s: &Session, id: NodeId) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes").children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).filter_map(|c| c.geometric_bounds()).collect() +} + +fn axis_texts(s: &Session, id: NodeId) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }) + .collect() +} + +#[test] +fn value_axis_tick_marks_take_a_length_and_a_count_per_division() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let short = |s: &Session, x0: f64| axis_lines(s, id).iter().filter(|b| (b.x0 - x0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); + let across = |s: &Session| axis_lines(s, id).iter().filter(|b| b.height() < 1e-6 && (b.width() - 300.0).abs() < 1e-6).count(); + // Short (the default): one at each of the six labels; the zero line runs across. + assert_eq!((short(&s, 96.0), across(&s)), (6, 1)); + s.execute("graph.setType", &json!({"tickLength": "full"})).unwrap(); + // Across the plot at 200…1000; the one at 0 would be the zero line again. + assert_eq!((short(&s, 96.0), across(&s)), (0, 6)); + s.execute("graph.setType", &json!({"tickLength": "none"})).unwrap(); + assert_eq!((short(&s, 96.0), across(&s)), (0, 1)); + // Two per division: one more halfway between labels, none past the last label. + s.execute("graph.setType", &json!({"tickLength": "short", "tickMarks": 2})).unwrap(); + assert_eq!(short(&s, 96.0), 11); + assert!(axis_lines(&s, id).iter().any(|b| (b.x0 - 96.0).abs() < 1e-6 && (b.y0 - 280.0).abs() < 1e-6), "100 of 0..1000 sits at y 280"); + assert_eq!(axis_texts(&s, id).iter().filter(|t| t.parse::().is_ok()).count(), 6, "labels only at divisions"); + // The right axis has its own. + s.execute("graph.setType", &json!({"valueAxis": "both", "rightTickLength": "none"})).unwrap(); + assert_eq!((short(&s, 96.0), short(&s, 400.0)), (11, 0)); + s.execute("graph.setType", &json!({"rightTickLength": "short", "rightTickMarks": 4})).unwrap(); + assert_eq!(short(&s, 400.0), 21); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["tickLength"].clone(), v["tickMarks"].clone(), v["rightTickMarks"].clone()), (json!("short"), json!(2), json!(4))); + assert!(s.execute("graph.setType", &json!({"tickLength": "long"})).is_err()); + s.execute("graph.setType", &json!({"tickMarks": 1_000_000})).unwrap(); + assert_eq!(spec(&s, id).tick_marks, 20); +} + +#[test] +fn category_tick_marks_go_at_or_between_the_labels() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let below = |s: &Session| { + let mut xs: Vec = axis_lines(s, id) + .iter() + .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) + .map(|b| b.x0) + .collect(); + xs.sort_by(f64::total_cmp); + xs + }; + // None by default, as before. + assert!(below(&s).is_empty()); + s.execute("graph.setType", &json!({"categoryTickLength": "short"})).unwrap(); + assert_eq!(below(&s), [175.0, 325.0]); + s.execute("graph.setType", &json!({"ticksBetweenLabels": true})).unwrap(); + assert_eq!(below(&s), [100.0, 250.0, 400.0]); + s.execute("graph.setType", &json!({"categoryTickMarks": 2})).unwrap(); + assert_eq!(below(&s), [100.0, 175.0, 250.0, 325.0, 400.0]); + s.execute("graph.setType", &json!({"categoryTickLength": "full"})).unwrap(); + let full = axis_lines(&s, id).iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6 && b.x0 > 100.0 + 1e-6).count(); + assert_eq!(full, 4, "inside the plot: 175, 250, 325 and 400"); + // Bar graphs: the category axis is the left one. + let bar = graph(&mut s, "bar"); + s.execute("graph.setType", &json!({"id": bar.0, "categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); + let left = axis_lines(&s, bar).iter().filter(|b| b.height() < 1e-6 && (b.x0 - 96.0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); + assert_eq!(left, 4, "three categories, four edges"); +} + +#[test] +fn value_axis_numbers_take_a_prefix_and_a_suffix() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"prefix": "$", "suffix": " M", "valueAxis": "both", "separateScales": true})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right", "rightSuffix": "%"})).unwrap(); + let texts = axis_texts(&s, id); + assert!(texts.contains(&"$10 M".to_string()) && texts.contains(&"$0 M".to_string()), "{texts:?}"); + assert!(texts.contains(&"1000%".to_string()) && !texts.contains(&"$1000 M".to_string()), "{texts:?}"); + // One short line: control characters dropped, the length capped. + s.execute("graph.setType", &json!({"prefix": format!("a\nb{}", "x".repeat(500))})).unwrap(); + let p = spec(&s, id).prefix; + assert!(p.starts_with("ab") && p.chars().count() == 64, "{p}"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["suffix"].clone(), v["rightSuffix"].clone()), (json!(" M"), json!("%"))); +} + +#[test] +fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute( + "graph.setType", + &json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}), + ) + .unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!( + (g.tick_length, g.tick_marks, g.category_tick_length, g.ticks_between_labels, g.suffix.as_str()), + (vectorcraft_doc::TickLength::Full, 3, vectorcraft_doc::TickLength::Short, true, "%") + ); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in [ + "tickLength", + "tickMarks", + "rightTickLength", + "rightTickMarks", + "categoryTickLength", + "categoryTickMarks", + "ticksBetweenLabels", + "prefix", + "suffix", + "rightPrefix", + "rightSuffix", + ] { + assert!(v.get(k).is_none(), "{k}"); + } + // A file's huge values draw within the caps. + let g: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]], "tickMarks": 1_000_000_000u64, "prefix": "y".repeat(10_000)})).unwrap(); + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let art = super::generate(&mut d, &g); + let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap().children().unwrap().to_vec(); + assert!(axes.len() < 200, "{} axis parts", axes.len()); + for t in axes.iter().filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }) { + assert!(t.chars().count() < 80, "{} characters", t.chars().count()); + } +} + +#[test] +fn between_labels_ticks_on_an_edge_to_edge_graph_fall_halfway_between_its_labels() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "area"); + s.execute("graph.setType", &json!({"categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); + // Labels at 100, 250 and 400 (the plot's edges and middle): ticks at 175 and 325. + let mut xs: Vec = axis_lines(&s, id) + .iter() + .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) + .map(|b| b.x0) + .collect(); + xs.sort_by(f64::total_cmp); + assert_eq!(xs, [175.0, 325.0]); +} + +#[test] +fn bar_graph_value_ticks_run_up_the_plot_and_long_labels_push_the_legend() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "bar"); + let legend_x = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap().x0 + }; + let before = legend_x(&s); + assert!((before - 414.0).abs() < 1e-6, "a short last label leaves the legend where it was: {before}"); + s.execute("graph.setType", &json!({"tickLength": "full", "tickMarks": 2})).unwrap(); + let up: Vec<_> = axis_lines(&s, id).into_iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6).collect(); + // -2..6 in 2s: five labels and four halves; the one at 0 is the zero line already, the left end the axis. + assert_eq!(up.len(), 9, "{up:?}"); + s.execute("graph.setType", &json!({"suffix": " million tonnes"})).unwrap(); + let last = value_label_edge(&s, id); + assert!(legend_x(&s) >= last + 6.0 - 1e-6, "{} vs {last}", legend_x(&s)); +} + +/// The right end of the Axes group's texts. +fn value_label_edge(s: &Session, id: NodeId) -> f64 { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter(|c| matches!(c.kind, NodeKind::Text(_))) + .filter_map(|c| c.geometric_bounds()) + .map(|b| b.x1) + .fold(f64::MIN, f64::max) +} + +#[test] +fn counts_come_back_from_the_dialog_as_floats_and_are_capped() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3)); + s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0)); + // A file's values come back as drawn. + let mut fields = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!(fields["rightTickMarks"], 20); + fields["tickLength"] = json!("full"); + s.execute("graph.setType", &fields).unwrap(); + assert_eq!(spec(&s, id).tick_length, vectorcraft_doc::TickLength::Full); +} + +#[test] +fn a_long_category_axis_is_ticked_all_the_way_within_the_cap() { + let rows: Vec> = (0..5000).map(|i| vec![i as f64]).collect(); + let g = GraphSpec { rows, category_tick_length: vectorcraft_doc::TickLength::Short, category_tick_marks: 20, ..GraphSpec::default() }; + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let art = super::generate(&mut d, &g); + let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); + let ticks: Vec = axes + .children() + .unwrap() + .iter() + .filter_map(|c| c.geometric_bounds()) + .filter(|b| b.width() < 1e-9 && (b.height() - 4.0).abs() < 1e-9) + .map(|b| b.x0) + .collect(); + assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len()); + assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked"); +} + +/// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`. +fn save_design(s: &mut Session, name: &str) { + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r, e]})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); +} + +/// The marks of series `index` (legend swatch last) and their bounds. +fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect() +} + +#[test] +fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); + let id = graph(&mut s, "line"); + let before = series_parts(&s, id, 1); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + let after = series_parts(&s, id, 1); + assert_eq!(after.len(), before.len(), "a line, three markers and the swatch"); + for (old, new) in before.iter().zip(&after).skip(1) { + let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap()); + assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art"); + // The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide. + assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}"); + assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}"); + } + // The series stays selected; the other series keeps its squares; the query names the design. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill"); + assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]); + // Back to the default square. + s.execute("graph.marker", &json!({"design": null})).unwrap(); + assert!(spec(&s, id).series_markers.is_empty()); + assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err()); + assert!(s.execute("graph.marker", &json!({"design": 3})).is_err()); +} + +#[test] +fn marker_designs_apply_to_markers_not_to_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + // Every series (none picked): the line series' markers and swatch; the columns and their swatch stay. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + let line = series_parts(&s, id, 1); + assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch"); + let swatch = line.last().unwrap().geometric_bounds().unwrap(); + assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}"); + // Scatter and radar graphs draw them too. + for ty in ["scatter", "radar"] { + let g = graph(&mut s, ty); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}"); + } +} + +#[test] +fn graph_designs_are_saved_pasted_and_deleted() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "nothing selected"); + save_design(&mut s, "Pill"); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "the name is taken"); + assert!(s.execute("graph.design", &json!({"new": " "})).is_err(), "no name"); + // Paste Design: a copy of the art, selected, with ids of its own. + let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]); + let art = s.doc().unwrap().doc.graph_designs[0].art.clone(); + let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone(); + assert_ne!(copy.id, art.id); + assert_eq!(copy.geometric_bounds(), art.geometric_bounds()); + assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err()); + // Deleting a design draws its graphs' default markers again; undo brings both back. + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); + assert!(s.doc().unwrap().doc.graph_designs.is_empty()); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. }))); + assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err()); +} + +#[test] +fn designs_and_markers_survive_save_and_open() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); + assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none()); + // A design whose art has no area draws the default square. + let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() }; + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); +} + +#[test] +fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); + let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); + let mut graphs = 0; + art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); + assert_eq!(graphs, 0, "no graph inside a design"); + // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. + s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); + s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); + let mut found = 0; + s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); + assert_eq!(found, 1); + // A deleted design reads as none in the query, so the dialog opens on the default marker. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap(); + let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap(); + assert!(v["design"].is_null(), "{v}"); +} + +#[test] +fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // 1,500 objects at each of 200 points is past the budget. + let ids: Vec = (0..1500) + .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) + .collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": "Big"})).unwrap(); + let rows: Vec> = (0..200).map(|i| vec![i as f64]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err()); + assert!(spec(&s, id).series_markers.is_empty()); + // A file asking for it anyway draws the default squares. + let mut g = spec(&s, id); + g.series_markers = vec![Some("Big".into())]; + let mut d = (*s.doc().unwrap().doc).clone(); + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Too many objects for one design. + let ids: Vec = (0..2001) + .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) + .collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + assert!(s.execute("graph.design", &json!({"new": "Huge"})).is_err()); +} + +#[test] +fn a_files_designs_are_tidied() { + let mut d = vectorcraft_doc::Document::new(100.0, 100.0); + let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); + let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); + graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); + for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { + let a = art(&mut d); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); + } + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) }); + d.tidy_graph_designs(); + let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); + assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); + assert!(d.graph_designs[2].art.extras().graph.is_none()); +} + +#[test] +fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); + // The design's paths keep the design's paint, before and after the graph is drawn again. + let design_fill = |s: &Session| { + let parts = series_parts(s, id, 0); + let mut fills = vec![]; + parts[1].walk(&mut |n| { + if let NodeKind::Path { .. } = n.kind { + fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + fills + }; + let painted = design_fill(&s); + assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(design_fill(&s), painted); + // A design of one path too. + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Square"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Square"})).unwrap(); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); + assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]); +} + +#[test] +fn graph_design_params_are_names() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + for p in [json!({"new": 5}), json!({"paste": null}), json!({"delete": ["a"]})] { + assert!(s.execute("graph.design", &p).is_err(), "{p}"); + } +} + +#[test] +fn document_wide_passes_see_design_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Blue"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mut seen = false; + s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456")); + assert!(seen, "the colour is used by the design"); +} + +#[test] +fn new_design_rename_delete_and_select_unused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + // New Design names it "New Design", numbered when that's taken. + assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design"); + assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap(); + let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap(); + assert_eq!(v["unused"], json!(["New Design 2"])); + // Renaming a design the graph draws with: the graph follows, still drawing it. + s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap(); + assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]); + assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken"); + assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err()); + // Delete several at once. + s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap(); + assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([])); + assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err()); +} + +/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle +/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`. +fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) { + let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| { + s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() + }; + let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)]; + ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top)); + if guide { + let line = + s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.push(line); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); +} + +/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero +/// line at y = 300. +fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId { + let rows: Vec> = values.iter().map(|v| vec![*v]).collect(); + let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows}); + let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap()); + s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap(); + id +} + +/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it. +fn frames(n: &vectorcraft_doc::Node) -> Vec { + n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect() +} + +fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool { + [a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6) +} + +#[test] +fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = one_series(&mut s, "column", &[35.0]); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}"); + s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap(); + // 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value. + let column = series_parts(&s, id, 0)[0].clone(); + let parts = column.children().unwrap(); + assert_eq!(parts.len(), 4, "3.5 designs"); + let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1]; + let copies = frames(&column); + assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}"); + let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) }; + assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy"); + let (clip, copy) = (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap()); + assert_eq!(clip.y0, 160.0, "cut off at the value"); + // Its sides and its foot, strokes included, are not cut. + assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}"); + // Nor is the brush art of a brushed path inside a group of the design. + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0)); + let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); + for item in &mut look.items { + if let vectorcraft_doc::AppearanceItem::Stroke(st) = item { + st.brush = Some("Wide".into()); + } + } + let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look); + let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]); + let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) }); + let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() }; + let g = + GraphSpec { rows: vec![vec![35.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_columns: vec![Some(options)], ..GraphSpec::default() }; + let out = super::generate(&mut d, &g); + let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); + let last = column.children().unwrap().last().unwrap().clone(); + let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) }; + let clip = children[0].geometric_bounds().unwrap(); + let mut reach = None; + children[1].walk(&mut |n| { + if vectorcraft_brush::has_brush(n) { + reach = vectorcraft_render::painted_bounds(&d, n); + } + }); + let reach = reach.unwrap(); + assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}"); + // Scale: the last copy half as tall instead. + s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let copies = frames(&column); + assert_eq!(copies.len(), 4); + assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}"); + let c = &spec(&s, id).series_columns[0]; + assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale))); + // A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line. + let bars = one_series(&mut s, "bar", &[-15.0]); + s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); + s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap(); + let bar = series_parts(&s, bars, 0)[0].clone(); + let copies = frames(&bar); + // The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250. + let (zero, unit) = (250.0, 3.0); + assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}"); + assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}"); +} + +#[test] +fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // 10 wide and 20 tall, the guide 5 points from the top. + save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true); + let id = one_series(&mut s, "column", &[35.0, 20.0]); + s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap(); + let parts = series_parts(&s, id, 0); + for (column, value_end) in parts.iter().zip([160.0, 220.0]) { + // The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands + // 12 points (2.4 times 5) below the value end. + let halves = column.children().unwrap(); + assert_eq!(halves.len(), 2, "{halves:?}"); + let half = |i: usize| { + let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) }; + (children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec()) + }; + let ((above, kept), (below, stretched)) = (half(0), half(1)); + let at = value_end + 12.0; + assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}"); + assert_eq!(kept.len(), 3, "the guide is not drawn"); + // Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end. + let top = kept[1].geometric_bounds().unwrap(); + assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}"); + // Below it the design is stretched down to the zero line. + let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap()); + assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}"); + assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}"); + } + // Vertically Scaled stretches the whole design, the top part included. + s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap(); + let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); + assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); + // Without a guide a sliding design is scaled vertically too. + save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false); + s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap(); + let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); + assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); + // Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before + // the line, are passed over. + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let mut ids = vec![ + id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()), + id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()), + ]; + let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap()); + let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap()); + let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap()); + s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.extend([square, tick, line]); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": "Boxed"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap(); + let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec(); + let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}"); +} + +#[test] +fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = one_series(&mut s, "column", &[35.0, 20.0]); + s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap(); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap(); + for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) { + let frame = frames(column)[0]; + assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}"); + assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6); + } + assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column"); + // A design twice as wide as it is tall reaches past the plot, and the legend starts right of it. + save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false); + let wide = one_series(&mut s, "column", &[20.0, 35.0]); + s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap(); + let parts = series_parts(&s, wide, 0); + let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap()); + assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns"); + // The legend swatch shows the design, its frame filling the 8-point square. + let swatch = series_parts(&s, id, 0).last().unwrap().clone(); + let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap(); + assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}"); + // Painting the series leaves the design's paint alone. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); + let mut red = false; + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000")); + assert!(!red); +} + +#[test] +fn column_designs_survive_save_and_open_and_follow_their_design() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = graph(&mut s, "stackedColumn"); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + // Every series takes the design; only the stacked column series draws it. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}); + s.execute("graph.column", &p).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch"); + assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series"); + let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap(); + assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"})); + let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale }; + assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none()); + // Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again. + s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap(); + assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token")); + assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([])); + s.execute("graph.design", &json!({"delete": "Token"})).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. }))); + // Null goes back to plain columns. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap(); + assert!(spec(&s, id).series_columns.is_empty()); +} + +#[test] +fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = one_series(&mut s, "column", &[35.0]); + for p in [ + json!({"design": "Nope"}), + json!({"design": 3}), + json!({"design": "Coin", "columnType": "diagonal"}), + json!({"design": "Coin", "each": 0}), + json!({"design": "Coin", "each": -1}), + json!({"design": "Coin", "each": "10"}), + json!({"design": "Coin", "fractions": "round"}), + ] { + let mut p = p; + p["id"] = json!(id.0); + assert!(s.execute("graph.column", &p).is_err(), "{p}"); + } + assert!(spec(&s, id).series_columns.is_empty()); + // 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns. + let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001}); + assert!(s.execute("graph.column", &p).is_err()); + let mut g = spec(&s, id); + let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop }; + g.series_columns = vec![Some(options.clone())]; + let mut d = (*s.doc().unwrap().doc).clone(); + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each + // 0.1 is past the budget too. + let far = one_series(&mut s, "column", &[0.0, 0.0]); + s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap(); + assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err()); + assert!(spec(&s, far).series_columns.is_empty()); + // A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas: + // 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy. + for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] { + g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })]; + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + let column = s0.children().unwrap()[0].clone(); + assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}"); + assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}"); + assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped"); + } +} + +/// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a +/// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5 +/// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from. +fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] { + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); + let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap()); + let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap()); + let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap()); + let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO, + k => panic!("type: {k:?}"), + }; + let points = [origin(s, above), origin(s, below)]; + s.execute("select.set", &json!({"ids": [votes, below]})).unwrap(); + let inner = id_of(s.execute("object.group", &json!({})).unwrap()); + let mut ids = vec![frame, above, inner]; + if guide { + let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap()); + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.push(line); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); + points +} + +/// The text of each piece of type inside `n`, in paint order. +fn texts_in(n: &vectorcraft_doc::Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + out.push(t.plain_text()); + } + }); + out +} + +/// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size. +fn totals_of(nodes: &[std::sync::Arc]) -> Vec<(String, vectorcraft_geom::Point)> { + nodes + .iter() + .map(|n| match &n.kind { + NodeKind::Text(t) => { + let [a, b, c, d, x, y] = t.xf.as_coeffs(); + assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf); + (t.plain_text(), vectorcraft_geom::Point::new(x, y)) + } + k => panic!("a total: {k:?}"), + }) + .collect() +} + +fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool { + (a - b).hypot() < 1e-6 +} + +#[test] +fn column_design_totals_show_each_columns_value_at_their_own_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let [above, below] = save_totals_design(&mut s, "Ballot", false); + let id = one_series(&mut s, "column", &[35.0, 7.256]); + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap(); + let check = |s: &Session, want: [[&str; 2]; 2]| { + let parts = series_parts(s, id, 0); + for (column, want) in parts.iter().zip(want) { + let children = column.children().unwrap(); + // The design's copy keeps the type without a total, stretched with the design, and leaves the totals out. + assert_eq!(texts_in(&children[0]), ["Votes"]); + let frame = frames(column)[0]; + let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); + let totals = totals_of(&children[1..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), want); + // Each set from where the design's copy puts the point it was set from. + assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); + } + // The legend swatch keeps the type without a total and leaves the totals out. + assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]); + }; + check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]); + // New data draws the new values. + s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap(); + check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]); + // The design in the Graph Design list is left as it was. + assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]); + // A bar below zero shows its minus sign, its totals moved with the design along the bar. + let bars = one_series(&mut s, "bar", &[-15.0]); + s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); + s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap(); + let bar = series_parts(&s, bars, 0)[0].clone(); + let frame = frames(&bar)[0]; + let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); + let totals = totals_of(&bar.children().unwrap()[1..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["-015.0", "-15.00 and -15"]); + assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); + // A design that is a single piece of type with a total shows the value alone, and its legend swatch is the + // default square. + let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [one]})).unwrap(); + s.execute("graph.design", &json!({"new": "Count"})).unwrap(); + let count = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap(); + let parts = series_parts(&s, count, 0); + assert_eq!(texts_in(&parts[0]), ["35"]); + assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + // Repeating, with a last copy to chop, it still shows the value. + s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap(); + let column = series_parts(&s, count, 0)[0].clone(); + assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind); +} + +#[test] +fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let [above, below] = save_totals_design(&mut s, "Ballot", true); + let id = one_series(&mut s, "column", &[35.0]); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let children = column.children().unwrap(); + // Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut. + assert_eq!(children.len(), 6, "{children:?}"); + assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped"); + assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"])); + // Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column. + let sx = plain.width() / 10.0; + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0); + let totals = totals_of(&children[4..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["035.0", "35.00 and 35"]); + assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}"); + // Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the + // design with the part stretched down to the zero line. + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let children = column.children().unwrap(); + assert_eq!(children.len(), 4, "two halves and two totals: {children:?}"); + let k = (140.0 - 5.0 * sx) / 5.0; + let totals = totals_of(&children[2..]); + let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx); + let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k); + assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}"); + // A total's pattern fill moves with it. + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + let origin = vectorcraft_geom::Point::new(0.0, -2.0); + let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default()); + let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into(); + let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) }); + let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() }; + let g = + GraphSpec { rows: vec![vec![35.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_columns: vec![Some(options)], ..GraphSpec::default() }; + let out = super::generate(&mut d, &g); + let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); + let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0); + assert_eq!(text, "35"); + let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") }; + assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}"); +} + +#[test] +fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); + let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [total]})).unwrap(); + let faded = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [faded]})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap(); + s.execute("graph.design", &json!({"new": "Faded"})).unwrap(); + let id = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap(); + // The total stays in its half-opaque group: each of the four copies shows the value there. + let parts = series_parts(&s, id, 0); + let copies = parts[0].children().unwrap(); + assert_eq!(copies.len(), 4, "{copies:?}"); + let mut faded_groups = 0; + for c in copies { + assert_eq!(texts_in(c), ["35"]); + c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6)); + } + assert_eq!(faded_groups, 4); + // The legend swatch shows the design without it. + let swatch = parts.last().unwrap(); + assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind); + // Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out + // what it clips. + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); + let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); + let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap()); + s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap(); + let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap(); + s.execute("graph.design", &json!({"new": "Masked"})).unwrap(); + let masked = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap(); + let parts = series_parts(&s, masked, 0); + let clip_groups = |n: &vectorcraft_doc::Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Group { clip: true, children } = &c.kind { + out.push(children.first().map(|m| texts_in(m)).unwrap_or_default()); + } + }); + out + }; + assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value"); + let swatch = parts.last().unwrap(); + let mut drawn = 0; + swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. }))); + assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}"); + // Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves + // them out. + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap()); + let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap()); + let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [first, second]})).unwrap(); + let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap(); + s.execute("graph.design", &json!({"new": "Blended"})).unwrap(); + let blended = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap(); + let parts = series_parts(&s, blended, 0); + let mut blends = vec![]; + parts[0].walk(&mut |n| { + if matches!(n.kind, NodeKind::Blend { .. }) { + blends.push(texts_in(n)); + } + }); + assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]); + assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap()); + // A design that is that blend alone gets the default legend swatch. + s.execute("select.set", &json!({"ids": [blend]})).unwrap(); + s.execute("graph.design", &json!({"new": "Keys"})).unwrap(); + let keys = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap(); + let parts = series_parts(&s, keys, 0); + assert_eq!(texts_in(&parts[0]), ["35", "35"]); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + // A design of a total inside two plain groups shows the value, and its legend swatch is the default square. + let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [total]})).unwrap(); + let inner = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [inner]})).unwrap(); + let outer = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [outer]})).unwrap(); + s.execute("graph.design", &json!({"new": "Nested"})).unwrap(); + let nested = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap(); + let parts = series_parts(&s, nested, 0); + assert_eq!(texts_in(&parts[0]), ["35"]); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); +} + +#[test] +fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() { + for (v, before, after, want) in [ + (122.0, 3, 0, "122"), + (122.0, 0, 0, "122"), + (5.0, 3, 1, "005.0"), + (1234.0, 2, 0, "1234"), + (2.5, 0, 0, "3"), + (-2.5, 0, 0, "-3"), + (0.25, 0, 0, "0"), + (0.25, 0, 2, "0.25"), + (1.005, 0, 2, "1.01"), + (0.1 + 0.2, 0, 2, "0.30"), + (9.995, 0, 2, "10.00"), + (99.96, 0, 1, "100.0"), + (-0.004, 0, 2, "0.00"), + (-7.25, 2, 1, "-07.3"), + (7.0, 0, 9, "7.000000000"), + (1e21, 0, 0, "1000000000000000000000"), + ] { + assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}"); + } + // Each `%` and two digits, left to right; any other `%` stays. + let style = CharStyle::default; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style()); + assert!(super::fill_totals(&mut t, 7.25)); + assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%"); + // A total split between two runs takes the style of its `%`. + let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() }); + t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)]; + assert!(super::fill_totals(&mut t, 7.25)); + assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::>(), [("a 007.3", 10.0), (" b", 20.0)]); + // Type without a total is left as it is. + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style()); + assert!(!super::fill_totals(&mut t, 7.25)); + assert_eq!(t.plain_text(), "Votes: 50%"); +} + +fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId { + NodeId( + s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(), + ) +} + +/// Where each wedge of series `index` starts: its angle from 12 o'clock, clockwise, in degrees (the legend +/// swatch, last, left out). +fn wedge_starts(s: &Session, id: NodeId, index: u32) -> Vec { + let parts = series_parts(s, id, index); + parts[..parts.len() - 1] + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Path { path, .. } => { + let bp = path.to_bezpath(); + let pts: Vec<_> = bp.elements().iter().filter_map(|e| e.end_point()).collect(); + let (c, p) = (pts.first()?, pts.get(1)?); + let deg = (p.x - c.x).atan2(-(p.y - c.y)).to_degrees(); + Some(if deg < -1e-6 { deg + 360.0 } else { deg }) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_bar_in_a_label_breaks_the_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",Total|Sales|2023,b\nQ1|first,3,2\nQ2,5,4"); + let texts = axis_texts(&s, id); + assert!(texts.contains(&"Q1\nfirst".to_string()), "{texts:?}"); + // The legend: a label of three lines, and the next row below all three. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); + let NodeKind::Text(t) = &legend[0].kind else { panic!() }; + assert_eq!(t.plain_text(), "Total\nSales\n2023"); + let (a, b) = + (series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap()); + assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}"); +} + +#[test] +fn transpose_swaps_rows_and_columns_and_switch_xy_swaps_scatter_pairs() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",a,b\nQ1,1,2\nQ2,3,"); + let before = spec(&s, id); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.series.clone(), g.categories.clone()), (vec!["Q1".to_string(), "Q2".into()], vec!["a".to_string(), "b".into()])); + assert_eq!(g.cells(), [[Some(1.0), Some(3.0)], [Some(2.0), None]]); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + assert_eq!(spec(&s, id).cells(), before.cells()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).cells(), g.cells()); + assert!(s.execute("graph.setData", &json!({"switchXY": true})).is_err(), "scatter graphs only"); + let sc = make(&mut s, "scatter", ",y1,x1,y2,x2\nP,1,10,2,20"); + s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); + let g = spec(&s, sc); + assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); + assert_eq!(g.series, ["x1", "y1", "x2", "y2"]); +} + +#[test] +fn pie_wedges_sort_largest_first_in_each_pie_or_in_the_first_pies_order() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b,c\nP1,1,3,2\nP2,3,1,2"); + // None (data order): a starts at 12 o'clock in both pies. + assert_eq!(wedge_starts(&s, id, 0).iter().map(|d| d.round()).collect::>(), [0.0, 0.0]); + s.execute("graph.setType", &json!({"pieSort": "all"})).unwrap(); + // P1: b (3) first; P2: a (3) first. + let (a, b) = (wedge_starts(&s, id, 0), wedge_starts(&s, id, 1)); + assert!(b[0].abs() < 1e-6 && a[1].abs() < 1e-6, "{a:?} {b:?}"); + s.execute("graph.setType", &json!({"pieSort": "first"})).unwrap(); + // P1's order (b, c, a) in both: b first in P2 too. + let b = wedge_starts(&s, id, 1); + assert!(b.iter().all(|d| d.abs() < 1e-6), "{b:?}"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["pieSort"], "first"); + assert!(s.execute("graph.setType", &json!({"pieSort": "random"})).is_err()); +} + +#[test] +fn pies_sized_by_their_totals_side_by_side_or_stacked() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20"); + let pie = |s: &Session, c: usize| { + let wedges = |i: u32| { + let parts = series_parts(s, id, i); + parts[..parts.len() - 1].to_vec() + }; + let rects: Vec<_> = (0..2).flat_map(wedges).filter_map(|n| n.geometric_bounds()).collect(); + rects.into_iter().skip(c).step_by(2).reduce(|a, b| a.union(b)).unwrap() + }; + let even = (pie(&s, 0), pie(&s, 1)); + assert!((even.0.width() - even.1.width()).abs() < 1e-6); + s.execute("graph.setType", &json!({"piePosition": "ratio"})).unwrap(); + let (small, big) = (pie(&s, 0), pie(&s, 1)); + // Areas in proportion to the totals: 10 and 40, so radii 1 : 2. + assert!((big.width() / small.width() - 2.0).abs() < 1e-6, "{small:?} {big:?}"); + assert!((big.width() - even.1.width()).abs() < 1e-6, "the biggest keeps the full size"); + s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); + // Drawn largest first, so the big pie's wedges come first in each series. + let (big, small) = (pie(&s, 0), pie(&s, 1)); + assert!((small.center() - big.center()).hypot() < 1e-6 && (big.width() / small.width() - 2.0).abs() < 1e-6); + // The big pie is drawn first, under the small one. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let wedges: Vec = series(&n, 0).children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect(); + assert!(wedges[0] > wedges[1], "{wedges:?}"); +} + +#[test] +fn legends_in_wedges_put_the_series_labels_inside_the_pie() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",North,South|East\nP,1,1"); + s.execute("graph.setType", &json!({"pieLegend": "wedges"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let pie = + series(&n, 0).children().unwrap()[0].geometric_bounds().unwrap().union(series(&n, 1).children().unwrap()[0].geometric_bounds().unwrap()); + let labels: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); + assert_eq!(labels.len(), 2); + for l in &labels { + let b = l.geometric_bounds().unwrap(); + assert!(pie.contains(b.center()), "{b:?} inside {pie:?}"); + } + // No swatches beside the pie: each series group holds only its wedge. + assert_eq!((series(&n, 0).children().unwrap().len(), series(&n, 1).children().unwrap().len()), (1, 1)); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["pieLegend"].clone(), v["piePosition"].clone()), (json!("wedges"), json!("even"))); + assert!(v.get("legend").is_none(), "a pie's Legend stands for the checkbox"); + // The labels are drawn over the wedges, and on the black first wedge in white. + let order: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect(); + assert_eq!(order.last().map(String::as_str), Some("Legend"), "{order:?}"); + let colour = |l: &vectorcraft_doc::Node| match &l.kind { + NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), + _ => panic!(), + }; + assert_eq!((colour(&labels[0]), colour(&labels[1])), ("#ffffff".to_string(), "#000000".to_string())); + // No Legend wins over a legend checkbox sent along (as the dialog does), and Standard comes back without wedges. + s.execute("graph.setType", &json!({"pieLegend": "none", "legend": true})).unwrap(); + assert!(!spec(&s, id).legend); + s.execute("graph.setType", &json!({"legend": true})).unwrap(); + assert!(!spec(&s, id).pie_legend_in_wedges); + assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err()); + let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft"); + s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.pie_legend_in_wedges, g.pie_position, g.pie_sort), (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All)); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in ["pieLegendInWedges", "piePosition", "pieSort"] { + assert!(v.get(k).is_none(), "{k}"); + } +} + +#[test] +fn stacked_pies_are_rings_so_none_covers_another() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20\nSame,20,20"); + s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + // Series b's wedges: of the two equal big pies one is left out, the other a ring outside the small pie + // (clear of the centre), and the small pie's whole. + let b: Vec<_> = series(&n, 1).children().unwrap().iter().map(|c| (**c).clone()).collect(); + assert_eq!(b.len(), 3, "two wedges and the swatch"); + let small = b[1].geometric_bounds().unwrap(); + let NodeKind::Path { path, .. } = &b[0].kind else { panic!() }; + let c = small.center(); + let near: Vec = path.to_bezpath().elements().iter().filter_map(|e| e.end_point()).map(|p| (p - c).hypot()).collect(); + let min = near.iter().copied().fold(f64::MAX, f64::min); + assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); + // The names are drawn over the pies: after every series group. + let kids = n.children().unwrap(); + let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); + let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); + assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); + assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); +} + +#[test] +fn transpose_keeps_within_the_series_cap_and_drops_stale_axis_series() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows: Vec> = (0..300).map(|i| vec![i as f64]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + assert!(s.execute("graph.setData", &json!({"transpose": true})).is_err(), "300 categories can't become series"); + assert_eq!(spec(&s, id).cells().len(), 300); + let g = make(&mut s, "column", ",a,b,c\nQ1,1,2,3"); + s.execute("graph.setType", &json!({"seriesIndexes": [2], "valueAxis": "right"})).unwrap(); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + assert!(spec(&s, g).right_series.is_empty(), "one series left"); + // An all-blank series becomes no category. + let h = make(&mut s, "column", ",a,b\nQ1,1,\nQ2,2,"); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + assert_eq!(spec(&s, h).categories, ["a", "b"], "b keeps its label"); + assert!(s.execute("graph.setData", &json!({"id": h.0, "transpose": false})).unwrap().get("csv").is_some(), "no change: the query"); +} + +#[test] +fn switch_xy_moves_the_labels_with_their_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "scatter", ",y1,x1,y2\nP,1,10,2,20"); + s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.series, ["x1", "y1", "", "y2"]); + assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); +} + +#[test] +fn a_label_of_only_bars_is_blank_and_non_pie_graphs_list_no_pie_options() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",||,b\nQ1,1,2"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert_eq!(group(&n, "Legend").children().unwrap().len(), 1, "only b"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert!(v.get("pieLegend").is_none() && v.get("legend").is_some()); + // Bar and radar labels with a `|` centre their lines on the category. + for ty in ["bar", "radar"] { + let g = make(&mut s, ty, ",a\nOne|Two|Three,1\nQ2,2\nQ3,3"); + assert!(axis_texts(&s, g).contains(&"One\nTwo\nThree".to_string()), "{ty}"); + } +} + +#[test] +fn drop_shadows_sit_behind_the_columns_lines_and_wedges() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let kids = n.children().unwrap(); + let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); + assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); + let shadow = &kids[at]; + assert!((shadow.opacity - 0.35).abs() < 1e-6); + let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + let cols: Vec<_> = (0..2).flat_map(|i| marks(&s, id, i)).collect(); + assert_eq!(copies.len(), cols.len(), "one per column, none for the swatches"); + for c in &cols { + assert!(copies.iter().any(|b| (b.x0 - c.x0 - 2.0).abs() < 1e-6 && (b.y0 - c.y0 - 2.0).abs() < 1e-6), "{c:?}"); + } + let black = shadow.children().unwrap().iter().all(|c| c.appearance.fill_paint().color().map(|c| c.to_hex()) == Some("#000000".into())); + assert!(black); + // Lines cast one too (not their markers); pies one per wedge. + let line = graph(&mut s, "line"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(line).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 2, "one per series line"); + let pie = graph(&mut s, "pie"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 5, "the wedges with a value above 0"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["dropShadow"], true); + s.execute("graph.setType", &json!({"dropShadow": false})).unwrap(); + let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow"))); +} + +#[test] +fn the_legend_across_top_runs_in_rows_above_the_plot() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let swatch = |s: &Session, i: u32| series_parts(s, id, i).last().unwrap().geometric_bounds().unwrap(); + let (a, b) = (swatch(&s, 0), swatch(&s, 1)); + assert!(a.y1 <= 100.0 - 8.0 + 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && b.x0 > a.x1 && (a.x0 - 100.0).abs() < 1e-6, "{a:?} {b:?}"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["legendAcrossTop"], true); + // Long labels wrap into rows within the plot's width, the last row just above it. + let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); + let csv = format!(",{}\nQ1,1,2,3,4,5,6", labels.join(",")); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let ys: Vec = (0..6).map(|i| swatch(&s, i).y0).collect(); + assert!(ys.windows(2).any(|w| w[1] > w[0]), "a second row: {ys:?}"); + assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}"); + let xs: Vec = (0..6).map(|i| swatch(&s, i).x1).collect(); + assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}"); + let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert!(g.legend_across_top && g.drop_shadow); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + assert!(v.get("dropShadow").is_none() && v.get("legendAcrossTop").is_none()); +} + +#[test] +fn each_layer_keeps_its_shadows_just_behind_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",a,b,c\nQ1,3,2,1\nQ2,5,,2\nQ3,4,6,3"); + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n + .children() + .unwrap() + .iter() + .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) + .collect(); + // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns + // and the line. + let at = |k: &str| order.iter().position(|o| o == k).unwrap(); + let shadows: Vec = order.iter().enumerate().filter(|(_, o)| *o == "Drop Shadow").map(|(i, _)| i).collect(); + assert_eq!(shadows.len(), 2, "{order:?}"); + assert!(at("s0") < shadows[0] && shadows[0] < at("s1") && at("s1") < shadows[1] && shadows[1] < at("s2"), "{order:?}"); + // b's blank leaves two columns; a line with a blank casts a shadow per run. + let line = make(&mut s, "line", ",a\nQ1,1\nQ2,\nQ3,3\nQ4,4"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(line).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1, "Q1 alone draws no line; Q3–Q4 one"); + // Scatter and radar lines cast one too; a stacked segment of 0 doesn't. + for (ty, want) in [("scatter", 1), ("radar", 2)] { + let g = graph(&mut s, ty); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(g).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), want, "{ty}"); + } + let st = make(&mut s, "stackedColumn", ",a,b\nQ1,0,2"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(st).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1); +} + +#[test] +fn the_legend_across_top_keeps_above_labels_over_the_plot() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "radar", ",a,b\nTop|of|chart,1,2\nQ2,2,3\nQ3,3,1"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min); + let legend_bottom = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max); + assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}"); + // No legend: nothing across the top either. + s.execute("graph.setType", &json!({"legend": false})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Legend"))); + // Labels of a row other than the last stay within the plot's width. + let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); + let col = make(&mut s, "column", &format!(",{}\nQ1,1,2,3,4,5,6", labels.join(","))); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let n = s.doc().unwrap().doc.node(col).unwrap().clone(); + let texts: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + let last_row = texts.iter().map(|b| b.y0).fold(f64::MIN, f64::max); + assert!(texts.iter().filter(|b| b.y0 < last_row - 1e-6).all(|b| b.x1 <= 400.0 + 1e-6), "{texts:?}"); +} + +#[test] +fn widths_past_100_overlap_and_the_first_row_or_column_goes_in_front() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"columnWidth": 250, "clusterWidth": 300})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.column_width, g.cluster_width), (250.0, 300.0)); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); + assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); + assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); + let order = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() + }; + assert_eq!(order(&s), [0, 1]); + s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); + assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); + // First Row in Front: each series' first category drawn last. + let xs = |s: &Session| marks(s, id, 0).iter().map(|b| b.x0).collect::>(); + let before = xs(&s); + assert!(before.windows(2).all(|w| w[0] < w[1])); + s.execute("graph.setType", &json!({"firstRowInFront": true})).unwrap(); + let after = xs(&s); + assert!(after.windows(2).all(|w| w[0] > w[1]), "{after:?}"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["firstRowInFront"].clone(), v["firstColumnInFront"].clone()), (json!(true), json!(true))); +} + +#[test] +fn filled_lines_are_bands_in_the_series_colour() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,2\nQ3,2"); + let thin = marks(&s, id, 0)[0]; + assert!(thin.height() < 1e-6, "a flat 1 pt line"); + s.execute("graph.setType", &json!({"filledLines": true})).unwrap(); + let parts = series_parts(&s, id, 0); + let band = parts[0].geometric_bounds().unwrap(); + assert!((band.height() - 3.0).abs() < 1e-6 && (band.width() - thin.width()).abs() < 1e-6, "{band:?}"); + assert_eq!(parts[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000", "the first series' colour"); + assert_eq!(parts[0].appearance.stroke_width(), 0.25); + // A series colour set with Group Selection fills the band. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap(); + assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft"); + s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert!(g.filled_lines && g.first_row_in_front && g.first_column_in_front); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in ["filledLines", "firstRowInFront", "firstColumnInFront"] { + assert!(v.get(k).is_none(), "{k}"); + } +} + +#[test] +fn filled_lines_keep_the_line_colour_and_fall_back_to_thin_lines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); + // A colour given to the thin line (its stroke) fills the band. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setStroke", &json!({"ids": [grp.0], "color": "#00ff00"})).unwrap(); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = &series_parts(&s, id, 0)[0]; + assert_eq!(band.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); + // A radar series of zeros has every point at the centre: no band to make, so a thin line. + let radar = make(&mut s, "radar", ",a\nQ1,0\nQ2,0\nQ3,0"); + s.execute("graph.setType", &json!({"id": radar.0, "filledLines": true})).unwrap(); + let line = &series_parts(&s, radar, 0)[0]; + assert!(line.appearance.fill_paint().is_none(), "a stroked line"); + // Past the budget, thin lines. + let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); + let big = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"id": big.0, "filledLines": true, "markPoints": false})).unwrap(); + assert!(series_parts(&s, big, 0)[0].appearance.fill_paint().is_none()); +} + +#[test] +fn overlapping_columns_keep_clear_of_the_legend_and_options_follow_the_graph_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"columnWidth": 1000, "clusterWidth": 1000})).unwrap(); + let reach = (0..2).flat_map(|i| marks(&s, id, i)).map(|b| b.x1).fold(f64::MIN, f64::max); + assert!(reach > 400.0, "past the plot: {reach}"); + let swatch = series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(); + assert!(swatch.x0 >= reach + 14.0 - 1e-6, "{swatch:?} after {reach}"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert!(v.get("firstRowInFront").is_some() && v.get("filledLines").is_none()); + let line = graph(&mut s, "line"); + let v = s.execute("graph.setType", &json!({"id": line.0})).unwrap(); + assert!(v.get("firstRowInFront").is_none() && v.get("filledLines").is_some()); +} + +/// The Axes and Legend texts with their size and fill, after the graph is drawn again. +fn label_looks(s: &mut Session, id: NodeId) -> Vec<(String, f64, String)> { + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + let st = &t.runs[0].style; + out.push((t.plain_text(), st.size, st.fill.color().map(|c| c.to_hex()).unwrap_or_default())); + } + } + } + out +} + +fn text_id(s: &Session, id: NodeId, grp: &str, text: &str) -> NodeId { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, grp).children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap().id +} + +#[test] +fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 12})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().all(|(_, size, _)| (*size - 12.0).abs() < 1e-6), "{looks:?}"); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_some() && g.label_styles.legend.is_some()); + // One undo takes the data change back, the next the formatting and the kept styles together. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).label_styles.values.is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); +} + +#[test] +fn formatting_one_label_keeps_that_kind_of_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // A legend label (Group Selection), recoloured with the Character panel's fill. + let l = text_id(&s, id, "Legend", "2025"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setStyle", &json!({"fill": "#ff0000"})).unwrap(); + // A category label, resized. + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + // A value axis number, painted with the Appearance fill. + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "color": "#0000ff"})).unwrap(); + let looks = label_looks(&mut s, id); + let look = |t: &str| looks.iter().find(|(x, _, _)| x == t).cloned().unwrap(); + assert_eq!(look("2024"), ("2024".into(), 9.0, "#ff0000".into()), "every legend label"); + assert_eq!(look("Q1").1, 14.0, "every category label"); + assert_eq!(look("Q1").2, "#000000"); + assert_eq!(look("0").2, "#0000ff", "every value axis number"); + assert_eq!(look("0").1, 9.0); + // Kept through save and open; nothing written when unset. + let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); +} + +#[test] +fn pie_names_and_wedge_labels_keep_their_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let name = n.children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "P1")).unwrap().id; + s.execute("select.set", &json!({"ids": [name.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 16})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 16.0); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + let w = text_id(&s, id, "Legend", "a"); + s.execute("select.set", &json!({"ids": [w.0]})).unwrap(); + s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let fills: Vec = group(&n, "Legend") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => t.runs[0].style.fill.color().map(|c| c.to_hex()), + _ => None, + }) + .collect(); + assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge"); +} + +#[test] +fn labels_are_told_apart_by_where_they_come_not_by_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // Categories named like the value axis numbers. + let id = make(&mut s, "column", ",a\n2,1\n4,3\n6,5"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let texts: Vec<(NodeId, String)> = group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => Some((c.id, t.plain_text())), + _ => None, + }) + .collect(); + // The value axis "2" comes before the category "2". + let value_two = texts.iter().find(|(_, t)| t == "2").unwrap().0; + s.execute("select.set", &json!({"ids": [value_two.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 7})).unwrap(); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none(), "{:?}", g.label_styles); + // A scatter graph's numbers are all values. + let sc = graph(&mut s, "scatter"); + s.execute("select.set", &json!({"ids": [sc.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 8})).unwrap(); + let g = spec(&s, sc); + assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none()); + // Part of a label's text: nothing kept. + let col = graph(&mut s, "column"); + let q = text_id(&s, col, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 20, "start": 0, "end": 1})).unwrap(); + assert!(spec(&s, col).label_styles.categories.is_none()); + // Only the change is kept: a size given to the whole graph leaves the label colours as they were. + let pie = make(&mut s, "pie", ",a,b\nP,1,1"); + s.execute("graph.setType", &json!({"id": pie.0, "pieLegend": "wedges"})).unwrap(); + s.execute("select.set", &json!({"ids": [pie.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 11})).unwrap(); + let legend = spec(&s, pie).label_styles.legend.unwrap(); + assert_eq!((legend.size, legend.fill.color().unwrap().to_hex()), (11.0, "#000000".into())); + s.execute("graph.setData", &json!({"id": pie.0, "rows": [[2, 1]]})).unwrap(); + let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); + let fill = match &group(&n, "Legend").children().unwrap()[0].kind { + NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), + _ => panic!(), + }; + assert_eq!(fill, "#ffffff", "still white on the black wedge"); +} + +#[test] +fn character_formats_are_kept_and_bigger_labels_space_out() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true, "baselineShift": 1})).unwrap(); + let g = spec(&s, id); + let st = g.label_styles.legend.unwrap(); + assert!(st.underline && (st.baseline_shift - 1.0).abs() < 1e-9); + // 20 pt legend labels: the rows move apart so they don't overlap. + s.execute("text.setStyle", &json!({"size": 20})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",a,b,c\nQ1,1,2,3"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut rows: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + rows.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + assert!(rows.windows(2).all(|w| w[1].y0 >= w[0].y1 - 1e-6), "{rows:?}"); + // A file's absurd style is kept within the Character limits. + let mut g = spec(&s, id); + g.label_styles.values = Some(CharStyle { size: 1e300, tracking: f64::INFINITY, ..CharStyle::default() }); + let mut d = (*s.doc().unwrap().doc).clone(); + let art = super::generate(&mut d, &g); + let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); + for t in axes.children().unwrap() { + if let NodeKind::Text(t) = &t.kind { + assert!(t.runs[0].style.size <= 1296.0 && t.runs[0].style.tracking.is_finite()); + } + } +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 05ec602e6..7b56c2f46 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -138,6 +138,15 @@ pub fn specs() -> Vec { has_doc, artboard_delete ), + cmd!( + "artboard.deleteEmpty", + "Delete Empty Artboards", + ["Window", "Artboards"], + None, + "{} delete every artboard with no artwork on it (no object, hidden, locked and template art included, whose visual bounds meet or touch it, clip groups and clipping layers by their members inside the clip; guides don't count), with its guides, in one step; locked artboards stay; when every artboard is empty and none is locked, artboard 1 stays (with a locked one left, artboard 1 can go) → {deleted: [index…]} (the indices before deleting)", + has_empty_artboards, + artboard_delete_empty + ), cmd!( "artboard.setProps", "Artboard Options…", @@ -863,21 +872,49 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { fn artboard_delete(s: &mut Session, p: &Value) -> Result { let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?; super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?; - s.edit("Delete Artboard", |d, sel| { - if indices.len() >= d.artboards.len() { - return Err(EngineError::Other("a document needs at least one artboard".into())); - } - // The last first, so the others keep their places; their guides go with them. - for &i in indices.iter().rev() { - let id = d.artboards.remove(i).id; - d.retire_artboard_id(id); - d.retain_guides(sel, |_, g| g.artboard != Some(id)); - } - Ok(()) - })?; + s.edit("Delete Artboard", |d, sel| remove_artboards(d, sel, &indices))?; ok() } +/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left. +fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> { + if indices.len() >= d.artboards.len() { + return Err(EngineError::Other("a document needs at least one artboard".into())); + } + // The last first, so the others keep their places; their guides go with them. + for &i in indices.iter().rev() { + let id = d.artboards.remove(i).id; + d.retire_artboard_id(id); + d.retain_guides(sel, |_, g| g.artboard != Some(id)); + } + Ok(()) +} + +/// The artboards Delete Empty Artboards deletes: those with no artwork +/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one +/// of them (a document keeps one, as Delete Artboards keeps artboard 1). +pub(crate) fn empty_artboards(d: &Document) -> Vec { + let mut empty: Vec = d.artboards.iter().enumerate().filter(|(_, a)| !a.locked && !d.artboard_has_art(a.rect)).map(|(i, _)| i).collect(); + if empty.len() == d.artboards.len() { + empty.retain(|&i| i != 0); + } + empty +} + +fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if empty_artboards(&st.doc).is_empty() { Err("no empty artboards to delete".into()) } else { Ok(()) } +} + +fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result { + let indices = empty_artboards(&s.doc()?.doc); + if indices.is_empty() { + return Err(EngineError::Other("no empty artboards to delete".into())); + } + s.edit("Delete Empty Artboards", |d, sel| remove_artboards(d, sel, &indices))?; + Ok(json!({ "deleted": indices })) +} + /// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock, /// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel /// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index ef5be1322..aef05cc8a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image too large to draw, document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", has_doc, list ), @@ -1128,7 +1128,7 @@ fn list(s: &mut Session, p: &Value) -> Result { // Only an image that could be past the decode allowance (at 16 bytes a pixel, the // most a decoder gives) has its header read for this. let large = im.width as u64 * im.height as u64 > fileio::MAX_RASTER_ALLOC / 16; - if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::too_large_note(&b.bytes)) { + if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::size_note(&b.bytes)) { row["note"] = json!(note); } (im.link.as_ref(), row) diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index d228c7d81..1276bdc2a 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,1640 +21,13 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::*; -pub fn specs() -> Vec { - vec![ - // ---------- Blend ---------- - cmd!( - "object.blend.make", - "Make", - ["Object", "Blend"], - Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", - has_doc, - blend_make - ), - cmd!( - "object.blend.release", - "Release", - ["Object", "Blend"], - Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", - has_blend, - blend_release - ), - cmd!( - "object.blend.options", - "Blend Options…", - ["Object", "Blend"], - None, - "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}", - always, - blend_options - ), - cmd!( - "object.blend.expand", - "Expand", - ["Object", "Blend"], - None, - "{} replace the selected blends by groups of their keys and steps, each keeping the blend's id, name, transparency (knockout and isolation included), opacity mask and appearance → {ids}", - has_blend, - blend_expand - ), - cmd!( - "object.blend.replaceSpine", - "Replace Spine", - ["Object", "Blend"], - None, - "{} use the selected path as the spine of the selected blend (the path is consumed)", - has_blend, - blend_replace_spine - ), - cmd!( - "object.blend.reverseSpine", - "Reverse Spine", - ["Object", "Blend"], - None, - "{} reverse the order of the key objects along the spine", - has_blend, - blend_reverse_spine - ), - cmd!( - query "object.blend.info", - "Blend Info", - [], - None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", - always, - blend_info - ), - cmd!( - "object.blend.spine.moveAnchor", - "Move Spine Point", - [], - None, - "{id? (default: the selected blend), anchor: index, x, y, handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on the point moves with it. The first edit turns the straight spine into a path", - has_doc, - spine_move_anchor - ), - cmd!( - "object.blend.spine.convertAnchor", - "Convert Anchor Point", - [], - None, - "{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points", - has_doc, - spine_convert_anchor - ), - cmd!( - "object.blend.spine.reshapeSegment", - "Reshape Spine Segment", - [], - None, - "{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)", - has_doc, - spine_reshape_segment - ), - cmd!( - "object.blend.spine.addAnchor", - "Add Spine Point", - [], - None, - "{id? (default: the selected blend), x, y} add a point to the blend's spine where it passes nearest (x, y) → {anchor}", - has_doc, - spine_add_anchor - ), - cmd!( - "object.blend.spine.removeAnchor", - "Delete Spine Point", - [], - None, - "{id? (default: the selected blend), anchor: index} delete a point of the blend's spine that no key object sits on", - has_doc, - spine_remove_anchor - ), - cmd!( - "object.blend.reverseFrontToBack", - "Reverse Front to Back", - ["Object", "Blend"], - None, - "{} reverse the stacking order of the selected blends", - has_blend, - blend_reverse_stack - ), - // ---------- Envelope ---------- - cmd!( - "object.envelope.makeWithWarp", - "Make with Warp…", - ["Object", "Envelope Distort"], - Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}", - has_selection, - env_make_warp - ), - cmd!( - "object.envelope.makeWithMesh", - "Make with Mesh…", - ["Object", "Envelope Distort"], - Some("Cmd+Alt+M"), - "{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}", - has_selection, - env_make_mesh - ), - cmd!( - "object.envelope.makeWithTopObject", - "Make with Top Object", - ["Object", "Envelope Distort"], - Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others → {id}", - has_multi, - env_make_top - ), - cmd!( - "object.envelope.resetWithWarp", - "Reset with Warp…", - ["Object", "Envelope Distort"], - None, - "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content", - has_envelope, - env_reset_warp - ), - cmd!( - "object.envelope.resetWithMesh", - "Reset with Mesh…", - ["Object", "Envelope Distort"], - None, - "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content", - has_envelope, - env_reset_mesh - ), - cmd!( - query "object.envelope.info", - "Envelope Info", - [], - None, - "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}", - has_doc, - env_info - ), - cmd!( - "object.envelope.release", - "Release", - ["Object", "Envelope Distort"], - None, - "{} release the selected envelopes into their content and their shape (a grey gradient mesh for warp and mesh envelopes, the path for a top-object envelope; the content keeps the envelope's opacity, blend mode and opacity mask) → {ids}", - has_envelope, - env_release - ), - cmd!( - "object.envelope.options", - "Envelope Options…", - ["Object", "Envelope Distort"], - None, - "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", - has_doc, - env_options - ), - cmd!( - "object.envelope.expand", - "Expand", - ["Object", "Envelope Distort"], - None, - "{} replace the selected envelopes by groups of their distorted content (type outlined), keeping name, opacity, blend mode, isolation, knockout and opacity mask → {ids}", - has_envelope, - env_expand - ), - cmd!( - "object.envelope.editContents", - "Edit Contents", - ["Object", "Envelope Distort"], - None, - "{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}", - has_envelope, - env_edit_contents - ), - cmd!( - "object.envelope.setMeshPoint", - "Move Envelope Mesh Point", - [], - None, - "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too", - has_doc, - env_set_mesh_point - ), - // ---------- Gradient mesh ---------- - cmd!( - "object.mesh.create", - "Create Gradient Mesh…", - ["Object"], - None, - "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}", - has_selection, - mesh_create - ), - cmd!( - "object.mesh.setPointColor", - "Set Mesh Point Color", - [], - None, - "{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point", - has_doc, - mesh_set_color - ), - cmd!( - "object.mesh.movePoint", - "Move Mesh Point", - [], - None, - "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)", - has_doc, - mesh_move_point - ), - cmd!( - "object.mesh.addLine", - "Add Mesh Line", - [], - None, - "{id, x, y, color?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}", - has_doc, - mesh_add_line - ), - cmd!( - "object.mesh.deletePoint", - "Delete Mesh Point", - [], - None, - "{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope", - has_doc, - mesh_delete_point - ), - cmd!( - "object.mesh.expand", - "Expand Gradient Mesh", - ["Object"], - None, - "{} replace the selected meshes by groups of flat-coloured pieces → {ids}", - has_mesh, - mesh_expand - ), - ] -} - -fn ids_json(ids: &[NodeId]) -> Value { - json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) -} - -/// Explicit `ids` (in paint order) or the selected roots. -fn roots_param(s: &Session, p: &Value) -> Result> { - if let Some(ids) = ids_param(p, "ids") { - let st = s.doc()?; - for id in &ids { - if st.doc.node(*id).is_none() { - return Err(EngineError::NoNode(*id)); - } - } - let mut sel = Selection::default(); - sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))); - let ordered = sel.in_paint_order(&st.doc); - // Drop ids nested inside other given ids. - return Ok(ordered - .iter() - .copied() - .filter(|id| { - let anc = st.doc.ancestry(*id).unwrap_or_default(); - !anc[..anc.len().saturating_sub(1)].iter().any(|a| ordered.contains(a)) - }) - .collect()); - } - selected_roots(s) -} - -/// Selected nodes of a kind (the selection itself or an ancestor). -fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { - let Some(st) = s.active() else { return vec![] }; - let mut out = vec![]; - for id in &st.selection.objects { - for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { - if st.doc.node(a).is_some_and(pred) { - if !out.contains(&a) { - out.push(a); - } - break; - } - } - } - out -} - -fn is_blend(n: &Node) -> bool { - matches!(n.kind, NodeKind::Blend { .. }) -} -fn is_envelope(n: &Node) -> bool { - matches!(n.kind, NodeKind::Envelope { .. }) -} -fn is_mesh(n: &Node) -> bool { - matches!(n.kind, NodeKind::Mesh(_)) -} - -fn has_blend(s: &Session) -> std::result::Result<(), String> { - has_selection(s)?; - if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } -} -fn has_envelope(s: &Session) -> std::result::Result<(), String> { - has_selection(s)?; - if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } -} -fn has_mesh(s: &Session) -> std::result::Result<(), String> { - has_selection(s)?; - if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } -} - -/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was. -fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec>) -> Node) -> Result { - if roots.is_empty() { - return Err(EngineError::Other("nothing to wrap".into())); - } - let id = d.alloc_id(); - d.wrap(roots, |nodes| make(id, nodes))?; - sel.set([id]); - Ok(id) -} - -/// Put `nodes` where `id` was (replacing it). Returns their ids. -fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result> { - let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; - d.remove(id)?; - let mut out = vec![]; - for (k, n) in nodes.into_iter().enumerate() { - out.push(n.id); - d.insert(par, idx + k, n)?; - } - Ok(out) -} - -/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids. -pub(super) fn fix_ids(d: &mut Document, n: &mut Node) { - if n.id == NodeId(0) { - n.id = d.alloc_id(); - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - fix_ids(d, Arc::make_mut(c)); - } - } -} - -// ---------- Blend ---------- - -/// The Specified Steps and Specified Distance a spacing mode starts with. -const DEFAULT_STEPS: u32 = 5; -const DEFAULT_DISTANCE: f64 = 10.0; - -fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { - check_numbers(p, &["value", "steps", "distance"], cmd)?; - let smooth = switch_or(p, "smooth", false, cmd)?; - let num = |k: &str| p.get(k).and_then(Value::as_f64); - let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32)); - let mode = str_param(p, "spacing"); - let value = num("value"); - let sp = match mode { - Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, - Some("steps") => match value { - Some(v) => steps("value", v)?, - None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?, - }, - Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)), - Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))), - None => { - if let Some(n) = num("steps") { - steps("steps", n)? - } else if let Some(d) = num("distance") { - BlendSpacing::Distance(d) - } else if smooth { - BlendSpacing::SmoothColor - } else { - current - } - } - }; - match sp { - BlendSpacing::Steps(n) if !(1..=1000).contains(&n) => Err(bad(cmd, "steps must be 1..1000")), - BlendSpacing::Distance(d) if !(d.is_finite() && d > 0.0) => Err(bad(cmd, "distance must be > 0")), - s => Ok(s), - } -} - -fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation { - match str_param(p, "orientation") { - Some("path") | Some("alignToPath") => BlendOrientation::AlignToPath, - Some("page") | Some("alignToPage") => BlendOrientation::AlignToPage, - _ => current, - } -} - -/// Blend panel Type and Ramp, validated before any document mutation. -fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { - let kind = match p.get("easingType").filter(|v| !v.is_null()) { - None => current.kind, - Some(v) => match v.as_str() { - Some("easeIn") => BlendEaseType::In, - Some("easeOut") => BlendEaseType::Out, - Some("easeInOut") => BlendEaseType::InOut, - Some("easeOutIn") => BlendEaseType::OutIn, - _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), - }, - }; - Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) -} - -/// Bounded percentage input; missing or null preserves the current value. -fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { - match p.get(key).filter(|v| !v.is_null()) { - None => Ok(current), - Some(v) => v - .as_f64() - .filter(|n| n.is_finite() && (min..=100.0).contains(n)) - .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), - } -} - -/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. -fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { - const C: &str = "object.blend.make"; - let Some(v) = p.get("starts").filter(|v| !v.is_null()) else { return Ok(vec![]) }; - let list = v.as_array().ok_or_else(|| bad(C, "`starts` must be an array of anchor indices (or nulls)"))?; - let owners = ids_param(p, "ids").unwrap_or_else(|| roots.to_vec()); - let mut out = vec![]; - for (id, a) in owners.iter().zip(list) { - if a.is_null() { - continue; - } - let i = a.as_u64().and_then(|i| u32::try_from(i).ok()).ok_or_else(|| bad(C, "a start must be an anchor index ≥ 0 or null"))?; - out.push((*id, i)); - } - Ok(out) -} - -/// The first blend among `roots`. -fn first_blend(d: &Document, roots: &[NodeId]) -> Option { - roots.iter().filter_map(|r| d.node(*r)).find(|n| is_blend(n)).cloned() -} - -/// Key objects of a blend made of `nodes` (paint order) and their start points: a blend among -/// them gives its keys (with theirs), the other objects are keys themselves. -fn merge_keys(nodes: Vec>, starts: &[(NodeId, u32)]) -> (Vec>, Vec>) { - let (mut keys, mut st) = (vec![], vec![]); - for n in nodes { - match &n.kind { - NodeKind::Blend { children, spec } => { - st.extend((0..children.len()).map(|i| spec.starts.get(i).copied().flatten())); - keys.extend(children.iter().cloned()); - } - _ => { - st.push(starts.iter().find(|(id, _)| *id == n.id).map(|(_, a)| *a)); - keys.push(n); - } - } - } - if st.iter().all(Option::is_none) { - st.clear(); - } - (keys, st) -} - -/// The spine of a blend of `keys` made by giving blend `old` (spec `old_spec`) more keys: its -/// spine pinned, with a straight segment to each new key before or after its own (a closed spine -/// gives way to the straight lines between the keys). -fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, keys: &[Arc]) { - if old_spec.spine.is_none() { - return; - } - let Some((mut path, anchors)) = live::pin_spine(old, old_spec) else { return }; - let Some(first) = old.first().and_then(|f| keys.iter().position(|k| k.id == f.id)) else { return }; - let Some(sp) = path.subpaths.first_mut().filter(|sp| !sp.closed) else { return }; - let center = |k: &Arc| vectorcraft_geom::Anchor::corner(k.geometric_bounds().map(|b| b.center()).unwrap_or_default()); - let before: Vec<_> = keys.iter().take(first).map(center).collect(); - let after: Vec<_> = keys.iter().skip(first + old.len()).map(center).collect(); - let shift = before.len(); - sp.anchors.splice(0..0, before); - let n = sp.anchors.len(); - sp.anchors.extend(after); - let mut ka: Vec = (0..shift).collect(); - ka.extend(anchors.iter().map(|a| a + shift)); - ka.extend(n..sp.anchors.len()); - spec.key_anchors = ka.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); - spec.spine = Some(path); -} - -fn blend_make(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.make"; - let roots = roots_param(s, p)?; - if roots.len() < 2 { - return Err(bad(C, "select at least two objects")); - } - let starts = starts_param(p, &roots)?; - let base = first_blend(&s.doc()?.doc, &roots); - let cur = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, - _ => s.prefs.blend_options.unwrap_or_default(), - }; - let spacing = spacing_param(p, C, cur.spacing)?; - let orientation = orientation_param(p, cur.orientation); - let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), - }; - let ease = ease_param(p, C, ease0)?; - let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; - let id = s.edit("Make Blend", |d, sel| { - wrap(d, sel, &roots, |id, nodes| { - let (children, starts) = merge_keys(nodes, &starts); - // A blend taking more keys keeps its name, transparency and spine; a new one is a - // knockout group, so translucent steps don't show through each other. - let mut n = base.unwrap_or_else(|| { - let mut n = Node::new(id, NodeKind::Group { children: vec![], clip: false }); - n.knockout = vectorcraft_doc::Knockout::On; - n - }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; - if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { - extend_spine(&mut spec, old, old_spec, &children); - } - n.id = id; - n.kind = NodeKind::Blend { children, spec: Box::new(spec) }; - n - }) - })?; - Ok(json!({ "id": id.0 })) -} - -fn blend_release(s: &mut Session, _: &Value) -> Result { - let blends = selected_of(s, is_blend); - let (ids, spines, groups) = s.edit("Release Blend", |d, sel| { - let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]); - let mut released = Beside::replacing(d, &blends); - for b in &blends { - let Some(n) = d.node(*b).cloned() else { continue }; - let NodeKind::Blend { children, spec } = &n.kind else { continue }; - // The spine stays behind as a path that paints nothing, below the keys. - let mut nodes = vec![]; - if let Some((path, _)) = live::blend_spine(children, spec) { - let id = d.alloc_id(); - spines.push(id); - nodes.push(Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))); - } - keys.extend(children.iter().map(|c| c.id)); - nodes.extend(children.iter().map(|c| (**c).clone())); - // What the blend itself carries stays on a group around what it gave back. - if n.name.is_some() - || n.opacity < 1.0 - || n.blend != vectorcraft_color::BlendMode::Normal - || n.isolate - || n.extras().mask.is_some() - || !n.appearance.items.is_empty() - || !n.appearance.effects.is_empty() - { - let mut g = n.clone(); - g.kind = NodeKind::Group { children: nodes.into_iter().map(Arc::new).collect(), clip: false }; - groups.push(g.id); - nodes = vec![g]; - } - released.put(d, *b, nodes)?; - } - released.finish(d)?; - if groups.is_empty() { - sel.set(spines.iter().chain(&keys).copied()); - } else { - sel.set(groups.iter().copied()); - } - Ok((keys, spines, groups)) - })?; - let mut r = ids_json(&ids); - r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); - r["groups"] = json!(groups.iter().map(|i| i.0).collect::>()); - Ok(r) -} - -/// The blend `id` (default: the first selected), its spine pinned to its keys (see -/// [`live::pin_spine`]) and given to `f` with the key anchors and the keys; the result is stored -/// as the blend's spine, one undo step. -fn edit_spine( - s: &mut Session, - p: &Value, - cmd: &str, - label: &str, - f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, -) -> Result { - let id = match id_param(p, "id") { - Some(id) => id, - None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?, - }; - let c = cmd.to_string(); - s.edit(label, move |d, _| { - let Some(NodeKind::Blend { children, spec }) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(bad(&c, "not a blend")) }; - let (mut path, mut anchors) = live::pin_spine(children, spec).ok_or_else(|| bad(&c, "the blend has no spine"))?; - let sp = path.subpaths.first_mut().ok_or_else(|| bad(&c, "the blend has no spine"))?; - let out = f(sp, &mut anchors, children)?; - spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); - spec.spine = Some(path); - Ok(out) - }) -} - -/// A point parameter that must be finite. -fn finite_point(p: &Value, cmd: &str) -> Result { - let q = Point::new(f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?); - if q.x.is_finite() && q.y.is_finite() && q.x.abs() < 1e9 && q.y.abs() < 1e9 { Ok(q) } else { Err(bad(cmd, "x and y must be finite")) } -} - -fn spine_move_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.moveAnchor"; - let index = index_param(p, "anchor", C)?; - let q = finite_point(p, C)?; - let handle = match str_param(p, "handle") { - None => None, - Some("out") => Some(true), - Some("in") => Some(false), - Some(_) => return Err(bad(C, "handle must be in or out")), - }; - let independent = switch_or(p, "independent", false, C)?; - edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| { - let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; - if let Some(out) = handle { - a.set_handle(out, q, independent); - return Ok(()); - } - let delta = q - a.p; - a.translate(delta); - // The key objects on the point move with it. - for (k, _) in anchors.iter().enumerate().filter(|(_, a)| **a == index) { - if let Some(key) = keys.get_mut(k) { - Arc::make_mut(key).transform(Affine::translate(delta), false); - } - } - Ok(()) - })?; - ok() -} - -fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.convertAnchor"; - let index = index_param(p, "anchor", C)?; - let smooth = match str_param(p, "to") { - Some("smooth") => true, - Some("corner") => false, - _ => return Err(bad(C, "to must be smooth or corner")), - }; - let target = match (p.get("x"), p.get("y")) { - (None, None) => None, - (Some(_), Some(_)) => Some(finite_point(p, C)?), - _ => return Err(bad(C, "x and y must be given together")), - }; - edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| { - if smooth && target.is_none() { - if index >= sp.anchors.len() { - return Err(bad(C, "no such spine point")); - } - vectorcraft_tools::draw2::curvature_smooth(sp, index); - return Ok(()); - } - let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; - match target { - Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q), - _ => a.retract(), - } - Ok(()) - })?; - ok() -} - -fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.reshapeSegment"; - let seg = index_param(p, "segment", C)?; - let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); - let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); - if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) { - return Err(bad(C, "t, dx and dy must be finite")); - } - let perpendicular = switch_or(p, "perpendicular", false, C)?; - edit_spine(s, p, C, "Reshape Spine", |sp, _, _| { - let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; - if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) } - })?; - ok() -} - -fn spine_add_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.addAnchor"; - let q = finite_point(p, C)?; - let index = edit_spine(s, p, C, "Add Spine Point", |sp, anchors, _| { - let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q).ok_or_else(|| bad(C, "the spine has no segments"))?; - let i = sp.insert_anchor(seg, t.clamp(1e-3, 1.0 - 1e-3)); - for a in anchors.iter_mut().filter(|a| **a >= i) { - *a += 1; - } - Ok(i) - })?; - Ok(json!({ "anchor": index })) -} - -fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.removeAnchor"; - let index = index_param(p, "anchor", C)?; - edit_spine(s, p, C, "Delete Spine Point", |sp, anchors, _| { - if index >= sp.anchors.len() { - return Err(bad(C, "no such spine point")); - } - if anchors.contains(&index) { - return Err(bad(C, "a key object sits on that point")); - } - if sp.anchors.len() <= 2 { - return Err(bad(C, "a spine keeps at least two points")); - } - sp.anchors.remove(index); - for a in anchors.iter_mut().filter(|a| **a > index) { - *a -= 1; - } - Ok(()) - })?; - ok() -} - -fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mut BlendSpec)) -> Result { - let blends = selected_of(s, is_blend); - s.edit(label, |d, _| { - for b in &blends { - if let Some(NodeKind::Blend { children, spec }) = d.node_mut(*b).map(|n| &mut n.kind) { - f(children, spec); - } - } - Ok(()) - })?; - ok() -} - -/// The first selected blend: its id, spec and key ids. -fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { - let b = *selected_of(s, is_blend).first()?; - match &s.doc().ok()?.doc.node(b)?.kind { - NodeKind::Blend { spec, children } => Some((b, (**spec).clone(), children.iter().map(|c| c.id).collect())), - _ => None, - } -} - -fn blend_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.options"; - let Some((_, cur, _)) = selected_spec(s) else { - if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { - return Err(bad(C, "select a blend to set easing or color shift")); - } - // Nothing to set them on: the options new blends start with (easing is per blend). - let d = s.prefs.blend_options.unwrap_or_default(); - let spacing = spacing_param(p, C, d.spacing)?; - s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); - return Ok(json!({ "defaults": true })); - }; - let spacing = spacing_param(p, C, cur.spacing)?; - let orientation = orientation_param(p, cur.orientation); - let ease = ease_param(p, C, cur.ease)?; - let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; - edit_blends(s, "Blend Options", |_, spec| { - spec.spacing = spacing; - spec.orientation = orientation; - spec.ease = ease; - spec.color_shift = color_shift; - }) -} - -/// Spacing and orientation as `object.blend.options` takes them; the step count and distance -/// not in use show their defaults. -fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { - let (mode, steps, distance) = match spacing { - BlendSpacing::SmoothColor => ("smooth", DEFAULT_STEPS, DEFAULT_DISTANCE), - BlendSpacing::Steps(n) => ("steps", n, DEFAULT_DISTANCE), - BlendSpacing::Distance(d) => ("distance", DEFAULT_STEPS, d), - }; - let orientation = if orientation == BlendOrientation::AlignToPath { "path" } else { "page" }; - json!({ "spacing": mode, "steps": steps, "distance": distance, "orientation": orientation }) -} - -fn blend_info(s: &mut Session, _: &Value) -> Result { - Ok(match selected_spec(s) { - Some((id, spec, keys)) => { - let mut v = blend_options_json(spec.spacing, spec.orientation); - v["target"] = json!("blend"); - v["id"] = json!(id.0); - v["easingType"] = json!(spec.ease.kind.id()); - v["ramp"] = json!(spec.ease.ramp * 100.0); - v["colorShift"] = json!(spec.color_shift); - v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); - v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); - if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) - && let Some((path, anchors)) = live::blend_spine(children, &spec) - && let Some(sp) = path.subpaths.first() - { - let pt = |p: Point| json!([p.x, p.y]); - let points: Vec = sp.anchors.iter().map(|a| json!({"x": a.p.x, "y": a.p.y, "in": pt(a.h_in), "out": pt(a.h_out)})).collect(); - v["spine"] = json!({"anchors": points, "closed": sp.closed, "keyAnchors": anchors, "explicit": spec.spine.is_some()}); - } - v - } - None => { - let d = s.prefs.blend_options.unwrap_or_default(); - let mut v = blend_options_json(d.spacing, d.orientation); - v["target"] = json!("defaults"); - v - } - }) -} - -fn blend_expand(s: &mut Session, _: &Value) -> Result { - let blends = selected_of(s, is_blend); - let ids = s.edit("Expand Blend", |d, sel| { - for b in &blends { - expand_blend(d, *b)?; - } - sel.set(blends.iter().copied()); - Ok(blends.clone()) - })?; - Ok(ids_json(&ids)) -} - -/// Replace blend `id` by a group of its keys and steps in place, keeping everything about the -/// blend itself: id, name, visibility, lock, transparency (knockout and isolation included), -/// opacity mask and appearance. -fn expand_blend(d: &mut Document, id: NodeId) -> Result<()> { - let Some(n) = d.node(id).filter(|n| is_blend(n)).cloned() else { return Ok(()) }; - let mut g = n.clone(); - g.kind = NodeKind::Group { children: vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(), clip: false }; - fix_ids(d, &mut g); - *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; - Ok(()) -} - -/// Object › Expand: every blend in the subtrees of `roots` (outer ones first) becomes a group of -/// its steps ([`expand_blend`]). Returns how many. -pub(crate) fn expand_blends(d: &mut Document, roots: &[NodeId]) -> Result { - let mut blends = vec![]; - for r in roots { - if let Some(n) = d.node(*r) { - n.walk(&mut |c| { - if is_blend(c) { - blends.push(c.id); - } - }); - } - } - for b in &blends { - expand_blend(d, *b)?; - } - Ok(blends.len()) -} - -/// Move the keys onto their spine positions (so what's stored matches what's drawn): onto their -/// anchors, or spread evenly by arc length along a spine from Replace Spine. -fn keys_to_spine(children: &mut [Arc], spec: &BlendSpec) { - let Some(path) = &spec.spine else { return }; - let k = children.len(); - let pinned = spec.key_anchors.len() == k; - let sp = Spine::new(path); - for (i, c) in children.iter_mut().enumerate() { - let pt = if pinned { - spec.key_anchors.get(i).and_then(|a| path.subpaths.first()?.anchors.get(*a as usize)).map(|a| a.p) - } else { - sp.as_ref().map(|sp| sp.at(if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }).0) - }; - if let (Some(pt), Some(b)) = (pt, c.geometric_bounds()) { - Arc::make_mut(c).transform(Affine::translate(pt - b.center()), false); - } - } -} - -/// Store the blend's spine pinned to its keys (see [`live::pin_spine`]); false when it has none. -fn pin(children: &[Arc], spec: &mut BlendSpec) -> bool { - match live::pin_spine(children, spec) { - Some((path, anchors)) => { - spec.spine = Some(path); - spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); - true - } - None => false, - } -} - -/// `spec`'s spine reversed: the key anchors count from the other end. -fn reverse_spine(spec: &mut BlendSpec) { - if let Some(sp) = &mut spec.spine { - sp.reverse(); - let n = sp.subpaths.first().map_or(0, |s| s.anchors.len() as u32); - for a in &mut spec.key_anchors { - *a = n.saturating_sub(1).saturating_sub(*a); - } - } -} - -fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { - let blends = selected_of(s, is_blend); - let st = s.doc()?; - let path = st - .selection - .objects - .iter() - .copied() - .find(|id| { - !blends.contains(id) - && st.doc.node(*id).is_some_and(|n| n.path_data().is_some()) - && !blends.iter().any(|b| st.doc.ancestry(*id).unwrap_or_default().contains(b)) - }) - .ok_or_else(|| EngineError::Other("Replace Spine: select a blend and a path".into()))?; - let Some(&b) = blends.first() else { return Err(EngineError::Other("Replace Spine: select a blend and a path".into())) }; - s.edit("Replace Spine", |d, sel| { - let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; - d.remove(path)?; - if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { - spec.spine = Some(spine); - spec.key_anchors.clear(); - keys_to_spine(children, spec); - } - sel.set([b]); - Ok(()) - })?; - ok() -} - -fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { - edit_blends(s, "Reverse Spine", |children, spec| { - if spec.spine.is_some() && pin(children, spec) { - // The keys keep their anchors, which now count from the other end. - if let Some(sp) = &mut spec.spine { - sp.reverse(); - } - keys_to_spine(children, spec); - } else { - let centers: Vec = children.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); - let n = children.len(); - for (i, k) in children.iter_mut().enumerate() { - Arc::make_mut(k).transform(Affine::translate(centers[n - 1 - i] - centers[i]), false); - } - } - }) -} - -fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { - edit_blends(s, "Reverse Front to Back", |children, spec| { - children.reverse(); - // Each key keeps its start point. - if !spec.starts.is_empty() { - spec.starts.resize(children.len(), None); - spec.starts.reverse(); - } - // Each key keeps its place: on its anchor, or (spread evenly) with the spine reversed. - spec.key_anchors.reverse(); - reverse_spine(spec); - }) -} - -// ---------- Envelope ---------- - -/// The number keys of a warp: `bend`, and the two distortions by their short and long names. -const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"]; - -fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { - check_numbers(p, WARP_NUMBERS, cmd)?; - let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); - if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { - (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); - } - if let Some(s) = str_param(p, "style") { - let s = s.strip_prefix("warp.").unwrap_or(s); - if live::WarpStyle::from_id(s).is_none() { - return Err(bad(cmd, format!("unknown warp style `{s}`"))); - } - style = s.to_string(); - } - bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0); - h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0); - v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0); - let given = switch_or(p, "horizontal", horizontal, cmd)?; - horizontal = match str_param(p, "orientation") { - Some("vertical") => false, - Some("horizontal") => true, - _ => given, - }; - Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) -} - -/// The options and fidelity new envelopes get (Envelope Options with no envelope selected). -fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { - s.envelope_defaults.unwrap_or((EnvelopeOptions::NEW, live::default_fidelity())) -} - -fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { - Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) -} - -/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`. -fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> { - check_numbers(p, &["rows", "cols", "columns"], cmd)?; - let rows = f64_or(p, "rows", default.0 as f64); - let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" }; - let cols = f64_or(p, cols_key, default.1 as f64); - if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { - return Err(bad(cmd, "rows and cols must be 1..50")); - } - Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32)) -} - -fn env_make_warp(s: &mut Session, p: &Value) -> Result { - let kind = warp_kind(p, "object.envelope.makeWithWarp", None)?; - let roots = roots_param(s, p)?; - if roots.is_empty() { - return Err(EngineError::Other("nothing selected".into())); - } - let defaults = envelope_defaults(s); - let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; - Ok(json!({ "id": id.0 })) -} - -/// A mesh envelope of `rows`×`cols` patches for an envelope with map `map`: following its -/// current surface (`maintain`), or a flat grid over its content. -fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind { - if !maintain { - return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] }; - } - // The surface's points with handles along it: the grid lines keep the current shape. - let m = map.surface_mesh(rows, cols, Color::BLACK); - EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() } -} - -fn env_make_mesh(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.makeWithMesh"; - let (rows, cols) = rows_cols(p, C, (4, 4))?; - let roots = roots_param(s, p)?; - let st = s.doc()?; - let nodes: Vec> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); - let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; - let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; - let defaults = envelope_defaults(s); - let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; - Ok(json!({ "id": id.0 })) -} - -/// Path data of a path or compound path. -fn outline_of(n: &Node) -> Option { - match &n.kind { - NodeKind::Path { path, .. } => Some(path.clone()), - NodeKind::Compound { children, .. } => { - Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect())) - } - _ => None, - } -} - -fn env_make_top(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.makeWithTopObject"; - let roots = roots_param(s, p)?; - if roots.len() < 2 { - return Err(bad(C, "select the content and a path on top")); - } - let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) }; - let content = content.to_vec(); - let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; - let defaults = envelope_defaults(s); - let id = s.edit("Make Envelope", |d, sel| { - let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?; - d.remove(top)?; - Ok(id) - })?; - Ok(json!({ "id": id.0 })) -} - -/// The kind `f` makes of each selected envelope (from the envelope and its current kind). -fn reshaped(s: &Session, f: impl Fn(&Node, &EnvelopeKind) -> Result) -> Result> { - let doc = &s.doc()?.doc; - let mut out = vec![]; - for e in selected_of(s, is_envelope) { - if let Some(n) = doc.node(e) - && let NodeKind::Envelope { kind, .. } = &n.kind - { - out.push((e, f(n, kind)?)); - } - } - Ok(out) -} - -/// Give each envelope of `kinds` its new kind (as one undo step `label`). -fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) -> Result { - s.edit(label, |d, _| { - for (e, k) in kinds { - if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - **kind = k; - } - } - Ok(()) - })?; - ok() -} - -fn env_reset_warp(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.resetWithWarp"; - let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?; - set_kinds(s, "Reset with Warp", kinds) -} - -fn env_reset_mesh(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.resetWithMesh"; - let maintain = switch_or(p, "maintainShape", true, C)?; - let kinds = reshaped(s, |env, kind| { - let own = match kind { - EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols), - _ => (4, 4), - }; - let (rows, cols) = rows_cols(p, C, own)?; - let map = EnvelopeMap::of(env).ok_or_else(|| bad(C, "the envelope's content has no bounds"))?; - Ok(mesh_kind(&map, maintain, rows, cols)) - })?; - set_kinds(s, "Reset with Mesh", kinds) -} - -/// `v` with an envelope's options and fidelity added. -fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value { - v["fidelity"] = json!(fidelity); - v["antiAlias"] = json!(o.anti_alias); - v["preserveShape"] = json!(o.preserve_shape.id()); - v["distortAppearance"] = json!(o.distort_appearance); - v["distortLinearGradients"] = json!(o.distort_linear_gradients); - v["distortPatternFills"] = json!(o.distort_pattern_fills); - v -} - -/// `cur` with the options `p` sets. -fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result { - let preserve_shape = match str_param(p, "preserveShape") { - Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?, - None => cur.preserve_shape, - }; - Ok(EnvelopeOptions { - anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?, - preserve_shape, - distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?, - distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?, - distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?, - }) -} - -fn env_info(s: &mut Session, _: &Value) -> Result { - let doc = &s.doc()?.doc; - let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind))); - let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else { - let (options, fidelity) = envelope_defaults(s); - return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); - }; - let v = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { - json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) - } - EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), - EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), - }; - let mut v = envelope_options_json(v, options, *fidelity); - v["id"] = json!(id.0); - v["editing"] = json!(editing); - Ok(v) -} - -/// What Release gives back as the shape of envelope `env` (of `kind`): a top object's path, or the -/// surface of a warp or mesh envelope as a gradient mesh, painted grey (id 0). -fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option { - let grey = Color::gray(0.25); - let (rows, cols) = match kind { - EnvelopeKind::TopObject { path } => { - return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0))); - } - // The mesh itself, handles and all. - EnvelopeKind::Mesh { rows, cols, points, handles } => { - let mut m = live::envelope_grid(*rows, *cols, points, handles)?; - m.points.iter_mut().for_each(|q| q.color = grey); - return Some(Node::new(NodeId(0), NodeKind::Mesh(m))); - } - EnvelopeKind::Warp { .. } => (4, 4), - }; - EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey)))) -} - -/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity -/// mask: a single object without its own takes them, else a new group around the content does. -fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec) -> Vec { - if env.has_default_transparency() { - return nodes; - } - let carry = |n: &mut Node| { - n.opacity = env.opacity; - n.blend = env.blend; - n.isolate = env.isolate; - n.knockout = env.knockout; - n.knockout_shape = env.knockout_shape; - n.extras_mut().mask = env.extras().mask.clone(); - }; - if let [one] = nodes.as_mut_slice() - && one.has_default_transparency() - { - carry(one); - return nodes; - } - let mut g = Node::group(d.alloc_id(), nodes.into_iter().map(Arc::new).collect()); - carry(&mut g); - vec![g] -} - -fn env_release(s: &mut Session, _: &Value) -> Result { - let envs = selected_of(s, is_envelope); - let ids = s.edit("Release Envelope", |d, sel| { - let mut out = vec![]; - let mut released = Beside::replacing(d, &envs); - for e in &envs { - let Some(n) = d.node(*e).cloned() else { continue }; - let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue }; - let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect()); - if let Some(mut shape) = envelope_shape(&n, kind) { - shape.id = d.alloc_id(); - nodes.push(shape); - } - out.extend(nodes.iter().map(|c| c.id)); - released.put(d, *e, nodes)?; - } - released.finish(d)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(ids_json(&ids)) -} - -fn env_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.options"; - check_numbers(p, &["fidelity"], C)?; - check_numbers(p, WARP_NUMBERS, C)?; - check_switches(p, &["horizontal"], C)?; - let fid = p.get("fidelity").and_then(Value::as_f64); - if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { - return Err(bad(C, "fidelity must be 0..100")); - } - let envs = selected_of(s, is_envelope); - if envs.is_empty() { - // No envelope selected: the options new envelopes get. - let (o, f) = envelope_defaults(s); - s.envelope_defaults = Some((options_param(p, C, o)?, fid.unwrap_or(f))); - return ok(); - } - // Validate everything up front. - let doc = &s.doc()?.doc; - let mut updates = vec![]; - for e in &envs { - if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { - let kind = match &**kind { - k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, - k => k.clone(), - }; - updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity))); - } - } - s.edit("Envelope Options", |d, _| { - for (e, k, o, f) in updates { - if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - (**kind, *options, *fidelity) = (k, o, f); - } - } - Ok(()) - })?; - ok() -} - -/// Replace envelope `id` by a group of its distorted content (type outlined) with its id, name, -/// transparency and opacity mask; the generated pieces get ids of their own. -fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; - let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n); - fix_ids(d, &mut g); - // Outlined type's emoji take their pixels into the document. - d.adopt_pixels(&mut g); - // Outlined type repeats its object's id on its pieces. - vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default()); - *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; - Ok(()) -} - -/// Object → Expand: every envelope in `root`'s subtree (itself included) expanded -/// ([`expand_envelope`]; envelopes inside envelopes go with them). Returns how many. -pub(super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result { - fn collect(n: &Node, out: &mut Vec) { - if is_envelope(n) { - out.push(n.id); - } else { - for c in n.children().into_iter().flatten() { - collect(c, out); - } - } - } - let mut envs = vec![]; - if let Some(n) = d.node(root) { - collect(n, &mut envs); - } - for e in &envs { - expand_envelope(d, *e)?; - } - Ok(envs.len()) -} - -fn env_expand(s: &mut Session, _: &Value) -> Result { - let envs = selected_of(s, is_envelope); - let ids = s.edit("Expand Envelope", |d, sel| { - for e in &envs { - expand_envelope(d, *e)?; - } - sel.set(envs.iter().copied()); - Ok(envs.clone()) - })?; - Ok(ids_json(&ids)) -} - -fn env_edit_contents(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.editContents"; - let envs = selected_of(s, is_envelope); - let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. })); - let want = switch_or(p, "editing", !cur, C)?; - s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| { - let mut content_ids = vec![]; - for e in &envs { - if let Some(n) = d.node_mut(*e) - && let NodeKind::Envelope { editing, content, .. } = &mut n.kind - { - *editing = want; - content_ids.extend(content.iter().map(|c| c.id)); - } - } - if want { - sel.set(content_ids); - } else { - sel.set(envs.iter().copied()); - } - Ok(()) - })?; - Ok(json!({ "editing": want })) -} - -fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; - let index = index_param(p, "index", C)?; - let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let handle = handle_param(p, C)?; - s.edit("Move Envelope Point", |d, _| { - let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { - return Err(bad(C, "not a mesh envelope point")); - }; - let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(C, "not a mesh envelope point")); - }; - let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; - match handle { - // The handles are offsets: they follow. - None => points.get_mut(index).into_iter().for_each(|p| *p = q), - Some(h) => { - // The first handle edited fills in the smooth mesh's handles. - if handles.len() != points.len() { - *handles = live::smooth_handles(*rows, *cols, points); - } - if let Some(hs) = handles.get_mut(index) { - hs[h] = q - at; - } - } - } - Ok(()) - })?; - ok() -} - -// ---------- Gradient mesh ---------- - -fn mesh_create(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.mesh.create"; - let (rows, cols) = rows_cols(p, C, (4, 4))?; - let app = match str_param(p, "appearance") { - Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, - None => MeshAppearance::Flat, - }; - check_numbers(p, &["highlight"], C)?; - let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); - let at = match p.get("at") { - None | Some(Value::Null) => None, - Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), - }; - let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) }; - let roots = roots_param(s, p)?; - let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { - let mut out = vec![]; - let mut index = None; - let mut refused = None; - let model = d.color_mode.model(); - for id in &roots { - let Some(n) = d.node(*id).cloned() else { continue }; - let m = if let NodeKind::Image(im) = &n.kind { - // An embedded image: the points take the picture's colours there. - match image_mesh(d, im, rows, cols) { - Ok(mut m) => { - m.highlight(app, highlight); - Some(m) - } - Err(why) => { - refused.get_or_insert(why); - continue; - } - } - } else { - let Some(path) = outline_of(&n) else { continue }; - match n.appearance.fill_paint() { - // The points take the colours the gradient paints there. - Paint::Gradient(g) => { - path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| { - m.highlight(app, highlight); - m - }) - } - p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), - } - }; - let Some(mut m) = m else { continue }; - if let Some(q) = at { - index = m.add_lines_at(q); - } - let mut mn = Node::new(n.id, NodeKind::Mesh(m)); - mn.name = n.name.clone(); - mn.opacity = n.opacity; - mn.blend = n.blend; - mn.locked = n.locked; - mn.visible = n.visible; - *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = mn; - out.push(*id); - } - if out.is_empty() { - return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image"))); - } - sel.set(out.iter().copied()); - Ok((out, index)) - })?; - let mut r = ids_json(&ids); - if let Some(i) = index { - r["index"] = json!(i); - } - Ok(r) -} - -/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle -/// (placed with the image's transform), each point taking the average colour and opacity of the -/// area around it. Linked images are refused: the documentation allows embedded ones only. -fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result { - if im.link.is_some() { - return Err("a linked image can't become a gradient mesh: embed it first"); - } - let xf = im.xf.as_coeffs(); - if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) { - return Err("the image has no usable size or position"); - } - let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?; - let (w, h) = (im.width as f64, im.height as f64); - let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h); - if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) { - return Err("the image has no usable size or position"); - } - let cols_n = cols as usize + 1; - let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::>(); - Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| { - let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]); - (Color::rgb8(pr, pg, pb), pa as f32 / 255.0) - })) -} - -/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh -/// envelope's grid too (its points and handles are written back: editing fills in the handles). -fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; - let c = cmd.to_string(); - s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { - Some(NodeKind::Mesh(m)) => f(m), - Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { - let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(&c, "not a gradient mesh or mesh envelope")); - }; - let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; - let r = f(&mut m)?; - (*rows, *cols) = (m.rows, m.cols); - *points = m.points.iter().map(|q| q.p).collect(); - *handles = m.points.iter().map(|q| q.handles).collect(); - Ok(r) - } - Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), - None => Err(EngineError::NoNode(id)), - }) -} - -/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any. -fn handle_param(p: &Value, cmd: &str) -> Result> { - match p.get("handle") { - None | Some(Value::Null) => Ok(None), - Some(v) => match v.as_u64() { - Some(h) if h <= 3 => Ok(Some(h as usize)), - _ => Err(bad(cmd, "handle must be 0..3")), - }, - } -} - -fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - let i = f64_req(p, key, cmd)?; - if i < 0.0 { - return Err(bad(cmd, "index must be ≥ 0")); - } - Ok(whole(i, key, cmd)? as usize) -} - -/// `x`, the value of `key`, when it is a whole number, and an error otherwise. -fn whole(x: f64, key: &str, cmd: &str) -> Result { - if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) } -} - -fn mesh_set_color(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.mesh.setPointColor"; - check_numbers(p, &["opacity"], C)?; - super::paint::check_keep_model(p, C)?; - let index = index_param(p, "index", C)?; - let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c)); - let opacity = p.get("opacity").and_then(Value::as_f64); - if color.is_none() && opacity.is_none() { - return Err(bad(C, "missing `color` or `opacity`")); - } - with_mesh(s, p, C, "Mesh Point Color", false, |m| { - let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; - if let Some(c) = color { - pt.color = c; - } - if let Some(o) = opacity { - pt.opacity = o.clamp(0.0, 1.0) as f32; - } - Ok(()) - })?; - ok() -} - -fn mesh_move_point(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.mesh.movePoint"; - let index = index_param(p, "index", C)?; - let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let handle = handle_param(p, C)?; - with_mesh(s, p, C, "Move Mesh Point", true, |m| { - let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; - match handle { - Some(h) => pt.handles[h] = q - pt.p, - None => pt.p = q, - } - Ok(()) - })?; - ok() -} - -fn mesh_add_line(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.mesh.addLine"; - let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - super::paint::check_keep_model(p, C)?; - let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c)); - let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| { - let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?; - if let Some(c) = color { - m.points[i].color = c; - } - Ok(i) - })?; - Ok(json!({ "index": index })) -} - -fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.mesh.deletePoint"; - let index = index_param(p, "index", C)?; - with_mesh(s, p, C, "Delete Mesh Point", true, |m| { - if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) } - })?; - ok() -} - -fn mesh_expand(s: &mut Session, _: &Value) -> Result { - let meshes = selected_of(s, is_mesh); - let ids = s.edit("Expand Gradient Mesh", |d, sel| { - let mut out = vec![]; - for m in &meshes { - let Some(n) = d.node(*m).cloned() else { continue }; - let mut g = live::expanded_group(&n, None); - fix_ids(d, &mut g); - out.extend(replace_with(d, *m, vec![g])?); - } - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(ids_json(&ids)) -} +mod blend; +mod commands; +mod common; +mod envelope; +mod mesh; + +pub(crate) use blend::expand_blends; +pub use commands::specs; +pub(super) use common::fix_ids; +pub(super) use envelope::expand_envelopes_under; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs new file mode 100644 index 000000000..1ab018a8f --- /dev/null +++ b/crates/engine/src/cmd/live/blend.rs @@ -0,0 +1,525 @@ +use super::common::{ + DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, + spacing_param, starts_param, wrap, +}; +use super::mesh::index_param; +use super::*; + +/// The first blend among `roots`. +fn first_blend(d: &Document, roots: &[NodeId]) -> Option { + roots.iter().filter_map(|r| d.node(*r)).find(|n| is_blend(n)).cloned() +} + +/// Key objects of a blend made of `nodes` (paint order) and their start points: a blend among +/// them gives its keys (with theirs), the other objects are keys themselves. +fn merge_keys(nodes: Vec>, starts: &[(NodeId, u32)]) -> (Vec>, Vec>) { + let (mut keys, mut st) = (vec![], vec![]); + for n in nodes { + match &n.kind { + NodeKind::Blend { children, spec } => { + st.extend((0..children.len()).map(|i| spec.starts.get(i).copied().flatten())); + keys.extend(children.iter().cloned()); + } + _ => { + st.push(starts.iter().find(|(id, _)| *id == n.id).map(|(_, a)| *a)); + keys.push(n); + } + } + } + if st.iter().all(Option::is_none) { + st.clear(); + } + (keys, st) +} + +/// The spine of a blend of `keys` made by giving blend `old` (spec `old_spec`) more keys: its +/// spine pinned, with a straight segment to each new key before or after its own (a closed spine +/// gives way to the straight lines between the keys). +fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, keys: &[Arc]) { + if old_spec.spine.is_none() { + return; + } + let Some((mut path, anchors)) = live::pin_spine(old, old_spec) else { return }; + let Some(first) = old.first().and_then(|f| keys.iter().position(|k| k.id == f.id)) else { return }; + let Some(sp) = path.subpaths.first_mut().filter(|sp| !sp.closed) else { return }; + let center = |k: &Arc| vectorcraft_geom::Anchor::corner(k.geometric_bounds().map(|b| b.center()).unwrap_or_default()); + let before: Vec<_> = keys.iter().take(first).map(center).collect(); + let after: Vec<_> = keys.iter().skip(first + old.len()).map(center).collect(); + let shift = before.len(); + sp.anchors.splice(0..0, before); + let n = sp.anchors.len(); + sp.anchors.extend(after); + let mut ka: Vec = (0..shift).collect(); + ka.extend(anchors.iter().map(|a| a + shift)); + ka.extend(n..sp.anchors.len()); + spec.key_anchors = ka.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); +} + +pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.make"; + let roots = roots_param(s, p)?; + if roots.len() < 2 { + return Err(bad(C, "select at least two objects")); + } + let starts = starts_param(p, &roots)?; + let base = first_blend(&s.doc()?.doc, &roots); + let cur = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, + _ => s.prefs.blend_options.unwrap_or_default(), + }; + let spacing = spacing_param(p, C, cur.spacing)?; + let orientation = orientation_param(p, cur.orientation); + let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), + _ => Default::default(), + }; + let ease = ease_param(p, C, ease0)?; + let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; + let id = s.edit("Make Blend", |d, sel| { + wrap(d, sel, &roots, |id, nodes| { + let (children, starts) = merge_keys(nodes, &starts); + // A blend taking more keys keeps its name, transparency and spine; a new one is a + // knockout group, so translucent steps don't show through each other. + let mut n = base.unwrap_or_else(|| { + let mut n = Node::new(id, NodeKind::Group { children: vec![], clip: false }); + n.knockout = vectorcraft_doc::Knockout::On; + n + }); + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; + if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { + extend_spine(&mut spec, old, old_spec, &children); + } + n.id = id; + n.kind = NodeKind::Blend { children, spec: Box::new(spec) }; + n + }) + })?; + Ok(json!({ "id": id.0 })) +} + +pub(super) fn blend_release(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let (ids, spines, groups) = s.edit("Release Blend", |d, sel| { + let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]); + let mut released = Beside::replacing(d, &blends); + for b in &blends { + let Some(n) = d.node(*b).cloned() else { continue }; + let NodeKind::Blend { children, spec } = &n.kind else { continue }; + // The spine stays behind as a path that paints nothing, below the keys. + let mut nodes = vec![]; + if let Some((path, _)) = live::blend_spine(children, spec) { + let id = d.alloc_id(); + spines.push(id); + nodes.push(Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))); + } + keys.extend(children.iter().map(|c| c.id)); + nodes.extend(children.iter().map(|c| (**c).clone())); + // What the blend itself carries stays on a group around what it gave back. + if n.name.is_some() + || n.opacity < 1.0 + || n.blend != vectorcraft_color::BlendMode::Normal + || n.isolate + || n.extras().mask.is_some() + || !n.appearance.items.is_empty() + || !n.appearance.effects.is_empty() + { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: nodes.into_iter().map(Arc::new).collect(), clip: false }; + groups.push(g.id); + nodes = vec![g]; + } + released.put(d, *b, nodes)?; + } + released.finish(d)?; + if groups.is_empty() { + sel.set(spines.iter().chain(&keys).copied()); + } else { + sel.set(groups.iter().copied()); + } + Ok((keys, spines, groups)) + })?; + let mut r = ids_json(&ids); + r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); + r["groups"] = json!(groups.iter().map(|i| i.0).collect::>()); + Ok(r) +} + +/// The blend `id` (default: the first selected), its spine pinned to its keys (see +/// [`live::pin_spine`]) and given to `f` with the key anchors and the keys; the result is stored +/// as the blend's spine, one undo step. +fn edit_spine( + s: &mut Session, + p: &Value, + cmd: &str, + label: &str, + f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, +) -> Result { + let id = match id_param(p, "id") { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?, + }; + let c = cmd.to_string(); + s.edit(label, move |d, _| { + let Some(NodeKind::Blend { children, spec }) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(bad(&c, "not a blend")) }; + let (mut path, mut anchors) = live::pin_spine(children, spec).ok_or_else(|| bad(&c, "the blend has no spine"))?; + let sp = path.subpaths.first_mut().ok_or_else(|| bad(&c, "the blend has no spine"))?; + let out = f(sp, &mut anchors, children)?; + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); + Ok(out) + }) +} + +/// A point parameter that must be finite. +fn finite_point(p: &Value, cmd: &str) -> Result { + let q = Point::new(f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?); + if q.x.is_finite() && q.y.is_finite() && q.x.abs() < 1e9 && q.y.abs() < 1e9 { Ok(q) } else { Err(bad(cmd, "x and y must be finite")) } +} + +pub(super) fn spine_move_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.moveAnchor"; + let index = index_param(p, "anchor", C)?; + let q = finite_point(p, C)?; + let handle = match str_param(p, "handle") { + None => None, + Some("out") => Some(true), + Some("in") => Some(false), + Some(_) => return Err(bad(C, "handle must be in or out")), + }; + let independent = switch_or(p, "independent", false, C)?; + edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| { + let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; + if let Some(out) = handle { + a.set_handle(out, q, independent); + return Ok(()); + } + let delta = q - a.p; + a.translate(delta); + // The key objects on the point move with it. + for (k, _) in anchors.iter().enumerate().filter(|(_, a)| **a == index) { + if let Some(key) = keys.get_mut(k) { + Arc::make_mut(key).transform(Affine::translate(delta), false); + } + } + Ok(()) + })?; + ok() +} + +pub(super) fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.convertAnchor"; + let index = index_param(p, "anchor", C)?; + let smooth = match str_param(p, "to") { + Some("smooth") => true, + Some("corner") => false, + _ => return Err(bad(C, "to must be smooth or corner")), + }; + let target = match (p.get("x"), p.get("y")) { + (None, None) => None, + (Some(_), Some(_)) => Some(finite_point(p, C)?), + _ => return Err(bad(C, "x and y must be given together")), + }; + edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| { + if smooth && target.is_none() { + if index >= sp.anchors.len() { + return Err(bad(C, "no such spine point")); + } + vectorcraft_tools::draw2::curvature_smooth(sp, index); + return Ok(()); + } + let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; + match target { + Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q), + _ => a.retract(), + } + Ok(()) + })?; + ok() +} + +pub(super) fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.reshapeSegment"; + let seg = index_param(p, "segment", C)?; + let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); + let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); + if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) { + return Err(bad(C, "t, dx and dy must be finite")); + } + let perpendicular = switch_or(p, "perpendicular", false, C)?; + edit_spine(s, p, C, "Reshape Spine", |sp, _, _| { + let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; + if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) } + })?; + ok() +} + +pub(super) fn spine_add_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.addAnchor"; + let q = finite_point(p, C)?; + let index = edit_spine(s, p, C, "Add Spine Point", |sp, anchors, _| { + let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q).ok_or_else(|| bad(C, "the spine has no segments"))?; + let i = sp.insert_anchor(seg, t.clamp(1e-3, 1.0 - 1e-3)); + for a in anchors.iter_mut().filter(|a| **a >= i) { + *a += 1; + } + Ok(i) + })?; + Ok(json!({ "anchor": index })) +} + +pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.removeAnchor"; + let index = index_param(p, "anchor", C)?; + edit_spine(s, p, C, "Delete Spine Point", |sp, anchors, _| { + if index >= sp.anchors.len() { + return Err(bad(C, "no such spine point")); + } + if anchors.contains(&index) { + return Err(bad(C, "a key object sits on that point")); + } + if sp.anchors.len() <= 2 { + return Err(bad(C, "a spine keeps at least two points")); + } + sp.anchors.remove(index); + for a in anchors.iter_mut().filter(|a| **a > index) { + *a -= 1; + } + Ok(()) + })?; + ok() +} + +fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mut BlendSpec)) -> Result { + let blends = selected_of(s, is_blend); + s.edit(label, |d, _| { + for b in &blends { + if let Some(NodeKind::Blend { children, spec }) = d.node_mut(*b).map(|n| &mut n.kind) { + f(children, spec); + } + } + Ok(()) + })?; + ok() +} + +/// The first selected blend: its id, spec and key ids. +fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { + let b = *selected_of(s, is_blend).first()?; + match &s.doc().ok()?.doc.node(b)?.kind { + NodeKind::Blend { spec, children } => Some((b, (**spec).clone(), children.iter().map(|c| c.id).collect())), + _ => None, + } +} + +pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.options"; + let Some((_, cur, _)) = selected_spec(s) else { + if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { + return Err(bad(C, "select a blend to set easing or color shift")); + } + // Nothing to set them on: the options new blends start with (easing is per blend). + let d = s.prefs.blend_options.unwrap_or_default(); + let spacing = spacing_param(p, C, d.spacing)?; + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + return Ok(json!({ "defaults": true })); + }; + let spacing = spacing_param(p, C, cur.spacing)?; + let orientation = orientation_param(p, cur.orientation); + let ease = ease_param(p, C, cur.ease)?; + let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; + edit_blends(s, "Blend Options", |_, spec| { + spec.spacing = spacing; + spec.orientation = orientation; + spec.ease = ease; + spec.color_shift = color_shift; + }) +} + +/// Spacing and orientation as `object.blend.options` takes them; the step count and distance +/// not in use show their defaults. +fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { + let (mode, steps, distance) = match spacing { + BlendSpacing::SmoothColor => ("smooth", DEFAULT_STEPS, DEFAULT_DISTANCE), + BlendSpacing::Steps(n) => ("steps", n, DEFAULT_DISTANCE), + BlendSpacing::Distance(d) => ("distance", DEFAULT_STEPS, d), + }; + let orientation = if orientation == BlendOrientation::AlignToPath { "path" } else { "page" }; + json!({ "spacing": mode, "steps": steps, "distance": distance, "orientation": orientation }) +} + +pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { + Ok(match selected_spec(s) { + Some((id, spec, keys)) => { + let mut v = blend_options_json(spec.spacing, spec.orientation); + v["target"] = json!("blend"); + v["id"] = json!(id.0); + v["easingType"] = json!(spec.ease.kind.id()); + v["ramp"] = json!(spec.ease.ramp * 100.0); + v["colorShift"] = json!(spec.color_shift); + v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); + v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) + && let Some((path, anchors)) = live::blend_spine(children, &spec) + && let Some(sp) = path.subpaths.first() + { + let pt = |p: Point| json!([p.x, p.y]); + let points: Vec = sp.anchors.iter().map(|a| json!({"x": a.p.x, "y": a.p.y, "in": pt(a.h_in), "out": pt(a.h_out)})).collect(); + v["spine"] = json!({"anchors": points, "closed": sp.closed, "keyAnchors": anchors, "explicit": spec.spine.is_some()}); + } + v + } + None => { + let d = s.prefs.blend_options.unwrap_or_default(); + let mut v = blend_options_json(d.spacing, d.orientation); + v["target"] = json!("defaults"); + v + } + }) +} + +pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let ids = s.edit("Expand Blend", |d, sel| { + for b in &blends { + expand_blend(d, *b)?; + } + sel.set(blends.iter().copied()); + Ok(blends.clone()) + })?; + Ok(ids_json(&ids)) +} + +/// Replace blend `id` by a group of its keys and steps in place, keeping everything about the +/// blend itself: id, name, visibility, lock, transparency (knockout and isolation included), +/// opacity mask and appearance. +fn expand_blend(d: &mut Document, id: NodeId) -> Result<()> { + let Some(n) = d.node(id).filter(|n| is_blend(n)).cloned() else { return Ok(()) }; + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(), clip: false }; + fix_ids(d, &mut g); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object › Expand: every blend in the subtrees of `roots` (outer ones first) becomes a group of +/// its steps ([`expand_blend`]). Returns how many. +pub(crate) fn expand_blends(d: &mut Document, roots: &[NodeId]) -> Result { + let mut blends = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if is_blend(c) { + blends.push(c.id); + } + }); + } + } + for b in &blends { + expand_blend(d, *b)?; + } + Ok(blends.len()) +} + +/// Move the keys onto their spine positions (so what's stored matches what's drawn): onto their +/// anchors, or spread evenly by arc length along a spine from Replace Spine. +fn keys_to_spine(children: &mut [Arc], spec: &BlendSpec) { + let Some(path) = &spec.spine else { return }; + let k = children.len(); + let pinned = spec.key_anchors.len() == k; + let sp = Spine::new(path); + for (i, c) in children.iter_mut().enumerate() { + let pt = if pinned { + spec.key_anchors.get(i).and_then(|a| path.subpaths.first()?.anchors.get(*a as usize)).map(|a| a.p) + } else { + sp.as_ref().map(|sp| sp.at(if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }).0) + }; + if let (Some(pt), Some(b)) = (pt, c.geometric_bounds()) { + Arc::make_mut(c).transform(Affine::translate(pt - b.center()), false); + } + } +} + +/// Store the blend's spine pinned to its keys (see [`live::pin_spine`]); false when it has none. +fn pin(children: &[Arc], spec: &mut BlendSpec) -> bool { + match live::pin_spine(children, spec) { + Some((path, anchors)) => { + spec.spine = Some(path); + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + true + } + None => false, + } +} + +/// `spec`'s spine reversed: the key anchors count from the other end. +fn reverse_spine(spec: &mut BlendSpec) { + if let Some(sp) = &mut spec.spine { + sp.reverse(); + let n = sp.subpaths.first().map_or(0, |s| s.anchors.len() as u32); + for a in &mut spec.key_anchors { + *a = n.saturating_sub(1).saturating_sub(*a); + } + } +} + +pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let st = s.doc()?; + let path = st + .selection + .objects + .iter() + .copied() + .find(|id| { + !blends.contains(id) + && st.doc.node(*id).is_some_and(|n| n.path_data().is_some()) + && !blends.iter().any(|b| st.doc.ancestry(*id).unwrap_or_default().contains(b)) + }) + .ok_or_else(|| EngineError::Other("Replace Spine: select a blend and a path".into()))?; + let Some(&b) = blends.first() else { return Err(EngineError::Other("Replace Spine: select a blend and a path".into())) }; + s.edit("Replace Spine", |d, sel| { + let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; + d.remove(path)?; + if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + spec.spine = Some(spine); + spec.key_anchors.clear(); + keys_to_spine(children, spec); + } + sel.set([b]); + Ok(()) + })?; + ok() +} + +pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + edit_blends(s, "Reverse Spine", |children, spec| { + if spec.spine.is_some() && pin(children, spec) { + // The keys keep their anchors, which now count from the other end. + if let Some(sp) = &mut spec.spine { + sp.reverse(); + } + keys_to_spine(children, spec); + } else { + let centers: Vec = children.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); + let n = children.len(); + for (i, k) in children.iter_mut().enumerate() { + Arc::make_mut(k).transform(Affine::translate(centers[n - 1 - i] - centers[i]), false); + } + } + }) +} + +pub(super) fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { + edit_blends(s, "Reverse Front to Back", |children, spec| { + children.reverse(); + // Each key keeps its start point. + if !spec.starts.is_empty() { + spec.starts.resize(children.len(), None); + spec.starts.reverse(); + } + // Each key keeps its place: on its anchor, or (spread evenly) with the spine reversed. + spec.key_anchors.reverse(); + reverse_spine(spec); + }) +} diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs new file mode 100644 index 000000000..d46e198e3 --- /dev/null +++ b/crates/engine/src/cmd/live/commands.rs @@ -0,0 +1,289 @@ +use super::blend::{ + blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, + spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, +}; +use super::common::{has_blend, has_envelope, has_mesh}; +use super::envelope::{ + env_edit_contents, env_expand, env_info, env_make_mesh, env_make_top, env_make_warp, env_options, env_release, env_reset_mesh, env_reset_warp, + env_set_mesh_point, +}; +use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_set_color}; +use super::*; + +pub fn specs() -> Vec { + vec![ + // ---------- Blend ---------- + cmd!( + "object.blend.make", + "Make", + ["Object", "Blend"], + Some("Cmd+Alt+B"), + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + has_doc, + blend_make + ), + cmd!( + "object.blend.release", + "Release", + ["Object", "Blend"], + Some("Cmd+Alt+Shift+B"), + "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", + has_blend, + blend_release + ), + cmd!( + "object.blend.options", + "Blend Options…", + ["Object", "Blend"], + None, + "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}", + always, + blend_options + ), + cmd!( + "object.blend.expand", + "Expand", + ["Object", "Blend"], + None, + "{} replace the selected blends by groups of their keys and steps, each keeping the blend's id, name, transparency (knockout and isolation included), opacity mask and appearance → {ids}", + has_blend, + blend_expand + ), + cmd!( + "object.blend.replaceSpine", + "Replace Spine", + ["Object", "Blend"], + None, + "{} use the selected path as the spine of the selected blend (the path is consumed)", + has_blend, + blend_replace_spine + ), + cmd!( + "object.blend.reverseSpine", + "Reverse Spine", + ["Object", "Blend"], + None, + "{} reverse the order of the key objects along the spine", + has_blend, + blend_reverse_spine + ), + cmd!( + query "object.blend.info", + "Blend Info", + [], + None, + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + always, + blend_info + ), + cmd!( + "object.blend.spine.moveAnchor", + "Move Spine Point", + [], + None, + "{id? (default: the selected blend), anchor: index, x, y, handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on the point moves with it. The first edit turns the straight spine into a path", + has_doc, + spine_move_anchor + ), + cmd!( + "object.blend.spine.convertAnchor", + "Convert Anchor Point", + [], + None, + "{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points", + has_doc, + spine_convert_anchor + ), + cmd!( + "object.blend.spine.reshapeSegment", + "Reshape Spine Segment", + [], + None, + "{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)", + has_doc, + spine_reshape_segment + ), + cmd!( + "object.blend.spine.addAnchor", + "Add Spine Point", + [], + None, + "{id? (default: the selected blend), x, y} add a point to the blend's spine where it passes nearest (x, y) → {anchor}", + has_doc, + spine_add_anchor + ), + cmd!( + "object.blend.spine.removeAnchor", + "Delete Spine Point", + [], + None, + "{id? (default: the selected blend), anchor: index} delete a point of the blend's spine that no key object sits on", + has_doc, + spine_remove_anchor + ), + cmd!( + "object.blend.reverseFrontToBack", + "Reverse Front to Back", + ["Object", "Blend"], + None, + "{} reverse the stacking order of the selected blends", + has_blend, + blend_reverse_stack + ), + // ---------- Envelope ---------- + cmd!( + "object.envelope.makeWithWarp", + "Make with Warp…", + ["Object", "Envelope Distort"], + Some("Cmd+Alt+Shift+W"), + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}", + has_selection, + env_make_warp + ), + cmd!( + "object.envelope.makeWithMesh", + "Make with Mesh…", + ["Object", "Envelope Distort"], + Some("Cmd+Alt+M"), + "{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}", + has_selection, + env_make_mesh + ), + cmd!( + "object.envelope.makeWithTopObject", + "Make with Top Object", + ["Object", "Envelope Distort"], + Some("Cmd+Alt+C"), + "{ids?} the topmost selected path becomes the envelope of the others → {id}", + has_multi, + env_make_top + ), + cmd!( + "object.envelope.resetWithWarp", + "Reset with Warp…", + ["Object", "Envelope Distort"], + None, + "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content", + has_envelope, + env_reset_warp + ), + cmd!( + "object.envelope.resetWithMesh", + "Reset with Mesh…", + ["Object", "Envelope Distort"], + None, + "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content", + has_envelope, + env_reset_mesh + ), + cmd!( + query "object.envelope.info", + "Envelope Info", + [], + None, + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}", + has_doc, + env_info + ), + cmd!( + "object.envelope.release", + "Release", + ["Object", "Envelope Distort"], + None, + "{} release the selected envelopes into their content and their shape (a grey gradient mesh for warp and mesh envelopes, the path for a top-object envelope; the content keeps the envelope's opacity, blend mode and opacity mask) → {ids}", + has_envelope, + env_release + ), + cmd!( + "object.envelope.options", + "Envelope Options…", + ["Object", "Envelope Distort"], + None, + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + has_doc, + env_options + ), + cmd!( + "object.envelope.expand", + "Expand", + ["Object", "Envelope Distort"], + None, + "{} replace the selected envelopes by groups of their distorted content (type outlined), keeping name, opacity, blend mode, isolation, knockout and opacity mask → {ids}", + has_envelope, + env_expand + ), + cmd!( + "object.envelope.editContents", + "Edit Contents", + ["Object", "Envelope Distort"], + None, + "{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}", + has_envelope, + env_edit_contents + ), + cmd!( + "object.envelope.setMeshPoint", + "Move Envelope Mesh Point", + [], + None, + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too", + has_doc, + env_set_mesh_point + ), + // ---------- Gradient mesh ---------- + cmd!( + "object.mesh.create", + "Create Gradient Mesh…", + ["Object"], + None, + "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}", + has_selection, + mesh_create + ), + cmd!( + "object.mesh.setPointColor", + "Set Mesh Point Color", + [], + None, + "{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point", + has_doc, + mesh_set_color + ), + cmd!( + "object.mesh.movePoint", + "Move Mesh Point", + [], + None, + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)", + has_doc, + mesh_move_point + ), + cmd!( + "object.mesh.addLine", + "Add Mesh Line", + [], + None, + "{id, x, y, color?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}", + has_doc, + mesh_add_line + ), + cmd!( + "object.mesh.deletePoint", + "Delete Mesh Point", + [], + None, + "{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope", + has_doc, + mesh_delete_point + ), + cmd!( + "object.mesh.expand", + "Expand Gradient Mesh", + ["Object"], + None, + "{} replace the selected meshes by groups of flat-coloured pieces → {ids}", + has_mesh, + mesh_expand + ), + ] +} diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs new file mode 100644 index 000000000..4237f48d3 --- /dev/null +++ b/crates/engine/src/cmd/live/common.rs @@ -0,0 +1,202 @@ +use super::mesh::whole; +use super::*; + +pub(super) fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Explicit `ids` (in paint order) or the selected roots. +pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { + if let Some(ids) = ids_param(p, "ids") { + let st = s.doc()?; + for id in &ids { + if st.doc.node(*id).is_none() { + return Err(EngineError::NoNode(*id)); + } + } + let mut sel = Selection::default(); + sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))); + let ordered = sel.in_paint_order(&st.doc); + // Drop ids nested inside other given ids. + return Ok(ordered + .iter() + .copied() + .filter(|id| { + let anc = st.doc.ancestry(*id).unwrap_or_default(); + !anc[..anc.len().saturating_sub(1)].iter().any(|a| ordered.contains(a)) + }) + .collect()); + } + selected_roots(s) +} + +/// Selected nodes of a kind (the selection itself or an ancestor). +pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let mut out = vec![]; + for id in &st.selection.objects { + for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { + if st.doc.node(a).is_some_and(pred) { + if !out.contains(&a) { + out.push(a); + } + break; + } + } + } + out +} + +pub(super) fn is_blend(n: &Node) -> bool { + matches!(n.kind, NodeKind::Blend { .. }) +} + +pub(super) fn is_envelope(n: &Node) -> bool { + matches!(n.kind, NodeKind::Envelope { .. }) +} + +pub(super) fn is_mesh(n: &Node) -> bool { + matches!(n.kind, NodeKind::Mesh(_)) +} + +pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } +} + +pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } +} + +pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } +} + +/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was. +pub(super) fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec>) -> Node) -> Result { + if roots.is_empty() { + return Err(EngineError::Other("nothing to wrap".into())); + } + let id = d.alloc_id(); + d.wrap(roots, |nodes| make(id, nodes))?; + sel.set([id]); + Ok(id) +} + +/// Put `nodes` where `id` was (replacing it). Returns their ids. +pub(super) fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result> { + let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + d.remove(id)?; + let mut out = vec![]; + for (k, n) in nodes.into_iter().enumerate() { + out.push(n.id); + d.insert(par, idx + k, n)?; + } + Ok(out) +} + +/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids. +pub(in super::super) fn fix_ids(d: &mut Document, n: &mut Node) { + if n.id == NodeId(0) { + n.id = d.alloc_id(); + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + fix_ids(d, Arc::make_mut(c)); + } + } +} + +// ---------- Blend ---------- + +/// The Specified Steps and Specified Distance a spacing mode starts with. +pub(super) const DEFAULT_STEPS: u32 = 5; + +pub(super) const DEFAULT_DISTANCE: f64 = 10.0; + +pub(super) fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { + check_numbers(p, &["value", "steps", "distance"], cmd)?; + let smooth = switch_or(p, "smooth", false, cmd)?; + let num = |k: &str| p.get(k).and_then(Value::as_f64); + let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32)); + let mode = str_param(p, "spacing"); + let value = num("value"); + let sp = match mode { + Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, + Some("steps") => match value { + Some(v) => steps("value", v)?, + None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?, + }, + Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)), + Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))), + None => { + if let Some(n) = num("steps") { + steps("steps", n)? + } else if let Some(d) = num("distance") { + BlendSpacing::Distance(d) + } else if smooth { + BlendSpacing::SmoothColor + } else { + current + } + } + }; + match sp { + BlendSpacing::Steps(n) if !(1..=1000).contains(&n) => Err(bad(cmd, "steps must be 1..1000")), + BlendSpacing::Distance(d) if !(d.is_finite() && d > 0.0) => Err(bad(cmd, "distance must be > 0")), + s => Ok(s), + } +} + +pub(super) fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation { + match str_param(p, "orientation") { + Some("path") | Some("alignToPath") => BlendOrientation::AlignToPath, + Some("page") | Some("alignToPage") => BlendOrientation::AlignToPage, + _ => current, + } +} + +/// Blend panel Type and Ramp, validated before any document mutation. +pub(super) fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { + let kind = match p.get("easingType").filter(|v| !v.is_null()) { + None => current.kind, + Some(v) => match v.as_str() { + Some("easeIn") => BlendEaseType::In, + Some("easeOut") => BlendEaseType::Out, + Some("easeInOut") => BlendEaseType::InOut, + Some("easeOutIn") => BlendEaseType::OutIn, + _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), + }, + }; + Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) +} + +/// Bounded percentage input; missing or null preserves the current value. +pub(super) fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (min..=100.0).contains(n)) + .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), + } +} + +/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. +pub(super) fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { + const C: &str = "object.blend.make"; + let Some(v) = p.get("starts").filter(|v| !v.is_null()) else { return Ok(vec![]) }; + let list = v.as_array().ok_or_else(|| bad(C, "`starts` must be an array of anchor indices (or nulls)"))?; + let owners = ids_param(p, "ids").unwrap_or_else(|| roots.to_vec()); + let mut out = vec![]; + for (id, a) in owners.iter().zip(list) { + if a.is_null() { + continue; + } + let i = a.as_u64().and_then(|i| u32::try_from(i).ok()).ok_or_else(|| bad(C, "a start must be an anchor index ≥ 0 or null"))?; + out.push((*id, i)); + } + Ok(out) +} diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs new file mode 100644 index 000000000..27e41509a --- /dev/null +++ b/crates/engine/src/cmd/live/envelope.rs @@ -0,0 +1,423 @@ +use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap}; +use super::mesh::{handle_param, index_param, whole}; +use super::*; + +// ---------- Envelope ---------- + +/// The number keys of a warp: `bend`, and the two distortions by their short and long names. +const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"]; + +fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { + check_numbers(p, WARP_NUMBERS, cmd)?; + let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); + if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { + (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); + } + if let Some(s) = str_param(p, "style") { + let s = s.strip_prefix("warp.").unwrap_or(s); + if live::WarpStyle::from_id(s).is_none() { + return Err(bad(cmd, format!("unknown warp style `{s}`"))); + } + style = s.to_string(); + } + bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0); + h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0); + v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0); + let given = switch_or(p, "horizontal", horizontal, cmd)?; + horizontal = match str_param(p, "orientation") { + Some("vertical") => false, + Some("horizontal") => true, + _ => given, + }; + Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) +} + +/// The options and fidelity new envelopes get (Envelope Options with no envelope selected). +fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { + s.envelope_defaults.unwrap_or((EnvelopeOptions::NEW, live::default_fidelity())) +} + +fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { + Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) +} + +/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`. +pub(super) fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> { + check_numbers(p, &["rows", "cols", "columns"], cmd)?; + let rows = f64_or(p, "rows", default.0 as f64); + let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" }; + let cols = f64_or(p, cols_key, default.1 as f64); + if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { + return Err(bad(cmd, "rows and cols must be 1..50")); + } + Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32)) +} + +pub(super) fn env_make_warp(s: &mut Session, p: &Value) -> Result { + let kind = warp_kind(p, "object.envelope.makeWithWarp", None)?; + let roots = roots_param(s, p)?; + if roots.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + let defaults = envelope_defaults(s); + let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; + Ok(json!({ "id": id.0 })) +} + +/// A mesh envelope of `rows`×`cols` patches for an envelope with map `map`: following its +/// current surface (`maintain`), or a flat grid over its content. +fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind { + if !maintain { + return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] }; + } + // The surface's points with handles along it: the grid lines keep the current shape. + let m = map.surface_mesh(rows, cols, Color::BLACK); + EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() } +} + +pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.makeWithMesh"; + let (rows, cols) = rows_cols(p, C, (4, 4))?; + let roots = roots_param(s, p)?; + let st = s.doc()?; + let nodes: Vec> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); + let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; + let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; + let defaults = envelope_defaults(s); + let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; + Ok(json!({ "id": id.0 })) +} + +/// Path data of a path or compound path. +pub(super) fn outline_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.clone()), + NodeKind::Compound { children, .. } => { + Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect())) + } + _ => None, + } +} + +pub(super) fn env_make_top(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.makeWithTopObject"; + let roots = roots_param(s, p)?; + if roots.len() < 2 { + return Err(bad(C, "select the content and a path on top")); + } + let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) }; + let content = content.to_vec(); + let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; + let defaults = envelope_defaults(s); + let id = s.edit("Make Envelope", |d, sel| { + let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?; + d.remove(top)?; + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The kind `f` makes of each selected envelope (from the envelope and its current kind). +fn reshaped(s: &Session, f: impl Fn(&Node, &EnvelopeKind) -> Result) -> Result> { + let doc = &s.doc()?.doc; + let mut out = vec![]; + for e in selected_of(s, is_envelope) { + if let Some(n) = doc.node(e) + && let NodeKind::Envelope { kind, .. } = &n.kind + { + out.push((e, f(n, kind)?)); + } + } + Ok(out) +} + +/// Give each envelope of `kinds` its new kind (as one undo step `label`). +fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) -> Result { + s.edit(label, |d, _| { + for (e, k) in kinds { + if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + **kind = k; + } + } + Ok(()) + })?; + ok() +} + +pub(super) fn env_reset_warp(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.resetWithWarp"; + let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?; + set_kinds(s, "Reset with Warp", kinds) +} + +pub(super) fn env_reset_mesh(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.resetWithMesh"; + let maintain = switch_or(p, "maintainShape", true, C)?; + let kinds = reshaped(s, |env, kind| { + let own = match kind { + EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols), + _ => (4, 4), + }; + let (rows, cols) = rows_cols(p, C, own)?; + let map = EnvelopeMap::of(env).ok_or_else(|| bad(C, "the envelope's content has no bounds"))?; + Ok(mesh_kind(&map, maintain, rows, cols)) + })?; + set_kinds(s, "Reset with Mesh", kinds) +} + +/// `v` with an envelope's options and fidelity added. +fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value { + v["fidelity"] = json!(fidelity); + v["antiAlias"] = json!(o.anti_alias); + v["preserveShape"] = json!(o.preserve_shape.id()); + v["distortAppearance"] = json!(o.distort_appearance); + v["distortLinearGradients"] = json!(o.distort_linear_gradients); + v["distortPatternFills"] = json!(o.distort_pattern_fills); + v +} + +/// `cur` with the options `p` sets. +fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result { + let preserve_shape = match str_param(p, "preserveShape") { + Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?, + None => cur.preserve_shape, + }; + Ok(EnvelopeOptions { + anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?, + preserve_shape, + distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?, + distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?, + distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?, + }) +} + +pub(super) fn env_info(s: &mut Session, _: &Value) -> Result { + let doc = &s.doc()?.doc; + let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind))); + let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else { + let (options, fidelity) = envelope_defaults(s); + return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); + }; + let v = match &**kind { + EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) + } + EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), + EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), + }; + let mut v = envelope_options_json(v, options, *fidelity); + v["id"] = json!(id.0); + v["editing"] = json!(editing); + Ok(v) +} + +/// What Release gives back as the shape of envelope `env` (of `kind`): a top object's path, or the +/// surface of a warp or mesh envelope as a gradient mesh, painted grey (id 0). +fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option { + let grey = Color::gray(0.25); + let (rows, cols) = match kind { + EnvelopeKind::TopObject { path } => { + return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0))); + } + // The mesh itself, handles and all. + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let mut m = live::envelope_grid(*rows, *cols, points, handles)?; + m.points.iter_mut().for_each(|q| q.color = grey); + return Some(Node::new(NodeId(0), NodeKind::Mesh(m))); + } + EnvelopeKind::Warp { .. } => (4, 4), + }; + EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey)))) +} + +/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity +/// mask: a single object without its own takes them, else a new group around the content does. +fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec) -> Vec { + if env.has_default_transparency() { + return nodes; + } + let carry = |n: &mut Node| { + n.opacity = env.opacity; + n.blend = env.blend; + n.isolate = env.isolate; + n.knockout = env.knockout; + n.knockout_shape = env.knockout_shape; + n.extras_mut().mask = env.extras().mask.clone(); + }; + if let [one] = nodes.as_mut_slice() + && one.has_default_transparency() + { + carry(one); + return nodes; + } + let mut g = Node::group(d.alloc_id(), nodes.into_iter().map(Arc::new).collect()); + carry(&mut g); + vec![g] +} + +pub(super) fn env_release(s: &mut Session, _: &Value) -> Result { + let envs = selected_of(s, is_envelope); + let ids = s.edit("Release Envelope", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &envs); + for e in &envs { + let Some(n) = d.node(*e).cloned() else { continue }; + let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue }; + let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect()); + if let Some(mut shape) = envelope_shape(&n, kind) { + shape.id = d.alloc_id(); + nodes.push(shape); + } + out.extend(nodes.iter().map(|c| c.id)); + released.put(d, *e, nodes)?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn env_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.options"; + check_numbers(p, &["fidelity"], C)?; + check_numbers(p, WARP_NUMBERS, C)?; + check_switches(p, &["horizontal"], C)?; + let fid = p.get("fidelity").and_then(Value::as_f64); + if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { + return Err(bad(C, "fidelity must be 0..100")); + } + let envs = selected_of(s, is_envelope); + if envs.is_empty() { + // No envelope selected: the options new envelopes get. + let (o, f) = envelope_defaults(s); + s.envelope_defaults = Some((options_param(p, C, o)?, fid.unwrap_or(f))); + return ok(); + } + // Validate everything up front. + let doc = &s.doc()?.doc; + let mut updates = vec![]; + for e in &envs { + if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { + let kind = match &**kind { + k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, + k => k.clone(), + }; + updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity))); + } + } + s.edit("Envelope Options", |d, _| { + for (e, k, o, f) in updates { + if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + (**kind, *options, *fidelity) = (k, o, f); + } + } + Ok(()) + })?; + ok() +} + +/// Replace envelope `id` by a group of its distorted content (type outlined) with its id, name, +/// transparency and opacity mask; the generated pieces get ids of their own. +fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n); + fix_ids(d, &mut g); + // Outlined type's emoji take their pixels into the document. + d.adopt_pixels(&mut g); + // Outlined type repeats its object's id on its pieces. + vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default()); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object → Expand: every envelope in `root`'s subtree (itself included) expanded +/// ([`expand_envelope`]; envelopes inside envelopes go with them). Returns how many. +pub(in super::super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result { + fn collect(n: &Node, out: &mut Vec) { + if is_envelope(n) { + out.push(n.id); + } else { + for c in n.children().into_iter().flatten() { + collect(c, out); + } + } + } + let mut envs = vec![]; + if let Some(n) = d.node(root) { + collect(n, &mut envs); + } + for e in &envs { + expand_envelope(d, *e)?; + } + Ok(envs.len()) +} + +pub(super) fn env_expand(s: &mut Session, _: &Value) -> Result { + let envs = selected_of(s, is_envelope); + let ids = s.edit("Expand Envelope", |d, sel| { + for e in &envs { + expand_envelope(d, *e)?; + } + sel.set(envs.iter().copied()); + Ok(envs.clone()) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.editContents"; + let envs = selected_of(s, is_envelope); + let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. })); + let want = switch_or(p, "editing", !cur, C)?; + s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| { + let mut content_ids = vec![]; + for e in &envs { + if let Some(n) = d.node_mut(*e) + && let NodeKind::Envelope { editing, content, .. } = &mut n.kind + { + *editing = want; + content_ids.extend(content.iter().map(|c| c.id)); + } + } + if want { + sel.set(content_ids); + } else { + sel.set(envs.iter().copied()); + } + Ok(()) + })?; + Ok(json!({ "editing": want })) +} + +pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.setMeshPoint"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let index = index_param(p, "index", C)?; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let handle = handle_param(p, C)?; + s.edit("Move Envelope Point", |d, _| { + let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + return Err(bad(C, "not a mesh envelope point")); + }; + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(C, "not a mesh envelope point")); + }; + let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; + match handle { + // The handles are offsets: they follow. + None => points.get_mut(index).into_iter().for_each(|p| *p = q), + Some(h) => { + // The first handle edited fills in the smooth mesh's handles. + if handles.len() != points.len() { + *handles = live::smooth_handles(*rows, *cols, points); + } + if let Some(hs) = handles.get_mut(index) { + hs[h] = q - at; + } + } + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs new file mode 100644 index 000000000..570ef6c92 --- /dev/null +++ b/crates/engine/src/cmd/live/mesh.rs @@ -0,0 +1,229 @@ +use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selected_of}; +use super::envelope::{outline_of, rows_cols}; +use super::*; + +// ---------- Gradient mesh ---------- + +pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.create"; + let (rows, cols) = rows_cols(p, C, (4, 4))?; + let app = match str_param(p, "appearance") { + Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, + None => MeshAppearance::Flat, + }; + check_numbers(p, &["highlight"], C)?; + let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), + }; + let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) }; + let roots = roots_param(s, p)?; + let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { + let mut out = vec![]; + let mut index = None; + let mut refused = None; + let model = d.color_mode.model(); + for id in &roots { + let Some(n) = d.node(*id).cloned() else { continue }; + let m = if let NodeKind::Image(im) = &n.kind { + // An embedded image: the points take the picture's colours there. + match image_mesh(d, im, rows, cols) { + Ok(mut m) => { + m.highlight(app, highlight); + Some(m) + } + Err(why) => { + refused.get_or_insert(why); + continue; + } + } + } else { + let Some(path) = outline_of(&n) else { continue }; + match n.appearance.fill_paint() { + // The points take the colours the gradient paints there. + Paint::Gradient(g) => { + path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| { + m.highlight(app, highlight); + m + }) + } + p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), + } + }; + let Some(mut m) = m else { continue }; + if let Some(q) = at { + index = m.add_lines_at(q); + } + let mut mn = Node::new(n.id, NodeKind::Mesh(m)); + mn.name = n.name.clone(); + mn.opacity = n.opacity; + mn.blend = n.blend; + mn.locked = n.locked; + mn.visible = n.visible; + *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = mn; + out.push(*id); + } + if out.is_empty() { + return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image"))); + } + sel.set(out.iter().copied()); + Ok((out, index)) + })?; + let mut r = ids_json(&ids); + if let Some(i) = index { + r["index"] = json!(i); + } + Ok(r) +} + +/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle +/// (placed with the image's transform), each point taking the average colour and opacity of the +/// area around it. Linked images are refused: the documentation allows embedded ones only. +fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result { + if im.link.is_some() { + return Err("a linked image can't become a gradient mesh: embed it first"); + } + let xf = im.xf.as_coeffs(); + if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) { + return Err("the image has no usable size or position"); + } + let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?; + let (w, h) = (im.width as f64, im.height as f64); + let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h); + if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) { + return Err("the image has no usable size or position"); + } + let cols_n = cols as usize + 1; + let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::>(); + Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| { + let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]); + (Color::rgb8(pr, pg, pb), pa as f32 / 255.0) + })) +} + +/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh +/// envelope's grid too (its points and handles are written back: editing fills in the handles). +fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let c = cmd.to_string(); + s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { + Some(NodeKind::Mesh(m)) => f(m), + Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(&c, "not a gradient mesh or mesh envelope")); + }; + let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; + let r = f(&mut m)?; + (*rows, *cols) = (m.rows, m.cols); + *points = m.points.iter().map(|q| q.p).collect(); + *handles = m.points.iter().map(|q| q.handles).collect(); + Ok(r) + } + Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), + None => Err(EngineError::NoNode(id)), + }) +} + +/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any. +pub(super) fn handle_param(p: &Value, cmd: &str) -> Result> { + match p.get("handle") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(h) if h <= 3 => Ok(Some(h as usize)), + _ => Err(bad(cmd, "handle must be 0..3")), + }, + } +} + +pub(super) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + let i = f64_req(p, key, cmd)?; + if i < 0.0 { + return Err(bad(cmd, "index must be ≥ 0")); + } + Ok(whole(i, key, cmd)? as usize) +} + +/// `x`, the value of `key`, when it is a whole number, and an error otherwise. +pub(super) fn whole(x: f64, key: &str, cmd: &str) -> Result { + if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) } +} + +pub(super) fn mesh_set_color(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.setPointColor"; + check_numbers(p, &["opacity"], C)?; + crate::cmd::paint::check_keep_model(p, C)?; + let index = index_param(p, "index", C)?; + let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); + let opacity = p.get("opacity").and_then(Value::as_f64); + if color.is_none() && opacity.is_none() { + return Err(bad(C, "missing `color` or `opacity`")); + } + with_mesh(s, p, C, "Mesh Point Color", false, |m| { + let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; + if let Some(c) = color { + pt.color = c; + } + if let Some(o) = opacity { + pt.opacity = o.clamp(0.0, 1.0) as f32; + } + Ok(()) + })?; + ok() +} + +pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.movePoint"; + let index = index_param(p, "index", C)?; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let handle = handle_param(p, C)?; + with_mesh(s, p, C, "Move Mesh Point", true, |m| { + let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; + match handle { + Some(h) => pt.handles[h] = q - pt.p, + None => pt.p = q, + } + Ok(()) + })?; + ok() +} + +pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.addLine"; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + crate::cmd::paint::check_keep_model(p, C)?; + let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); + let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| { + let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?; + if let Some(c) = color { + m.points[i].color = c; + } + Ok(i) + })?; + Ok(json!({ "index": index })) +} + +pub(super) fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.deletePoint"; + let index = index_param(p, "index", C)?; + with_mesh(s, p, C, "Delete Mesh Point", true, |m| { + if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) } + })?; + ok() +} + +pub(super) fn mesh_expand(s: &mut Session, _: &Value) -> Result { + let meshes = selected_of(s, is_mesh); + let ids = s.edit("Expand Gradient Mesh", |d, sel| { + let mut out = vec![]; + for m in &meshes { + let Some(n) = d.node(*m).cloned() else { continue }; + let mut g = live::expanded_group(&n, None); + fix_ids(d, &mut g); + out.extend(replace_with(d, *m, vec![g])?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c4cf27198..97e4b3c2d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -17,2530 +17,19 @@ use super::opacitymask::percent; use super::*; use crate::{EngineError, LastTransform}; -pub fn specs() -> Vec { - vec![ - cmd!( - "object.transform", - "Transform", - [], - None, - "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)", - has_doc, - transform - ), - cmd!( - "object.move", - "Move…", - ["Object", "Transform"], - Some("Cmd+Shift+M"), - "{ids?: [id…] (selected first; default: the selection), dx, dy, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", - has_selection_or_guides, - move_cmd - ), - cmd!( - "object.rotate", - "Rotate…", - ["Object", "Transform"], - None, - "{ids?: [id…] (selected first; default: the selection), angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", - has_selection, - rotate - ), - cmd!( - "object.scale", - "Scale…", - ["Object", "Transform"], - None, - "{ids?: [id…] (selected first; default: the selection), sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", - has_selection, - scale - ), - cmd!( - "object.reflect", - "Reflect…", - ["Object", "Transform"], - None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", - has_selection, - reflect - ), - cmd!( - "object.shear", - "Shear…", - ["Object", "Transform"], - None, - "{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", - has_selection, - shear - ), - cmd!( - "object.transformAgain", - "Transform Again", - ["Object", "Transform"], - Some("Cmd+D"), - "{copy?, strokes?, corners?, patterns?, typeAreas?: bool (as object.transform's; default: the value the transform it repeats used, else object.transform's default)} the last transform's matrix on the selection (after a perspective move or scale: that plane-space transform, without these params)", - has_selection, - transform_again - ), - cmd!( - "object.nudge", - "Nudge", - [], - None, - "{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, along the x and y axes turned by the constrainAngle preference; else ruler guides, along the page axes)", - has_selection_or_guides, - nudge - ), - cmd!( - "object.arrange.bringToFront", - "Bring to Front", - ["Object", "Arrange"], - Some("Cmd+Shift+]"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - |s, _| arrange(s, Arrange::Front) - ), - cmd!( - "object.arrange.bringForward", - "Bring Forward", - ["Object", "Arrange"], - Some("Cmd+]"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - |s, _| arrange(s, Arrange::Forward) - ), - cmd!( - "object.arrange.sendBackward", - "Send Backward", - ["Object", "Arrange"], - Some("Cmd+["), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - |s, _| arrange(s, Arrange::Backward) - ), - cmd!( - "object.arrange.sendToBack", - "Send to Back", - ["Object", "Arrange"], - Some("Cmd+Shift+["), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - |s, _| arrange(s, Arrange::Back) - ), - cmd!( - "object.arrange.sendToCurrentLayer", - "Send to Current Layer", - ["Object", "Arrange"], - None, - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - send_to_current_layer - ), - cmd!( - "object.group", - "Group", - ["Object"], - Some("Cmd+G"), - "{ids?: [id…] (selected first; default: the selection)} → {id}", - has_selection, - group - ), - cmd!( - "object.ungroup", - "Ungroup", - ["Object"], - Some("Cmd+Shift+G"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - ungroup - ), - cmd!( - "object.lock", - "Selection", - ["Object", "Lock"], - Some("Cmd+2"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - lock - ), - cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all), - cmd!( - "object.hide", - "Selection", - ["Object", "Hide"], - Some("Cmd+3"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - hide - ), - cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all), - cmd!( - "object.compoundPath.make", - "Make", - ["Object", "Compound Path"], - Some("Cmd+8"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection, - compound_make - ), - cmd!( - "object.compoundPath.release", - "Release", - ["Object", "Compound Path"], - Some("Cmd+Alt+Shift+8"), - "{ids?: [id…] (selected first; default: the selection)} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath", - has_selection, - compound_release - ), - cmd!( - "object.clippingMask.make", - "Make", - ["Object", "Clipping Mask"], - Some("Cmd+7"), - "{ids?: [id…] (selected first; default: the selection)} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn. Paint given to the clipping path later (paint commands with its id) shows: its fill behind the clipped art, its stroke over it → {id} of the clip group", - has_multi, - clip_make - ), - cmd!( - "object.clippingMask.release", - "Release", - ["Object", "Clipping Mask"], - Some("Cmd+Alt+7"), - "{ids?: [id…] (selected first; default: the selection)} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, with the paint it has (none unless painted after Make)", - has_selection, - clip_release - ), - cmd!( - "object.clippingMask.editContents", - "Edit Contents", - ["Object", "Clipping Mask"], - None, - "{} select the clipped art of the selected clip groups → {count}", - has_selection, - |s, _| clip_edit(s, false) - ), - cmd!( - "object.clippingMask.editMask", - "Edit Clipping Path", - ["Object", "Clipping Mask"], - None, - "{} select the clipping paths of the selected clip groups → {count}", - has_selection, - |s, _| clip_edit(s, true) - ), - cmd!( - "object.isolate", - "Enter Isolation Mode", - [], - None, - "{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused", - has_doc, - isolate - ), - cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation), - cmd!( - "object.setProps", - "Object Properties", - [], - None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", - has_doc, - set_props - ), - cmd!( - "object.align", - "Align", - ["Window", "Align"], - None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", - has_selection_or_guides, - align - ), - cmd!( - "object.distribute", - "Distribute", - ["Window", "Align"], - None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", - can_distribute, - distribute - ), - cmd!( - "object.distributeSpacing", - "Distribute Spacing", - ["Window", "Align"], - None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", - can_distribute, - distribute_spacing - ), - cmd!( - "object.setBounds", - "Set Bounds", - [], - None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", - has_selection, - set_bounds - ), - cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape), - cmd!( - "object.setLiveShape", - "Live Shape Properties", - [], - None, - "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", - has_selection, - set_live_shape - ), - ] -} - -fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { - if let Some(o) = point_param(p, "origin") { - return Ok(o); - } - // Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre. - let st = s.doc()?; - if !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) - { - return Ok(b.center()); - } - s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) -} - -/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from -/// its `strokes` and `corners` params, else the preferences. The values join the command's journal -/// entry, so replaying it scales the same way whatever the preferences are then. -pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling { - let sc = scaling_options(s, p); - s.note_journal("strokes", json!(sc.strokes)); - s.note_journal("corners", json!(!sc.keep_corners)); - sc -} - -/// [`scaling`] without noting the values in the journal. -fn scaling_options(s: &Session, p: &Value) -> Scaling { - let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); - let corners = bool_or(p, "corners", s.prefs.scale_corners); - Scaling { - strokes, - effects: strokes.then_some(vectorcraft_render::effects::scale_effect), - keep_type_strokes: !strokes, - keep_corners: !corners, - ..Scaling::default() - } -} - -/// Transform Patterns: do the pattern fills and strokes of `ids` transform with them (`patterns` -/// param, else General › Transform Pattern Tiles)? Noted in the journal when they use a pattern, -/// so a replay moves their tiles alike whatever the preference is then. -pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result { - let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles); - let d = &s.doc()?.doc; - let mut uses = false; - let paths = d.index_paths(ids.iter().copied()); - for n in ids.iter().filter_map(|id| paths.get(id)).filter_map(|p| d.node_at(p)) { - n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None)); - } - if uses { - s.note_journal("patterns", json!(on)); - } - Ok(on) -} - -/// Transform Objects (`objects` param, on unless it is false): off, a transform leaves the art -/// where it is and, with Transform Patterns, moves only its pattern tiles. -pub(crate) fn transforms_objects(p: &Value) -> bool { - bool_or(p, "objects", true) -} - -/// The on/off params of the transform commands that [`apply_transform`], [`scaling`], -/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file -/// check them with [`check_switches`] before they change anything. -const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; - -/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. -pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { - if objects { - n.transform(xf, sc); - } else if sc.patterns { - vectorcraft_doc::pattern::transform_patterns_only(n, xf); - } -} - -/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]; -/// `patterns`: see [`transform_patterns`]; `objects`: see [`transforms_objects`]; `typeAreas`: -/// see [`resize_type_area`]). Outside an -/// interaction, records `xf` and the options it used for Transform Again. In an interaction whose -/// document is still its snapshot, a transform with `copy` keeps its copies ([`preview_copies`]), -/// and the next one that copies the same objects of the same snapshot transforms those copies. -/// -/// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no -/// copy, only those anchors transform, their handles with them, and the objects selected whole. -pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value, anchors: bool) -> Result { - apply_transform_moving_guides(s, label, ids, xf, p, anchors, None) -} - -/// [`apply_transform`], with the selected ruler guides moved by `guides` instead of by the -/// translation `xf` when given (a nudge along a turned Constrain Angle moves the art along it and -/// the guides along the page's axes). -fn apply_transform_moving_guides( - s: &mut Session, - label: &str, - ids: Vec, - xf: Affine, - p: &Value, - anchors: bool, - guides: Option, -) -> Result { - let copy = bool_or(p, "copy", false); - let areas = bool_or(p, "typeAreas", false); - let objects = transforms_objects(p); - let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; - sc.patterns = transform_patterns(s, p, &ids)?; - let c = xf.as_coeffs(); - let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; - let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); - let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; - let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); - // A transform of ids that name no object, or an identity transform without a copy, moves - // nothing: the document and its undo history stay as they are, and Transform Again repeats it. - // With a copy, the reply lists the copies, and there are none. - let d = &s.doc()?.doc; - let none = d.index_paths(ids.iter().copied()).is_empty() && !has_guides; - let ids = if anchors && objects && !copy && !s.doc()?.selection.anchors.is_empty() { - if xf != Affine::IDENTITY { - super::path::transform_anchors(s, label, xf, sc)?; - } - ids - } else if none || (!copy && xf == Affine::IDENTITY) { - if copy { vec![] } else { ids } - } else { - let kept = if copy { preview_copies(s, &ids, with_guides)? } else { None }; - s.edit(label, |d, sel| { - let targets = match &kept { - // The document becomes the snapshot with the kept copies, untransformed, and the - // loop below transforms only the copies. - Some(k) => { - *d = Document::clone(&k.doc); - sel.clone_from(&k.selection); - k.ids.clone() - } - None if copy => { - let guide_copies = if with_guides { - let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); - (!copies.is_empty()).then_some(copies) - } else { - None - }; - let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; - if let Some(copies) = guide_copies { - let first = d.guides.len(); - d.guides.extend(copies); - sel.guides = (first..d.guides.len()).collect(); - } - new_ids - } - None => ids.clone(), - }; - // Transforms don't move nodes in the tree, so the paths found up front stay right. - let paths = d.index_paths(targets.iter().copied()); - for id in &targets { - if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) - && !(objects && areas && resize_type_area(n, xf)) - { - transform_node(n, xf, sc, objects); - } - } - if with_guides && (delta.x != 0.0 || delta.y != 0.0) { - for i in sel.guides.clone() { - if let Some(g) = d.guides.get_mut(i) { - *g = g.moved(delta); - } - } - } - Ok(targets) - })? - }; - let st = s.doc_mut()?; - if st.interaction.is_none() { - let mut options = json!({ "copy": copy, "typeAreas": areas, "patterns": sc.patterns, "objects": objects }); - if Scaling::factor(xf).is_some() { - options["strokes"] = json!(sc.strokes); - options["corners"] = json!(!sc.keep_corners); - } - st.last_transform = Some(LastTransform::Matrix(xf, options)); - st.last_perspective = None; - } - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) -} - -/// Returns the copies of `roots` for a transform or a perspective edit with `copy` in an interaction -/// whose document is still its snapshot, as in a preview ([`Session::preview`] puts the snapshot -/// back first). These are the copies the interaction keeps when they were made of that snapshot for -/// the same `roots`; otherwise this copies `roots` with [`duplicate_in`] in a clone of the snapshot, -/// untransformed, and the interaction keeps that clone. It returns `None` outside an interaction, -/// and when the document is no longer the snapshot, as for a command run in a typing session or a -/// script batch after an earlier edit there: the command then copies the current document. -pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: bool) -> Result>> { - let st = s.doc_mut()?; - let Some(it) = &mut st.interaction else { return Ok(None) }; - if !Arc::ptr_eq(&st.doc, &it.doc) { - return Ok(None); - } - let has_guide_copies = with_guides && !st.selection.guides.is_empty(); - if let Some(k) = &it.copies - && Arc::ptr_eq(&k.from, &it.doc) - && k.roots == roots - && (k.selection.guides.is_empty() != has_guide_copies) - { - return Ok(Some(k.clone())); - } - let mut doc = it.doc.clone(); - let mut selection = st.selection.clone(); - let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; - if has_guide_copies { - let d = Arc::make_mut(&mut doc); - let first = d.guides.len(); - let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); - d.guides.extend(copies); - selection.guides = (first..d.guides.len()).collect(); - } - let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); - it.copies = Some(k.clone()); - Ok(Some(k)) -} - -/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text -/// reflows at its size. False for anything else (and type in perspective), which transforms as -/// usual. -fn resize_type_area(n: &mut Node, xf: Affine) -> bool { - if n.extras().perspective.is_some() { - return false; - } - let NodeKind::Text(t) = &mut n.kind else { return false }; - super::typecmd::reshape_area_with(t, |t| t.transform_area(xf)) -} - -fn transform(s: &mut Session, p: &Value) -> Result { - check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; - check_switches(p, &["anchors"], "object.transform")?; - let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; - let (ids, anchors) = match ids_param(p, "ids") { - Some(v) => (outermost(&s.doc()?.doc, v), false), - None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), - }; - apply_transform(s, "Transform", ids, m, p, anchors) -} - -fn move_cmd(s: &mut Session, p: &Value) -> Result { - check_numbers(p, &["dx", "dy"], "object.move")?; - check_switches(p, &TRANSFORM_SWITCHES, "object.move")?; - let ids = selected_roots_to_transform(s)?; - apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true) -} - -/// The arrow keys' step `(dx, dy)` (page axes, y down) along the x and y axes turned by General › -/// Constrain Angle (`deg`, counter-clockwise on the page): with 30°, → moves 30° up from the -/// horizontal, as the reference app's arrow keys follow the constrain angle (users set it to −90° -/// to nudge along a view rotated by 90°: -/// ). -pub(crate) fn along_constrain_angle(dx: f64, dy: f64, deg: f64) -> (f64, f64) { - // Whole turns are the page's axes, exactly; quarter turns are exact too. - let Some(axes) = vectorcraft_geom::constrain_axes(deg) else { return (dx, dy) }; - let v = axes * Point::new(dx, dy); - (v.x, v.y) -} - -fn nudge(s: &mut Session, p: &Value) -> Result { - check_numbers(p, &["dx", "dy"], "object.nudge")?; - check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?; - check_switches(p, &["big"], "object.nudge")?; - let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; - let (dx, dy) = (f64_or(p, "dx", 0.0) * k, f64_or(p, "dy", 0.0) * k); - // Guides keep to the page's axes; art and anchors move along the constrain angle's. - let (gx, gy) = (dx, dy); - let (dx, dy) = along_constrain_angle(dx, dy, s.prefs.constrain_angle); - if !s.doc()?.selection.anchors.is_empty() { - // When the anchors move by 0, the document and its undo history stay as they are. - if dx == 0.0 && dy == 0.0 { - return ok(); - } - return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy, "patterns": p.get("patterns") })); - } - if s.doc()?.selection.is_empty() { - let copy = bool_or(p, "copy", false); - // When guides move by 0 without a copy, the document and its undo history stay as they are. - if gx == 0.0 && gy == 0.0 && !copy { - return ok(); - } - return super::docmenu::guide_move(s, &json!({ "dx": gx, "dy": gy, "copy": copy })); - } - let ids = selected_roots_to_transform(s)?; - apply_transform_moving_guides(s, "Move", ids, Affine::translate((dx, dy)), p, true, Some(Vec2::new(gx, gy))) -} - -fn about(o: Point, a: Affine) -> Affine { - Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) -} - -fn rotate(s: &mut Session, p: &Value) -> Result { - check_numbers(p, &["angle"], "object.rotate")?; - check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?; - check_switches(p, &["absolute"], "object.rotate")?; - let ids = selected_roots_to_transform(s)?; - let o = origin_of(s, p, &ids)?; - let mut angle = f64_or(p, "angle", 0.0); - if bool_or(p, "absolute", false) { - angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids)); - } - // Angles are counter-clockwise; y is down, so negate. - let a = about(o, Affine::rotate(-angle.to_radians())); - apply_transform(s, "Rotate", ids, a, p, true) -} - -fn scale(s: &mut Session, p: &Value) -> Result { - check_numbers(p, &["sy"], "object.scale")?; - check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?; - let ids = selected_roots_to_transform(s)?; - let o = origin_of(s, p, &ids)?; - let sx = f64_req(p, "sx", "object.scale")? / 100.0; - let sy = f64_or(p, "sy", sx * 100.0) / 100.0; - if sx == 0.0 || sy == 0.0 { - return Err(bad("object.scale", "scale must be non-zero")); - } - apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true) -} - -fn reflect(s: &mut Session, p: &Value) -> Result { - check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; - let ids = selected_roots_to_transform(s)?; - let o = origin_of(s, p, &ids)?; - let m = match p.get("axis") { - Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), - Some(Value::Number(n)) => { - let t = -n.as_f64().unwrap_or(90.0).to_radians(); - Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) - } - None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0), - Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0), - Some(v) => { - let given = v.as_str().map_or_else(|| v.to_string(), str::to_string); - return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`"))); - } - }; - apply_transform(s, "Reflect", ids, about(o, m), p, true) -} - -fn shear(s: &mut Session, p: &Value) -> Result { - check_numbers(p, &["angle"], "object.shear")?; - check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?; - let ids = selected_roots_to_transform(s)?; - let o = origin_of(s, p, &ids)?; - let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); - let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical"); - let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) }; - apply_transform(s, "Shear", ids, about(o, m), p, true) -} - -/// The last transform on the selection, with the options `last_transform` keeps for it; an option -/// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform -/// Each is repeated by running it again (its transform is per object, about each one's bounds). -fn transform_again(s: &mut Session, p: &Value) -> Result { - check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?; - if let Some(again) = s.doc()?.last_perspective.clone() { - return super::distortcmds::transform_again(s, &again); - } - let last = s.doc()?.last_transform.clone().ok_or_else(|| EngineError::Other("no previous transform".into()))?; - let merge = |mut options: Value| { - if let (Some(o), Some(given)) = (options.as_object_mut(), p.as_object()) { - o.extend(given.iter().filter(|(_, v)| v.is_boolean()).map(|(k, v)| (k.clone(), v.clone()))); - } - options - }; - match last { - LastTransform::Matrix(m, options) => { - let ids = selected_roots_to_transform(s)?; - apply_transform(s, "Transform Again", ids, m, &merge(options), true) - } - LastTransform::Each(params) => super::menucmds::transform_each_again(s, &merge(params)), - } -} - -enum Arrange { - Front, - Forward, - Backward, - Back, -} - -fn arrange(s: &mut Session, how: Arrange) -> Result { - let mut ids = selected_roots(s)?; - if matches!(how, Arrange::Forward | Arrange::Front) { - ids.reverse(); - } - let label = match how { - Arrange::Front => "Bring to Front", - Arrange::Forward => "Bring Forward", - Arrange::Backward => "Send Backward", - Arrange::Back => "Send to Back", - }; - // When nothing changes order, the document and its undo history stay as they are. - super::overprint::edit_counted(s, label, |d| { - let mut moved = 0; - // Arrange within each parent independently (selections may span layers/groups). - let mut by_parent: Vec<(Option, Vec)> = vec![]; - for id in &ids { - let par = d.parent_of(*id); - match by_parent.iter_mut().find(|(p, _)| *p == par) { - Some((_, v)) => v.push(*id), - None => by_parent.push((par, vec![*id])), - } - } - for (par, mut group) in by_parent { - // In a clip group or a clipping layer the bottom child is the clipping path: it stays - // there, and the art above it never goes below it (#3188). - let floor = usize::from(par.and_then(|p| d.node(p)).is_some_and(vectorcraft_doc::Node::clips)); - if floor > 0 { - group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); - } - for (k, id) in group.iter().enumerate() { - let Some((_, idx, len)) = d.position(*id) else { continue }; - if len <= floor { - continue; - } - let last = len - 1; - let to = match how { - Arrange::Front => last.saturating_sub(k).max(floor), - Arrange::Back => (floor + k).min(last), - Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx), - Arrange::Backward => idx.saturating_sub(1).max(floor + k.min(idx.saturating_sub(floor))), - }; - if to != idx { - d.move_node(*id, par, to)?; - moved += 1; - } - } - } - Ok(moved) - })?; - ok() -} - -fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // Ids are reused after undo: the remembered current layer must still be a layer. - let st = s.doc()?; - let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?; - // When the current layer is hidden or locked, or is inside a hidden or locked layer, the - // command fails with the error that `DocState::target_parent` gives for new art. - if !st.doc.is_editable(layer) { - return Err(EngineError::Other("the target layer is locked or hidden".into())); - } - // When the selected objects are already the topmost in the current layer, in paint order, the - // document and its undo history stay as they are. - if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { - return ok(); - } - s.edit("Send to Current Layer", |d, _| { - let moved = d.remove_many(&ids)?; - d.children_mut(Some(layer))?.extend(moved); - Ok(()) - })?; - ok() -} - -fn group(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - let gid = s.edit("Group", |d, sel| { - let gid = group_nodes(d, &ids)?; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": gid.0 })) -} - -/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was. -pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result { - if ids.is_empty() { - return Err(EngineError::Other("nothing selected".into())); - } - let gid = d.alloc_id(); - Ok(d.wrap(ids, |children| Node::group(gid, children))?) -} - -fn ungroup(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // With no group selected, the document, the selection and the undo history stay as they are. - let d = &s.doc()?.doc; - if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) { - return ok(); - } - s.edit("Ungroup", |d, sel| { - let mut new_sel = vec![]; - let mut ungrouped = vec![]; - for id in &ids { - let Some(n) = d.node(*id) else { continue }; - let NodeKind::Group { children, clip } = &n.kind else { - new_sel.push(*id); - continue; - }; - let mut out = Vec::with_capacity(children.len()); - for (k, c) in children.iter().enumerate() { - let mut c = (**c).clone(); - // The clipping path keeps its paint (none, unless painted after Make). - if *clip - && k == 0 - && let NodeKind::Path { clipping, .. } = &mut c.kind - { - *clipping = false; - } - c.opacity *= n.opacity; - new_sel.push(c.id); - out.push(c); - } - ungrouped.push((*id, out)); - } - d.insert_beside(ungrouped, true)?; - sel.set(new_sel); - Ok(()) - })?; - ok() -} - -fn lock(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // With every selected object already locked, the document and its undo history stay as they - // are, and the objects are deselected. - let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) { - s.select(|_, sel| sel.clear())?; - return ok(); - } - s.edit("Lock", |d, sel| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.locked = true; - } - } - sel.clear(); - Ok(()) - })?; - ok() -} - -fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { - let mut v = vec![]; - d.walk(|n| { - if !n.is_layer() && f(n) { - v.push(n.id) - } - }); - v -} - -fn unlock_all(s: &mut Session, _: &Value) -> Result { - let ids = collect_flag(&s.doc()?.doc, |n| n.locked); - // With nothing locked, the document, the selection and the undo history stay as they are. - if ids.is_empty() { - return Ok(json!({ "count": 0 })); - } - s.edit("Unlock All", |d, sel| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.locked = false; - } - } - sel.set(ids.iter().copied()); - Ok(()) - })?; - Ok(json!({ "count": ids.len() })) -} - -fn hide(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // With every selected object already hidden, the document and its undo history stay as they - // are, and the objects are deselected. - let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) { - s.select(|_, sel| sel.clear())?; - return ok(); - } - s.edit("Hide", |d, sel| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.visible = false; - } - } - sel.clear(); - Ok(()) - })?; - ok() -} - -fn show_all(s: &mut Session, _: &Value) -> Result { - let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); - // With nothing hidden, the document, the selection and the undo history stay as they are. - if ids.is_empty() { - return Ok(json!({ "count": 0 })); - } - s.edit("Show All", |d, sel| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.visible = true; - } - } - sel.set(ids.iter().copied()); - Ok(()) - })?; - Ok(json!({ "count": ids.len() })) -} - -fn compound_make(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) }; - let id = s.edit("Make Compound Path", |d, sel| { - d.node(backmost).ok_or(EngineError::NoNode(backmost))?; - let mut units: Vec>> = vec![]; - let mut appearance = None; - for id in &ids { - let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; - if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) { - return Err(EngineError::Other("compound paths can only contain paths".into())); - } - compound_members(n, &mut appearance, &mut units); - } - let cid = d.alloc_id(); - let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); - c.appearance = appearance.unwrap_or_default(); - let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?; - // The paths in front run against the backmost one, so where they overlap it they cut holes - // under the non-zero fill rule (#1197). A compound path's paths turn together, by its - // first: its own holes (a letter's counters) stay holes. - let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum()); - let first = |unit: &[Arc]| unit.first().map_or(0.0, |p| direction(p)); - let back = units.first().map_or(0.0, |u| first(u)); - for (i, unit) in units.into_iter().enumerate() { - let flip = i > 0 && back != 0.0 && first(&unit) == back; - for mut p in unit { - let pn = Arc::make_mut(&mut p); - pn.appearance = Appearance::default().into(); - if let NodeKind::Path { path, live, .. } = &mut pn.kind { - *live = None; - if flip { - path.reverse(); - } - } - ch.push(p); - } - } - d.wrap(&ids, |_| c)?; - sel.set([cid]); - Ok(cid) - })?; - Ok(json!({ "id": id.0 })) -} - -/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint -/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of -/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A -/// group has no paint of its own to give: a group of outlined type made a compound path that drew -/// nothing. -fn compound_members(n: &Node, paint: &mut Option>, out: &mut Vec>>) { - match (&n.kind, n.children()) { - (NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]), - (NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()), - (_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)), - } - paint.get_or_insert_with(|| n.appearance.clone()); -} - -/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d` -/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)? -fn releases(n: &Node) -> bool { - match &n.kind { - NodeKind::Compound { .. } => true, - NodeKind::Path { path, .. } => path.subpaths.len() > 1, - _ => false, - } -} - -fn compound_release(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // With no compound path selected, the document, the selection and the undo history stay as - // they are. - let d = &s.doc()?.doc; - if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) { - return ok(); - } - s.edit("Release Compound Path", |d, sel| { - let mut out = vec![]; - let mut released = vec![]; - for id in &ids { - let Some(n) = d.node(*id).cloned().filter(releases) else { continue }; - let mut parts = vec![]; - match &n.kind { - NodeKind::Compound { children, .. } => { - for c in children { - let mut c = (**c).clone(); - c.appearance = n.appearance.clone(); - out.push(c.id); - parts.push(c); - } - } - // Each subpath becomes a path of its own with the path's paint, in the same - // stacking order; the first keeps the path's id. - NodeKind::Path { path, .. } => { - for (k, sp) in path.subpaths.iter().enumerate() { - let mut c = if k == 0 { n.clone() } else { d.reid(&n) }; - if let NodeKind::Path { path, live, .. } = &mut c.kind { - (*path, *live) = (PathData::single(sp.clone()), None); - } - out.push(c.id); - parts.push(c); - } - } - _ => {} - } - released.push((*id, parts)); - } - d.insert_beside(released, true)?; - sel.set(out); - Ok(()) - })?; - ok() -} - -/// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it -/// stays unpainted after Release). -pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> { - as_clipping_path(d.node_mut(top).ok_or(EngineError::NoNode(top))?) -} - -/// Turn path, compound path or text `c` into a clipping path (its paint goes). -pub(super) fn as_clipping_path(c: &mut Node) -> Result<()> { - if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { - return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into())); - } - c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into(); - match &mut c.kind { - NodeKind::Path { clipping, .. } => *clipping = true, - NodeKind::Text(t) => { - for r in &mut t.runs { - (r.style.fill, r.style.stroke) = (Paint::None, Paint::None); - } - } - _ => {} - } - Ok(()) -} - -/// Stop clip group or clipped layer `id` clipping; its clipping path stays, with its paint. -pub(super) fn release_clip(d: &mut Document, id: NodeId) { - let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return }; - n.set_clips(false); - let clip = n.children().and_then(|c| c.first()).map(|c| c.id); - if let Some(c) = clip.and_then(|c| d.node_mut(c)) - && let NodeKind::Path { clipping, .. } = &mut c.kind - { - *clipping = false; - } -} - -fn clip_make(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - let Some(&top) = ids.last() else { return Err(EngineError::Other("nothing selected".into())) }; - let gid = s.edit("Make Clipping Mask", |d, sel| { - make_clipping_path(d, top)?; - let gid = d.alloc_id(); - // The topmost object, the clipping path, goes first, and the others follow in paint order. - d.wrap(&ids, |mut children| { - children.rotate_right(1); - Node::new(gid, NodeKind::Group { children, clip: true }) - })?; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": gid.0 })) -} - -fn clip_release(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // With no clip group selected, the document and its undo history stay as they are. - let d = &s.doc()?.doc; - if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) { - return ok(); - } - s.edit("Release Clipping Mask", |d, _| { - for id in &ids { - release_clip(d, *id); - } - Ok(()) - })?; - ok() -} - -/// Selects the clipping path (`mask`) or the clipped contents of every selected clip group (or of the -/// clip group containing a selected object), like Illustrator's Edit Contents / Edit Clipping Path toggle. -fn clip_edit(s: &mut Session, mask: bool) -> Result { - let st = s.doc()?; - let mut ids = vec![]; - for id in st.selection.objects.iter().copied() { - let mut cur = Some(id); - while let Some(c) = cur { - if let Some(NodeKind::Group { children, clip: true }) = st.doc.node(c).map(|n| &n.kind) { - if mask { - ids.extend(children.first().map(|c| c.id)); - } else { - ids.extend(children.iter().skip(1).map(|c| c.id)); - } - break; - } - cur = st.doc.position(c).and_then(|(par, _, _)| par); - } - } - ids.dedup(); - if ids.is_empty() { - return Err(EngineError::Other("select a clip group".into())); - } - s.select(|_, sel| sel.set(ids.iter().copied()))?; - Ok(json!({ "count": ids.len() })) -} - -fn isolate(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id") - .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) - .ok_or_else(|| bad("object.isolate", "missing id"))?; - let guides_locked = s.menu.guides_locked; - let st = s.doc_mut()?; - // Groups, layers and single objects (a path, a compound path, an image…); type isn't - // isolated: a double-click on it edits its text. - if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) { - return Err(bad("object.isolate", "type objects can't be isolated")); - } - let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); - // A hidden or locked object, one in a hidden or locked group or layer, and a guide path while - // Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them. - // A Shaper Group isolates its source group, so that group is the one checked. - if !st.doc.is_editable(isolation) { - return Err(bad("object.isolate", "the object is hidden or locked")); - } - if guides_locked && crate::is_guide_path(&st.doc, isolation) { - return Err(EngineError::Other("guides are locked".into())); - } - st.isolation = Some(isolation); - st.selection.clear(); - // New art on a locked artboard, now deselected, is protected. - st.sync_new_art(); - st.revision += 1; - ok() -} - -fn exit_isolation(s: &mut Session, _: &Value) -> Result { - let menu = s.menu.clone(); - let st = s.doc_mut()?; - if let Some(i) = st.isolation.take() { - super::distortcmds::finish_edit_text(st, i); - let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); - st.selection.set([target]); - // A guide path isolated while Lock Guides is on isn't selected (#1534). - crate::deselect_hidden(&menu, &st.doc, &mut st.selection); - // The art on a locked artboard can't be selected. - st.protect_locked_artboards(&[], &Default::default(), None); - } - st.revision += 1; - ok() -} - -/// The most data entries `object.setProps` takes at once. -const MAX_DATA: usize = 256; - -/// `object.setProps`'s `data`: each key (a data-* attribute's name: letters, digits, `-`, `_`, -/// `.`) with its value, or `None` to remove it. -fn data_param(m: &serde_json::Map) -> Result)>> { - const C: &str = "object.setProps"; - if m.len() > MAX_DATA { - return Err(bad(C, format!("data takes at most {MAX_DATA} keys at once"))); - } - m.iter() - .map(|(k, v)| { - let k = k.strip_prefix("data-").unwrap_or(k); - if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || k == "name" || k.starts_with("vc-") { - return Err(bad(C, format!("data key `{k}`: letters, digits, -, _ and . (not name or vc-…)"))); - } - let v = match v { - Value::Null => None, - Value::String(s) => Some(s.clone()), - other => Some(other.to_string()), - }; - Ok((k.to_string(), v)) - }) - .collect() -} - -fn set_props(s: &mut Session, p: &Value) -> Result { - check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?; - check_numbers(p, &["opacity"], "object.setProps")?; - let ids = targets(s, p)?; - let opacity = p.get("opacity").and_then(Value::as_f64).map(percent); - let blend = match str_param(p, "blend") { - Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?), - None => None, - }; - let data = match p.get("data") { - None | Some(Value::Null) => None, - Some(Value::Object(m)) => Some(data_param(m)?), - Some(v) => return Err(bad("object.setProps", format!("data must be an object of key: value, not {v}"))), - }; - let knockout = match p.get("knockout") { - Some(v) => Some( - Knockout::from_value(v) - .ok_or_else(|| bad("object.setProps", format!("knockout must be \"on\", \"off\", \"neutral\" or a bool, not {v}")))?, - ), - None => None, - }; - let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true); - // When every object already has every value given, the document and its undo history stay as - // they are. - let flag = |k: &str| p.get(k).and_then(Value::as_bool); - let has_values = |n: &Node| { - str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty())) - && flag("visible").is_none_or(|v| n.visible == v) - && flag("locked").is_none_or(|v| n.locked == v) - && opacity.is_none_or(|v| n.opacity == v) - && blend.is_none_or(|b| n.blend == b) - && flag("isolate").is_none_or(|v| n.isolate == v) - && knockout.is_none_or(|k| n.knockout == k) - && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) - && data.iter().flatten().all(|(k, v)| { - n.extras().attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref() - }) - }; - let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { - // Hidden or locked objects can't stay selected. - if hides { - s.select(|d, sel| sel.deselect_uneditable(d))?; - } - return ok(); - } - s.edit("Object Properties", |d, sel| { - for id in &ids { - let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; - if let Some(v) = str_param(p, "name") { - n.name = if v.is_empty() { None } else { Some(v.to_string()) }; - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - n.visible = v; - } - if let Some(v) = p.get("locked").and_then(Value::as_bool) { - n.locked = v; - } - if let Some(v) = opacity { - n.opacity = v; - } - if let Some(b) = blend { - n.blend = b; - } - if let Some(v) = p.get("isolate").and_then(Value::as_bool) { - n.isolate = v; - } - if let Some(k) = knockout { - n.knockout = k; - } - if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) { - n.knockout_shape = v; - } - if let Some(changes) = &data { - n.edit_attrs(|a| { - for (k, v) in changes { - match (a.data.iter().position(|(key, _)| key == k), v) { - (Some(i), Some(v)) => { - if let Some(e) = a.data.get_mut(i) { - e.1 = v.clone(); - } - } - (Some(i), None) => { - a.data.remove(i); - } - (None, Some(v)) => a.data.push((k.clone(), v.clone())), - (None, None) => {} - } - } - }); - } - } - // Hidden or locked objects can't stay selected. - if hides { - sel.deselect_uneditable(d); - } - Ok(()) - })?; - ok() -} - -impl Session { - /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: - /// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included, - /// see [`effects::preview`]), else geometric bounds. - pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { - effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) - } - /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding - /// box, see [`Document::oriented_bounds`]). - pub fn transform_box(&self, ids: &[NodeId]) -> Option { - effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) - } -} - -/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds. -fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { - match choice(p, cmd, "bounds", &["preview", "geometric"])? { - None => Ok(s.prefs.use_preview_bounds), - Some(b) => Ok(b == "preview"), - } -} - -/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or -/// `None` when it is missing or null. Any other value is an error that lists `values`. -pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { - let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; - match v.as_str() { - Some(s) if values.contains(&s) => Ok(Some(s)), - _ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))), - } -} - -/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`]. -fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> { - Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?)) -} - -/// `spacing` of `object.distribute` and `object.distributeSpacing`: a finite number, or `None` -/// when it is missing or null. Any other value is an error. -fn spacing(p: &Value, cmd: &str) -> Result> { - p.get("spacing") - .filter(|v| !v.is_null()) - .map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number"))) - .transpose() -} - -/// `ids` with their bounds (`preview`: visual bounds as drawn, see [`effects::preview::bounds_of`]). -fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> { - ids.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], preview)?))).collect() -} - -/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) -/// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says -/// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document -/// and its undo history stay as they are. -fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result { - let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() }; - let guides: Vec<(usize, f64)> = { - let d = &s.doc()?.doc; - guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() - }; - if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() { - return ok(); - } - s.edit(label, |d, _| { - for (id, dv) in moves { - if let Some(n) = d.node_mut(*id) { - n.transform(Affine::translate(*dv), sc); - } - } - for &(i, pos) in &guides { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } - } - Ok(()) - })?; - ok() -} - -/// What `object.align` aligns to: `to`, else the key object (or key guide) when there is one, -/// else the selection. -fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { - str_param(p, "to").or_else(|| s.doc().ok()?.selection.has_key().then_some("key")) -} - -/// The key guide `object.align` lines the objects up on (aligning to the key with one set, -/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. -fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { - let st = s.doc().ok()?; - let g = st.doc.guides.get(st.selection.key_guide?)?; - (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) -} - -/// Where Align puts the objects, `r`, and the axes it moves them along (x, y): a key guide gives -/// the line across its axis only (a vertical guide fixes x, a horizontal one y), so the other -/// axis stays as it is. -fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: Option) -> Result<(Rect, bool, bool)> { - if let Some((pos, vertical)) = key_guide(s, p) { - return Ok((Rect::new(pos, pos, pos, pos), vertical, !vertical)); - } - let r = match own { - Some(b) if align_to(s, p) != Some("artboard") => b, - _ => reference_rect(s, p, ids, preview)?, - }; - Ok((r, true, true)) -} - -/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to -/// Selection when no objects are selected. -fn selected_guides_bounds(st: &crate::DocState) -> Option { - st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { - let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; - Some(acc.map(|a| a.union(r)).unwrap_or(r)) - }) -} - -fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { - let st = s.doc()?; - match align_to(s, p) { - Some("artboard") => { - let b = effects::preview::bounds_of(&st.doc, ids, preview) - .or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten()) - .unwrap_or_default(); - // Align to Artboard uses artboard `artboard` (the app passes the active one). Without - // it, or with an index the document doesn't have, it uses the artboard under the center - // of the selection, or the first one when no artboard is there. - let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i)); - let i = st.doc.artboard_at(b.center()).unwrap_or(0); - Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b)) - } - Some("key") => { - let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; - effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k)) - } - _ => { - if ids.is_empty() { - selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into())) - } else { - effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())) - } - } - } -} - -/// Returns the artboard for `to: "artboard"` of `object.distribute` and `object.distributeSpacing` -/// (command `cmd`), the same artboard as `object.align` uses ([`reference_rect`]). It returns -/// `None` for the other choices. A `to` outside the choices is an error. Both commands call it -/// before they space selected anchor points, and they space those points without the artboard. -fn distribute_to(s: &Session, p: &Value, ids: &[NodeId], preview: bool, cmd: &str) -> Result> { - match choice(p, cmd, "to", &["selection", "artboard", "key"])? { - Some("artboard") => reference_rect(s, p, ids, preview).map(Some), - _ => Ok(None), - } -} - -/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the -/// compound path or selected group the key is in. Align to the key object, and Distribute and -/// Distribute Spacing with a spacing, keep it in place. -fn key_root(s: &Session, ids: &[NodeId]) -> Option { - let st = s.doc().ok()?; - let ancestry = st.doc.ancestry(st.selection.key?)?; - ids.iter().copied().find(|id| ancestry.contains(id)) -} - -fn align(s: &mut Session, p: &Value) -> Result { - let ids = selected_roots(s)?; - let preview = preview_bounds(s, p, "object.align")?; - let (h, v) = direction(p, "object.align")?; - choice(p, "object.align", "to", &["selection", "artboard", "key"])?; - check_switches(p, &["patterns"], "object.align")?; - check_numbers(p, &["artboard"], "object.align")?; - if h.is_none() && v.is_none() { - return Err(bad("object.align", "give horizontal or vertical")); - } - // Anchor points selected with Direct Selection align themselves, as in Illustrator (#1180). - if !s.doc()?.selection.anchors.is_empty() { - return align_anchors(s, p, &ids, (h, v)); - } - let (r, along_x, along_y) = align_reference(s, p, &ids, preview, None)?; - let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); - let key = key_root(s, &ids); - let moves: Vec<(NodeId, Vec2)> = { - let d = &s.doc()?.doc; - items_bounds(d, &ids, preview) - .into_iter() - .filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key")) - .map(|(id, b)| { - let dx = match h { - Some("left") => r.x0 - b.x0, - Some("center") => r.center().x - b.center().x, - Some("right") => r.x1 - b.x1, - _ => 0.0, - }; - let dy = match v { - Some("top") => r.y0 - b.y0, - Some("center") => r.center().y - b.center().y, - Some("bottom") => r.y1 - b.y1, - _ => 0.0, - }; - (id, Vec2::new(dx, dy)) - }) - .collect() - }; - let guides = guide_moves(s, p, r, h, v)?; - apply_moves(s, "Align", p, &ids, &moves, &guides) -} - -/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge, -/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that -/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. -/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are -/// selected; with objects in the selection they stay put. -fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { - let st = s.doc()?; - let to = align_to(s, p); - let move_guides = match to { - Some("key" | "artboard") => true, - Some("selection") => st.selection.is_empty(), - _ => false, - }; - if !move_guides { - return Ok(vec![]); - } - let at = |vertical: bool| match (vertical, h, v) { - (true, Some("left"), _) => Some(r.x0), - (true, Some("center"), _) => Some(r.center().x), - (true, Some("right"), _) => Some(r.x1), - (false, _, Some("top")) => Some(r.y0), - (false, _, Some("center")) => Some(r.center().y), - (false, _, Some("bottom")) => Some(r.y1), - _ => None, - }; - let skip_key = to == Some("key"); - Ok(st - .selection - .guides - .iter() - .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) - .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) - .collect()) -} - -/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point -/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard. -fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result { - let targets = super::path::anchor_targets(s)?; - let d = &s.doc()?.doc; - let pts: Vec = targets - .iter() - .flat_map(|(id, refs)| refs.iter().filter_map(move |(si, ai)| d.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p))) - .collect(); - let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else { - return ok(); - }; - // shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does - // (or to a key guide). - let (r, along_x, along_y) = align_reference(s, p, ids, false, Some(bounds))?; - let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); - let target = |q: Point| { - let x = match h { - Some("left") => r.x0, - Some("center") => r.center().x, - Some("right") => r.x1, - _ => q.x, - }; - let y = match v { - Some("top") => r.y0, - Some("center") => r.center().y, - Some("bottom") => r.y1, - _ => q.y, - }; - Point::new(x, y) - }; - if pts.iter().all(|q| { - let dv = target(*q) - *q; - dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9 - }) { - return ok(); - } - s.edit("Align", |d, _| { - for (id, refs) in &targets { - let path = super::path::path_mut(d, *id)?; - for &(si, ai) in refs { - if let Some(a) = path.anchor_mut(si, ai) { - a.translate(target(a.p) - a.p); - } - } - } - Ok(()) - })?; - ok() -} - -/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct -/// Selection (they may all be on one path). -fn can_distribute(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) { - Ok(()) - } else { - has_multi(s) - } -} - -/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide -/// across the axis (#1597), which has no size. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Spaced { - Object(NodeId), - Guide(usize), -} - -/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected -/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds. -/// Guides along the axis are left out, so they stay where they are. -fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result> { - let st = s.doc()?; - let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r)); - let guides = st.selection.guides.iter().filter_map(|&i| { - let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; - Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) - }); - Ok(objects.chain(guides).collect()) -} - -/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing: -/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when -/// aligning to the key (#2143). -fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option { - let key = match key_root(s, ids) { - Some(id) => Spaced::Object(id), - None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?, - }; - items.iter().position(|(it, _)| *it == key) -} - -/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution), -/// aligning to the key. It has no line there, so a spacing from it leaves everything where it -/// is, as Align does. -fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool { - key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz) -} - -/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as -/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position. -fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result { - let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) }; - let (mut moves, mut guides) = (vec![], vec![]); - let d = &s.doc()?.doc; - for &(it, delta) in deltas { - match it { - Spaced::Object(id) => moves.push((id, along(delta))), - Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), - } - } - apply_moves(s, label, p, ids, &moves, &guides) -} - -/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them -/// give all theirs): the points between the first and the last along the axis are spaced evenly, -/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis -/// gives the same result. -fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option) -> Result { - let targets = super::path::anchor_targets(s)?; - let d = &s.doc()?.doc; - let along = |q: Point| if horiz { q.x } else { q.y }; - let mut pts: Vec<(NodeId, usize, usize, f64)> = targets - .iter() - .flat_map(|(id, refs)| { - refs.iter().filter_map(move |&(si, ai)| Some((*id, si, ai, along(d.node(*id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p)))) - }) - .collect(); - pts.sort_by(|a, b| a.3.total_cmp(&b.3)); - let (Some(first), Some(last)) = (pts.first().map(|q| q.3), pts.last().map(|q| q.3)) else { - return ok(); - }; - // Two points are the ends already; with a gap the second is placed from the first. - if pts.len() < if gap.is_some() { 2 } else { 3 } { - return ok(); - } - let step = gap.unwrap_or((last - first) / (pts.len() - 1) as f64); - let moves: Vec<(NodeId, usize, usize, f64)> = - pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect(); - if moves.iter().all(|m| m.3.abs() <= 1e-9) { - return ok(); - } - s.edit(label, |d, _| { - for &(id, si, ai, delta) in &moves { - if let Some(a) = super::path::path_mut(d, id)?.anchor_mut(si, ai) { - a.translate(if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }); - } - } - Ok(()) - })?; - ok() -} - -fn distribute(s: &mut Session, p: &Value) -> Result { - check_switches(p, &["patterns"], "object.distribute")?; - check_numbers(p, &["artboard"], "object.distribute")?; - let ids = selected_roots(s)?; - let preview = preview_bounds(s, p, "object.distribute")?; - let board = distribute_to(s, p, &ids, preview, "object.distribute")?; - let spacing = spacing(p, "object.distribute")?; - // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them. - if !s.doc()?.selection.anchors.is_empty() { - return match direction(p, "object.distribute")? { - (None, None) => Err(bad("object.distribute", "give horizontal or vertical")), - (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), - }; - } - let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { - (Some(h), _) => ( - true, - match h { - "left" => |r: &Rect, _| r.x0, - "right" => |r: &Rect, _| r.x1, - _ => |r: &Rect, _| r.center().x, - }, - ), - (_, Some(v)) => ( - false, - match v { - "top" => |r: &Rect, _| r.y0, - "bottom" => |r: &Rect, _| r.y1, - _ => |r: &Rect, _| r.center().y, - }, - ), - _ => return Err(bad("object.distribute", "give horizontal or vertical")), - }; - // The selected guides across the axis are spaced with the objects (#1597). A guide has no - // size, so its left edge, center and right edge are all its line. - let mut items = spaced_items(s, &ids, preview, horiz)?; - items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz))); - let n = items.len(); - if spacing.is_some() && key_guide_along(s, p, horiz) { - return ok(); - } - // With a spacing, the key object (or the compound path or selected group it is in) or the key - // guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in - // their order. Without a key the spacing is not used. `from_key` is the key's index in - // `items` and the spacing. - let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g)); - // Two items are enough on an artboard, where they go to its edges, and when they are spaced - // from a key. - if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) { - return ok(); - } - let (first_box, last_box) = (&items[0].1, &items[n - 1].1); - // `first` and `last` are the lines that the first and last items end on. Without an artboard - // the first and last items stay where they are. On an artboard the first item moves until - // its left (top) edge is on the artboard's left (top) edge, and the last item moves until its - // right (bottom) edge is on the artboard's right (bottom) edge. - let (first, last) = match board { - Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1), - Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1), - None => (key(first_box, horiz), key(last_box, horiz)), - }; - let deltas: Vec<(Spaced, f64)> = items - .iter() - .enumerate() - .map(|(i, (it, r))| { - let target = match from_key { - Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64), - None => first + (last - first) * i as f64 / (n - 1) as f64, - }; - (*it, target - key(r, horiz)) - }) - .collect(); - apply_spaced(s, "Distribute", p, &ids, horiz, &deltas) -} - -fn distribute_spacing(s: &mut Session, p: &Value) -> Result { - check_switches(p, &["patterns"], "object.distributeSpacing")?; - check_numbers(p, &["artboard"], "object.distributeSpacing")?; - let ids = selected_roots(s)?; - let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical"); - let preview = preview_bounds(s, p, "object.distributeSpacing")?; - let board = distribute_to(s, p, &ids, preview, "object.distributeSpacing")?; - let spacing = spacing(p, "object.distributeSpacing")?; - if !s.doc()?.selection.anchors.is_empty() { - return distribute_anchors(s, "Distribute Spacing", horiz, spacing); - } - // The selected guides across the axis are spaced with the objects, as items with no size (#1597). - let mut items = spaced_items(s, &ids, preview, horiz)?; - items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); - if spacing.is_some() && key_guide_along(s, p, horiz) { - return ok(); - } - let n = items.len(); - let &[(_, first), .., (_, last)] = items.as_slice() else { - // Selected guides along the axis have nothing to space. - return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() }; - }; - let size = |r: &Rect| if horiz { r.width() } else { r.height() }; - let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; - // The items start at `lo`, and without a spacing the last one ends at `hi`. These are the - // first item's left (top) edge and the last one's right (bottom) edge, or the artboard's - // edges with Align to Artboard and no spacing. - let (lo, hi) = match board.filter(|_| spacing.is_none()) { - Some(r) if horiz => (r.x0, r.x1), - Some(r) => (r.y0, r.y1), - None if horiz => (first.x0, last.x1), - None => (first.y0, last.y1), - }; - let gap = match spacing { - Some(g) => g, - None => (hi - lo - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64, - }; - let mut pos = lo; - let mut deltas = vec![]; - for (it, r) in &items { - deltas.push((*it, pos - start(r))); - pos += size(r) + gap; - } - // With a spacing the key stays where it is: the key object, with the compound path or - // selected group it is in, or the key guide (#2143). The others are spaced from it. - let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1); - let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect(); - apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas) -} - -fn set_bounds(s: &mut Session, p: &Value) -> Result { - match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? { - Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b), - Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false), - } -} - -/// What `object.setBounds` does with its values ([`bounds_transform`]). -enum Bounds { - /// With anchors selected with Direct Selection: the objects and the box of the anchors that - /// [`set_anchor_bounds`] moves or scales. - Anchors(Vec, Rect), - /// The objects and the transform that gives them the values. - Objects(Vec, Affine), -} - -/// A previewed `object.setBounds` that transforms objects runs as `object.transform` with the -/// transform [`bounds_transform`] finds for it, so the interaction commits that transform (Transform -/// Again repeats it, and the journal holds it). A bounding-box handle drag with Use Preview Bounds -/// previews `object.setBounds`. The objects are those `object.transform` transforms (a path of a -/// compound path selected on its own is one), and the art keeps at least 1e-4 of its width and -/// height, as with [`vectorcraft_tools::bbox::scale_for_drag`]. `None` with anchors selected with -/// Direct Selection and when the values are not valid. -pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { - if cmd != "object.setBounds" { - return None; - } - let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None }; - let mut p = params.as_object().cloned().unwrap_or_default(); - for k in ["x", "y", "width", "height", "reference", "proportional"] { - p.remove(k); - } - p.insert("matrix".into(), json!(xf.as_coeffs())); - Some(("object.transform".to_string(), Value::Object(p))) -} - -/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the -/// art with a width or height below `least` of the one it has keeps `least` of it. -fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64) -> Result { - check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?; - let st = s.doc()?; - check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; - check_switches(p, &["proportional"], "object.setBounds")?; - if transforms_objects(p) - && !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) - { - return Ok(Bounds::Anchors(ids, b)); - } - let d = &s.doc()?.doc; - // Sizes are measured square to the bounding box, which a rotation turns with the objects: - // the math below runs in its frame. - let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; - let g = frame.rect; - // With Use Preview Bounds the values measure the visual box as drawn, whose margins around the - // geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of - // `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their - // weight when an instance scales, so the rest of the margins stay. - let (v, vs) = if s.prefs.use_preview_bounds { - (effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) - } else { - (g, g) - }; - let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1]; - let (m, all) = (margins(vs), margins(v)); - let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]]; - let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; - let rp = vectorcraft_geom::reference_point(v, refi); - let (w, h) = wanted_size(p, v); - // The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is - // exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the - // scales where it misses. - let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); - let ratio = |size: f64, geo: f64, margin: f64| if geo > 1e-9 { (size - margin) / geo } else { 1.0 }; - let f = |sx: f64, sy: f64| if strokes { (sx * sy).abs().sqrt() } else { 1.0 }; - // The strokes' scale `f0`: the size left after the margins in symbols' art, which stay, over - // `vs`, whose margins scale. - let (lx, ly) = (ratio(w - fixed[0] - fixed[2], vs.width(), 0.0), ratio(h - fixed[1] - fixed[3], vs.height(), 0.0)); - let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 }); - // When a size of 0 or more is smaller than the margins and some of them stay, the geometry - // gets no width or height: the visual box is then as small as it can be. The margins in - // symbols' art stay, and so does every margin when strokes do not scale. - let fit = |size: f64, geo: f64, scaling: f64, kept: f64| { - let s = ratio(size, geo, scaling * f0 + kept); - let stays = if strokes { kept } else { scaling + kept }; - if s < 0.0 && size >= 0.0 && stays > 0.0 { 0.0 } else { s } - }; - let sx = fit(w, g.width(), m[0] + m[2], fixed[0] + fixed[2]); - let sy = fit(h, g.height(), m[1] + m[3], fixed[1] + fixed[3]); - let scale = about(rp, Affine::scale_non_uniform(sx, sy)); - // Move the new visual box's reference point to x, y (default: where it was): the geometric - // box's reference point moves with the art, the margin from it to `vs`'s by `f`, and the - // margin from that one to the visual box's stays. - let gp = vectorcraft_geom::reference_point(g, refi); - let sp = vectorcraft_geom::reference_point(vs, refi); - let new_rp = scale * gp + (sp - gp) * f(sx, sy) + (rp - sp); - // x, y place the reference point on the page. - let page = frame.to_doc() * rp; - // When every value given is the current one, the transform is the identity. Computing it can - // leave a rounding error in it on a rotated object or with Use Preview Bounds. - let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now); - if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) { - return Ok(Bounds::Objects(ids, Affine::IDENTITY)); - } - let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); - // The scales and the point above can miss the size or the reference point when the margins of - // the objects differ, the members of groups and the objects in symbols' art included, and with - // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are - // measured as drawn after each scale tried instead ([`refit_drawn`]). - let symbols = d.symbol_boxes(); - let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n)); - let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { - None - } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { - let options = scaling_options(s, p); - let to_local = frame.to_doc().inverse(); - let measure = |sx: f64, sy: f64| { - let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy))); - let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() }; - ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { - let mut n = n.clone(); - n.transform(xf, sc); - vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local)) - }) - }; - // The scales above keep the effects' reach as a margin that stays, and give 0 for a size - // smaller than that reach. Strokes and effects that scale shrink the reach with the art, so - // the search then starts from the size wanted over the size as drawn. - let seed = |s: f64, size: f64, now: f64| if s <= 0.0 && size > 0.0 && now > 1e-9 { size / now } else { s }; - refit_drawn(measure, refi, [w, h], (seed(sx, w, v.width()), seed(sy, h, v.height())), [g.width() > 1e-9, g.height() > 1e-9]) - } else { - Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp)) - }; - let (sx, sy, new_rp) = solved.unwrap_or((sx, sy, new_rp)); - // A scale below `least` of art with a width (height) keeps `least`, and the reference point - // moves with the scales as above. - let keep = |s: f64, geo: f64| if geo > 1e-9 && (0.0..least).contains(&s) { least } else { s }; - let (kx, ky) = (keep(sx, g.width()), keep(sy, g.height())); - let scale = about(rp, Affine::scale_non_uniform(kx, ky)); - let new_rp = if (kx, ky) == (sx, sy) { new_rp } else { scale * gp + (sp - gp) * f(kx, ky) + (rp - sp) }; - let xf = frame.conjugate(Affine::translate(to - new_rp) * scale); - Ok(Bounds::Objects(ids, xf)) -} - -/// The scales along x and y that give the box `measure` returns for them the size `size`, and point -/// `refi` of that box: the point that goes to the reference point's place. The search starts at -/// `start`. Each step measures how the width and the height change with each scale that `free` -/// marks true, by measuring again with that scale moved by 1e-4 of it (at least 1e-6), and moves -/// those scales by the least-squares solution of that linear change, never below 0. The width can -/// change with the y scale and the height with the x scale (a geometry effect can turn the art). A -/// scale that `free` marks false stays as it is. The search stops when both sizes are within 1e-9 -/// of the sizes wanted (of the largest of 1 and those sizes), when no scale moves, when `measure` -/// finds no box, or after 16 steps, and returns the scales that came nearest. `None` when `measure` -/// finds no box for `start`. -fn refit_drawn( - measure: impl Fn(f64, f64) -> Option, - refi: usize, - size: [f64; 2], - start: (f64, f64), - free: [bool; 2], -) -> Option<(f64, f64, Point)> { - let [w, h] = size; - let tolerance = 1e-9 * w.max(h).max(1.0); - let miss = |b: Rect| (b.width() - w).abs() + (b.height() - h).abs(); - let (mut sx, mut sy) = start; - let mut b = measure(sx, sy)?; - let mut best = (sx, sy, b); - for _ in 0..16 { - let f = [b.width() - w, b.height() - h]; - if f[0].abs() <= tolerance && f[1].abs() <= tolerance { - break; - } - // How the width and the height change with the x scale (`a`) and the y scale (`c`). - let change = |x: bool| -> Option<[f64; 2]> { - let (on, s) = if x { (free[0], sx) } else { (free[1], sy) }; - if !on { - return Some([0.0, 0.0]); - } - let ds = (s.abs() * 1e-4).max(1e-6); - let m = if x { measure(sx + ds, sy) } else { measure(sx, sy + ds) }?; - Some([(m.width() - b.width()) / ds, (m.height() - b.height()) / ds]) - }; - let (Some(a), Some(c)) = (change(true), change(false)) else { break }; - let dot = |u: [f64; 2], v: [f64; 2]| u[0] * v[0] + u[1] * v[1]; - let (aa, ac, cc, af, cf) = (dot(a, a), dot(a, c), dot(c, c), dot(a, f), dot(c, f)); - let det = aa * cc - ac * ac; - let (dx, dy) = if det > 1e-12 * aa * cc && det > 0.0 { - ((ac * cf - cc * af) / det, (ac * af - aa * cf) / det) - } else if aa >= cc && aa > 0.0 { - (-af / aa, 0.0) - } else if cc > 0.0 { - (0.0, -cf / cc) - } else { - break; - }; - let (nx, ny) = ((sx + dx).max(0.0), (sy + dy).max(0.0)); - if !(nx.is_finite() && ny.is_finite()) || (nx == sx && ny == sy) { - break; - } - let Some(next) = measure(nx, ny) else { break }; - (sx, sy, b) = (nx, ny, next); - if miss(b) < miss(best.2) { - best = (sx, sy, b); - } - } - let (sx, sy, b) = best; - Some((sx, sy, vectorcraft_geom::reference_point(b, refi))) -} - -/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or -/// with `proportional` follows the one given. -fn wanted_size(p: &Value, b: Rect) -> (f64, f64) { - let mut w = f64_or(p, "width", b.width()); - let mut h = f64_or(p, "height", b.height()); - if bool_or(p, "proportional", false) { - if p.get("width").is_some() && b.width() > 0.0 { - h = b.height() * w / b.width(); - } else if p.get("height").is_some() && b.height() > 0.0 { - w = b.width() * h / b.height(); - } - } - (w, h) -} - -/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of -/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only -/// the anchors, their handles with them, and those objects move or scale. -fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { - let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; - let rp = vectorcraft_geom::reference_point(b, refi); - let (w, h) = wanted_size(p, b); - // A single anchor, or anchors in a line, have no size to scale on that side. - let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 }; - let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height()))); - let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y)); - apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true) -} - -/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with -/// symbol instances measured without the strokes in their art -/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]). -fn scaling_box(d: &Document, ids: &[NodeId], frame: &OrientedBox) -> Option { - let symbols = d.symbol_boxes().without_art_strokes(); - let to_local = frame.to_doc().inverse(); - ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { - let b = if frame.angle == 0.0 { n.visual_bounds_with(&symbols) } else { n.bounds_in(&symbols, to_local, true) }; - vectorcraft_geom::union_opt(acc, b) - }) -} - -/// One axis of an object that [`set_bounds`] sizes, square to its frame: the geometric box runs -/// from `lo` to `hi`, and the visual box reaches past each end (before, after) by a margin that -/// scales with the strokes and one that stays. -#[derive(Clone, Copy, Debug)] -struct Span { - lo: f64, - hi: f64, - scaling: [f64; 2], - kept: [f64; 2], -} - -impl Span { - /// The ends of the visual box after a scale by `s` about `c`, with the margins that scale - /// grown by `k`. A negative scale turns the box over, and its margins with it. - fn scaled(self, c: f64, s: f64, k: f64) -> (f64, f64) { - let ([sb, sa], [kb, ka]) = (self.scaling, self.kept); - let (before, after) = (sb * k + kb, sa * k + ka); - let (a, b) = (c + s * (self.lo - c), c + s * (self.hi - c)); - if s < 0.0 { (b - after, a + before) } else { (a - before, b + after) } - } -} - -/// The objects that [`set_bounds`] sizes, measured one by one square to its frame. -struct Spans { - x: Vec, - y: Vec, - /// Whether strokes scale. The margins that scale grow by the square root of the product of the - /// two scales; with strokes that do not scale, every margin stays. - strokes: bool, -} - -impl Spans { - /// The objects of `ids` that have a box, measured one by one as [`Document::oriented_bounds`] - /// measures them square to `frame`, each plain group member by member and each symbol instance - /// that [`open`] opens object by object in its art. Past [`MAX_SPANS`] parts, each object of - /// `ids` is measured as one. The margins of the visual box without the strokes in symbols' art - /// ([`scaling_box`]) scale with the strokes, and the rest stay. With `strokes` false, every - /// margin stays. - fn measure(d: &Document, ids: &[NodeId], frame: &OrientedBox, strokes: bool) -> Self { - let (symbols, without, art) = (d.symbol_boxes(), d.symbol_boxes().without_art_strokes(), d.symbol_boxes().quick()); - let to_local = frame.to_doc().inverse(); - let measure = |n: &Node, s: &vectorcraft_doc::SymbolBoxes, visual: bool| match (frame.angle == 0.0, visual) { - (true, false) => n.geometric_bounds_with(s), - (true, true) => n.visual_bounds_with(s), - (false, _) => n.bounds_in(s, to_local, visual), - }; - let roots: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect(); - let mut parts = vec![]; - if !roots.iter().all(|n| open(n, 0.0, d, &symbols, to_local, &mut parts)) { - parts = roots.iter().map(|n| Part::Object(n, 0.0)).collect(); - } - let mut out = Self { x: vec![], y: vec![], strokes }; - for part in parts { - let (g, v, vs) = match part { - Part::Object(n, grow) => { - let Some(g) = measure(n, &symbols, false) else { continue }; - let v = measure(n, &symbols, true).unwrap_or(g).inflate(grow, grow); - let vs = if strokes { measure(n, &without, true).unwrap_or(g).inflate(grow, grow) } else { g }; - (g, v, vs) - } - // Measured as the instance's own box measures its art (`Node::visual_bounds_with`): - // the strokes at their own weight, and the instances in it by their art boxes. - Part::Art(n, xf, grow, kept) => { - let mut placed = n.clone(); - placed.transform(xf, false); - let Some(g) = placed.geometric_bounds_with(&art) else { continue }; - let v = placed.visual_bounds_with(&art).map_or(g, |v| v.union(g)).inflate(kept + grow, kept + grow); - let vs = if strokes { g.inflate(grow, grow) } else { g }; - (g, v, vs) - } - }; - out.x.push(Span { lo: g.x0, hi: g.x1, scaling: [g.x0 - vs.x0, vs.x1 - g.x1], kept: [vs.x0 - v.x0, v.x1 - vs.x1] }); - out.y.push(Span { lo: g.y0, hi: g.y1, scaling: [g.y0 - vs.y0, vs.y1 - g.y1], kept: [vs.y0 - v.y0, v.y1 - vs.y1] }); - } - out - } - - /// The visual box after a scale by `sx`, `sy` about `c`. - fn visual(&self, c: Point, sx: f64, sy: f64) -> Option { - let k = if self.strokes { (sx * sy).abs().sqrt() } else { 1.0 }; - let boxes = self.x.iter().zip(&self.y).map(|(x, y)| { - let ((x0, x1), (y0, y1)) = (x.scaled(c.x, sx, k), y.scaled(c.y, sy, k)); - Rect::new(x0, y0, x1, y1) - }); - boxes.reduce(|a, b| a.union(b)) - } - - /// The scales along x and y that give the visual box the size `size` with a scale about `c`, - /// or come as near to it as the margins that stay allow ([`Self::solve`]), and the point that - /// goes to the reference point's place: point `refi` of the visual box they give. Returns - /// `None`, which keeps `closed` (the scales and the point of [`set_bounds`]'s closed form), when - /// the scales of `closed` put point `refi` at the point of `closed` and either give the size - /// `size` or are the scales [`Self::solve`] finds, to within rounding (1e-12 of the largest - /// value compared, or of 1). - fn refit(&self, c: Point, refi: usize, size: [f64; 2], closed: (f64, f64, Point)) -> Option<(f64, f64, Point)> { - let ([w, h], (sx, sy, at)) = (size, closed); - let b = self.visual(c, sx, sy)?; - let p = vectorcraft_geom::reference_point(b, refi); - let tolerance = 1e-12 * [b.x0, b.y0, b.x1, b.y1, w, h, at.x, at.y].iter().fold(1.0, |m: f64, v| m.max(v.abs())); - let near = |a: f64, b: f64| (a - b).abs() <= tolerance; - let placed = near(p.x, at.x) && near(p.y, at.y); - if placed && near(b.width(), w) && near(b.height(), h) { - return None; - } - let (tx, ty) = self.solve(w, h); - let same = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0); - if placed && same(tx, sx) && same(ty, sy) { - return None; - } - let b = self.visual(c, tx, ty)?; - Some((tx, ty, vectorcraft_geom::reference_point(b, refi))) - } - - /// The scales of 0 or more along x and y that give the visual box the size `w` × `h`, each - /// found by [`scale_across`]. With strokes that scale, the margins that scale grow by `k`, - /// found where it is the square root of the product of the two scales. - fn solve(&self, w: f64, h: f64) -> (f64, f64) { - let at = |k: f64| (scale_across(&self.x, k, w), scale_across(&self.y, k, h)); - if !self.strokes { - return at(1.0); - } - // The scales get smaller as `k` grows, so the square root of their product minus `k` is at - // least 0 at `k` 0 and at most 0 at `k` equal to that root. Bisection finds the `k` between - // the two where it is 0. - let (sx, sy) = at(0.0); - let (mut lo, mut hi) = (0.0, (sx * sy).sqrt()); - if hi.is_nan() || hi <= 0.0 { - return (sx, sy); - } - for _ in 0..200 { - let mid = lo + (hi - lo) / 2.0; - if mid <= lo || mid >= hi { - break; - } - let (sx, sy) = at(mid); - if (sx * sy).sqrt() > mid { - lo = mid; - } else { - hi = mid; - } - } - let miss = |k: f64| { - let (sx, sy) = at(k); - ((sx * sy).sqrt() - k).abs() - }; - at(if miss(lo) < miss(hi) { lo } else { hi }) - } -} - -/// Most parts [`Spans::measure`] opens the objects it sizes into: past it, each object is one part. -const MAX_SPANS: usize = 4096; - -/// What [`Spans::measure`] measures as one span. -enum Part<'a> { - /// An object of the selection, or a member of a plain group in it, and the outset of the - /// strokes of the plain groups around it, which scale with its own. - Object(&'a Node, f64), - /// An object in the art of a symbol instance and the transform that places it square to the - /// frame. The outset of the strokes of the instance and of the plain groups around the instance - /// scales with the strokes; the outset of the strokes of the plain groups around the object in - /// the art stays, as the strokes in the art do. - Art(&'a Node, Affine, f64, f64), -} - -/// Adds the parts that [`Spans::measure`] measures `n` by to `parts`, `grow` being the outset of -/// the strokes of the plain groups around it ([`Part::Object`]). A plain group ([`opens`]) adds the -/// parts of each member. A symbol instance without effects or an opacity mask of its own adds the -/// objects of its art, with plain groups there opened too ([`Part::Art`]), when its symbol's art -/// measures it, it is not in perspective, and its transform keeps the art square to the frame (a -/// turned or slanted instance is measured by the box around its turned art box). Anything else is -/// one part. Returns false when the parts pass [`MAX_SPANS`]. -fn open<'a>(n: &'a Node, grow: f64, d: &'a Document, symbols: &vectorcraft_doc::SymbolBoxes, to_local: Affine, parts: &mut Vec>) -> bool { - if opens(n) { - match &n.kind { - NodeKind::Group { children, .. } => { - let grow = grow + n.appearance.outset(); - return children.iter().all(|c| open(c, grow, d, symbols, to_local, parts)); - } - NodeKind::SymbolInstance { symbol, xf } => { - let xf = to_local * *xf; - if n.projection().is_none() - && square(xf) - && symbols.art_bounds(symbol).is_some() - && let Some(art) = d.symbols.iter().find(|s| s.name == *symbol) - { - return open_art(&art.art, xf, grow + n.appearance.outset(), 0.0, parts); - } - } - _ => {} - } - } - parts.push(Part::Object(n, grow)); - parts.len() <= MAX_SPANS -} - -/// [`open`] for `n` in symbol art that `xf` places square to the frame: a plain group adds the parts -/// of each member, and anything else, a symbol instance included, is one part. `kept` is the outset -/// of the strokes of the plain groups around `n` in the art, and `grow` that of the instance and the -/// plain groups around it. -fn open_art<'a>(n: &'a Node, xf: Affine, grow: f64, kept: f64, parts: &mut Vec>) -> bool { - if let NodeKind::Group { children, .. } = &n.kind - && opens(n) - { - let kept = kept + n.appearance.outset(); - return children.iter().all(|c| open_art(c, xf, grow, kept, parts)); - } - parts.push(Part::Art(n, xf, grow, kept)); - parts.len() <= MAX_SPANS -} - -/// Whether [`open`] opens `n`: a group without a clip, a Shaper construction, effects or an opacity -/// mask of its own, or a symbol instance without effects or an opacity mask of its own. -fn opens(n: &Node) -> bool { - let visible = |effects: &[vectorcraft_doc::Effect]| effects.iter().any(|e| e.visible); - let effects = visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible(i.effects())); - let kind = match n.kind { - NodeKind::Group { clip, .. } => !clip && n.extras().shaper.is_none(), - NodeKind::SymbolInstance { .. } => true, - _ => false, - }; - kind && !effects && n.extras().mask.is_none() -} - -/// Whether `a` takes boxes square to the frame to boxes square to it: it scales, mirrors, turns by a -/// quarter turn and moves, and nothing else, to within rounding. -fn square(a: Affine) -> bool { - let [m0, m1, m2, m3, ..] = a.as_coeffs(); - let tolerance = 1e-9 * [m0, m1, m2, m3].iter().fold(0.0, |m: f64, v| m.max(v.abs())); - m1.abs() <= tolerance && m2.abs() <= tolerance || m0.abs() <= tolerance && m3.abs() <= tolerance -} - -/// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`, -/// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across -/// together, as the closed form in [`set_bounds`] does, and 0 when the spans reach farther than -/// `size` at the scale 0. -fn scale_across(spans: &[Span], k: f64, size: f64) -> f64 { - let o = spans.iter().map(|s| s.lo).fold(f64::INFINITY, f64::min); - let across = spans.iter().map(|s| s.hi).fold(f64::NEG_INFINITY, f64::max) - o; - // How far the spans reach together after a scale by `s` about `o`, and how fast that changes - // with `s`: the far geometric end minus the near one, of the objects that reach farthest at the - // two ends. - let reach = |s: f64| { - let (mut start, mut end, mut slope_start, mut slope_end) = (f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.0); - for sp in spans { - let (a, b) = sp.scaled(o, s, k); - if a < start { - (start, slope_start) = (a, sp.lo - o); - } - if b > end { - (end, slope_end) = (b, sp.hi - o); - } - } - (end - start, slope_end - slope_start) - }; - if across.is_nan() || across <= 1e-9 { - return 1.0; - } - let least = reach(0.0).0; - if least.is_nan() || least > size { - return 0.0; - } - // `reach` is convex in `s` and at most `size` at 0, so it equals `size` at one largest scale. - // Newton's method, started at a larger scale, reaches it one linear piece of `reach` at a - // time; bisection finishes the search when a step makes no progress. - let (mut lo, mut hi) = (0.0, size / across); - for _ in 0..64 { - if reach(hi).0 >= size { - break; - } - hi = 2.0 * hi + 1.0; - } - for _ in 0..64 { - let (r, slope) = reach(hi); - if r <= size { - return hi; - } - let next = hi - (r - size) / slope; - if next.is_nan() || next <= lo || next >= hi { - break; - } - hi = next; - } - for _ in 0..64 { - let mid = lo + (hi - lo) / 2.0; - if mid <= lo || mid >= hi { - break; - } - if reach(mid).0 <= size { - lo = mid; - } else { - hi = mid; - } - } - if size - reach(lo).0 <= reach(hi).0 - size { lo } else { hi } -} - -fn expand_shape(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; - // With no live shape selected, the document and its undo history stay as they are. - let d = &s.doc()?.doc; - if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) { - return ok(); - } - s.edit("Expand Shape", |d, _| { - for id in &ids { - if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { - *live = None; - } - } - Ok(()) - })?; - ok() -} - -/// The `corners` of `object.setLiveShape`: anchor indices of a path's uncut outline. -fn corners_param(p: &Value) -> Result>> { - let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) }; - let err = || bad("object.setLiveShape", format!("`corners` must list anchor indices (0, 1, 2…), not {v}")); - let list = v.as_array().ok_or_else(err)?; - list.iter().map(|k| k.as_u64().and_then(|k| usize::try_from(k).ok()).ok_or_else(err)).collect::>().map(Some) -} - -fn set_live_shape(s: &mut Session, p: &Value) -> Result { - check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?; - check_numbers(p, &["radius", "sides"], "object.setLiveShape")?; - // `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them. - let items = match p.get("items").filter(|v| !v.is_null()) { - None => None, - Some(Value::Array(a)) => Some( - a.iter() - .map(|it| { - let id = it - .get("id") - .and_then(Value::as_u64) - .map(NodeId) - .ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?; - Ok((id, corners_param(it)?)) - }) - .collect::>>()?, - ), - Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))), - }; - let ids = match &items { - Some(items) => items.iter().map(|(id, _)| *id).collect(), - None => targets(s, p)?, - }; - let shared = corners_param(p)?; - let corners_of = |id: NodeId| match &items { - Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()), - None => shared.clone(), - }; - let kind = p - .get("kind") - .filter(|v| !v.is_null()) - .map(|v| { - serde_json::from_value::(v.clone()) - .map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) - }) - .transpose()?; - let radius = p.get("radius").and_then(Value::as_f64); - let relative_radii = p.get("relativeRadii").and_then(Value::as_bool); - let sides = p.get("sides").and_then(Value::as_u64); - // A number that must be `what` (None when absent). - let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result> { - match p.get(k) { - None | Some(Value::Null) => Ok(None), - Some(v) => v - .as_f64() - .filter(|x| x.is_finite() && ok(*x)) - .map(Some) - .ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))), - } - }; - let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); - let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); - // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). - let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); - let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); - // A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal. - let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?); - let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true); - // An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole - // ellipse as 0° to 360°). - let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; - let pie_of = |pie: (f64, f64)| { - let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); - if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie } - }; - // When every value given is the current one, the document and its undo history stay as they - // are. - let st = s.doc()?; - let has_values = |id: &NodeId| { - let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; - match live.as_deref() { - Some(l @ LiveShape::Polygon { sides: now, .. }) - if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) - && (!equal || l.polygon_sides_equal()) - && polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a)) - && side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) => - { - return false; - } - Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false, - _ => {} - } - if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) { - return false; - } - if radius.is_none() && kind.is_none() { - return true; - } - let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true }; - let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); - // Setting corners folds a scale left in a rectangle's transform into its size, which - // changes it. - live.as_deref().is_none_or(|l| l.folded() == *l) - && picked.iter().all(|k| { - *k < c.base.anchor_count() - && radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9) - && kind.is_none_or(|kd| c.kind(*k) == kd) - }) - }; - if ids.iter().all(has_values) { - return ok(); - } - s.edit("Live Shape", |d, sel| { - for id in &ids { - let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut() - && (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal) - { - if let Some(n) = sides { - l.set_sides(n); - } - if equal { - l.make_sides_equal(); - } - if let Some(a) = polygon_angle { - l.set_polygon_angle(a); - } - // A side length sets the radius that gives it (with the new side count). - if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) { - l.set_polygon_radius(r); - } - *path = l.to_path(); - } - if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut() - && (pie_start.is_some() || pie_end.is_some() || invert) - { - if let LiveShape::Ellipse { pie, .. } = l { - *pie = pie_of(*pie); - } - *path = l.to_path(); - } - if let Some(on) = relative_radii - && let Some(l) = live.as_deref_mut() - { - convert_radii_relative(l, on); - *path = l.to_path(); - } - if radius.is_none() && kind.is_none() { - continue; - } - let partial = sel.anchors.get_mut(id); - // The corners to edit, the Direct-Selected ones and the anchor layout before the edit. - let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue }; - let corners = corners_of(*id); - let picked = match &corners { - Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => { - let n = c.base.anchor_count(); - let past: Vec<_> = k.range(n..).collect(); - return Err(bad("object.setLiveShape", format!("object {id} has anchors 0–{} for `corners`, not {past:?}", n.saturating_sub(1)))); - } - Some(k) => k.clone(), - None => c.picked(partial.as_deref()), - }; - let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()); - set_corners(path, live, &picked, radius, kind); - // Direct-Selected corners stay selected as they gain or lose anchors. - if let (Some(anchors), Some(selected)) = (partial, selected) - && let Some(c) = LiveCorners::new(path, live.as_deref()) - && c.sources() != layout - { - *anchors = c.anchors_of(&selected); - } - } - Ok(()) - })?; - ok() -} +mod align; +mod arrange; +mod bounds; +mod commands; +mod compound; +mod props; +mod shape; +mod spans; +mod transform; + +pub(crate) use align::choice; +pub(crate) use arrange::group_nodes; +pub(crate) use bounds::bounds_rewrite; +pub use commands::specs; +pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; +pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs new file mode 100644 index 000000000..7a0c073d9 --- /dev/null +++ b/crates/engine/src/cmd/object/align.rs @@ -0,0 +1,524 @@ +use super::transform::transform_patterns; +use super::*; + +impl Session { + /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: + /// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included, + /// see [`effects::preview`]), else geometric bounds. + pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { + effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + } + /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding + /// box, see [`Document::oriented_bounds`]). + pub fn transform_box(&self, ids: &[NodeId]) -> Option { + effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + } +} + +/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds. +fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { + match choice(p, cmd, "bounds", &["preview", "geometric"])? { + None => Ok(s.prefs.use_preview_bounds), + Some(b) => Ok(b == "preview"), + } +} + +/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or +/// `None` when it is missing or null. Any other value is an error that lists `values`. +pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + match v.as_str() { + Some(s) if values.contains(&s) => Ok(Some(s)), + _ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))), + } +} + +/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`]. +fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> { + Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?)) +} + +/// `spacing` of `object.distribute` and `object.distributeSpacing`: a finite number, or `None` +/// when it is missing or null. Any other value is an error. +fn spacing(p: &Value, cmd: &str) -> Result> { + p.get("spacing") + .filter(|v| !v.is_null()) + .map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number"))) + .transpose() +} + +/// `ids` with their bounds (`preview`: visual bounds as drawn, see [`effects::preview::bounds_of`]). +fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> { + ids.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], preview)?))).collect() +} + +/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) +/// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says +/// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document +/// and its undo history stay as they are. +fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result { + let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() }; + let guides: Vec<(usize, f64)> = { + let d = &s.doc()?.doc; + guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() + }; + if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() { + return ok(); + } + s.edit(label, |d, _| { + for (id, dv) in moves { + if let Some(n) = d.node_mut(*id) { + n.transform(Affine::translate(*dv), sc); + } + } + for &(i, pos) in &guides { + if let Some(g) = d.guides.get_mut(i) { + g.pos = pos; + } + } + Ok(()) + })?; + ok() +} + +/// What `object.align` aligns to: `to`, else the key object (or key guide) when there is one, +/// else the selection. +fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { + str_param(p, "to").or_else(|| s.doc().ok()?.selection.has_key().then_some("key")) +} + +/// The key guide `object.align` lines the objects up on (aligning to the key with one set, +/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. +fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { + let st = s.doc().ok()?; + let g = st.doc.guides.get(st.selection.key_guide?)?; + (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) +} + +/// Where Align puts the objects, `r`, and the axes it moves them along (x, y): a key guide gives +/// the line across its axis only (a vertical guide fixes x, a horizontal one y), so the other +/// axis stays as it is. +fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: Option) -> Result<(Rect, bool, bool)> { + if let Some((pos, vertical)) = key_guide(s, p) { + return Ok((Rect::new(pos, pos, pos, pos), vertical, !vertical)); + } + let r = match own { + Some(b) if align_to(s, p) != Some("artboard") => b, + _ => reference_rect(s, p, ids, preview)?, + }; + Ok((r, true, true)) +} + +/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to +/// Selection when no objects are selected. +fn selected_guides_bounds(st: &crate::DocState) -> Option { + st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; + Some(acc.map(|a| a.union(r)).unwrap_or(r)) + }) +} + +fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { + let st = s.doc()?; + match align_to(s, p) { + Some("artboard") => { + let b = effects::preview::bounds_of(&st.doc, ids, preview) + .or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten()) + .unwrap_or_default(); + // Align to Artboard uses artboard `artboard` (the app passes the active one). Without + // it, or with an index the document doesn't have, it uses the artboard under the center + // of the selection, or the first one when no artboard is there. + let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i)); + let i = st.doc.artboard_at(b.center()).unwrap_or(0); + Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b)) + } + Some("key") => { + let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; + effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k)) + } + _ => { + if ids.is_empty() { + selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into())) + } else { + effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())) + } + } + } +} + +/// Returns the artboard for `to: "artboard"` of `object.distribute` and `object.distributeSpacing` +/// (command `cmd`), the same artboard as `object.align` uses ([`reference_rect`]). It returns +/// `None` for the other choices. A `to` outside the choices is an error. Both commands call it +/// before they space selected anchor points, and they space those points without the artboard. +fn distribute_to(s: &Session, p: &Value, ids: &[NodeId], preview: bool, cmd: &str) -> Result> { + match choice(p, cmd, "to", &["selection", "artboard", "key"])? { + Some("artboard") => reference_rect(s, p, ids, preview).map(Some), + _ => Ok(None), + } +} + +/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the +/// compound path or selected group the key is in. Align to the key object, and Distribute and +/// Distribute Spacing with a spacing, keep it in place. +fn key_root(s: &Session, ids: &[NodeId]) -> Option { + let st = s.doc().ok()?; + let ancestry = st.doc.ancestry(st.selection.key?)?; + ids.iter().copied().find(|id| ancestry.contains(id)) +} + +pub(super) fn align(s: &mut Session, p: &Value) -> Result { + let ids = selected_roots(s)?; + let preview = preview_bounds(s, p, "object.align")?; + let (h, v) = direction(p, "object.align")?; + choice(p, "object.align", "to", &["selection", "artboard", "key"])?; + check_switches(p, &["patterns"], "object.align")?; + check_numbers(p, &["artboard"], "object.align")?; + if h.is_none() && v.is_none() { + return Err(bad("object.align", "give horizontal or vertical")); + } + // Anchor points selected with Direct Selection align themselves, as in Illustrator (#1180). + if !s.doc()?.selection.anchors.is_empty() { + return align_anchors(s, p, &ids, (h, v)); + } + let (r, along_x, along_y) = align_reference(s, p, &ids, preview, None)?; + let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); + let key = key_root(s, &ids); + let moves: Vec<(NodeId, Vec2)> = { + let d = &s.doc()?.doc; + items_bounds(d, &ids, preview) + .into_iter() + .filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key")) + .map(|(id, b)| { + let dx = match h { + Some("left") => r.x0 - b.x0, + Some("center") => r.center().x - b.center().x, + Some("right") => r.x1 - b.x1, + _ => 0.0, + }; + let dy = match v { + Some("top") => r.y0 - b.y0, + Some("center") => r.center().y - b.center().y, + Some("bottom") => r.y1 - b.y1, + _ => 0.0, + }; + (id, Vec2::new(dx, dy)) + }) + .collect() + }; + let guides = guide_moves(s, p, r, h, v)?; + apply_moves(s, "Align", p, &ids, &moves, &guides) +} + +/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge, +/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that +/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. +/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are +/// selected; with objects in the selection they stay put. +fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { + let st = s.doc()?; + let to = align_to(s, p); + let move_guides = match to { + Some("key" | "artboard") => true, + Some("selection") => st.selection.is_empty(), + _ => false, + }; + if !move_guides { + return Ok(vec![]); + } + let at = |vertical: bool| match (vertical, h, v) { + (true, Some("left"), _) => Some(r.x0), + (true, Some("center"), _) => Some(r.center().x), + (true, Some("right"), _) => Some(r.x1), + (false, _, Some("top")) => Some(r.y0), + (false, _, Some("center")) => Some(r.center().y), + (false, _, Some("bottom")) => Some(r.y1), + _ => None, + }; + let skip_key = to == Some("key"); + Ok(st + .selection + .guides + .iter() + .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) + .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) + .collect()) +} + +/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point +/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard. +fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result { + let targets = crate::cmd::path::anchor_targets(s)?; + let d = &s.doc()?.doc; + let pts: Vec = targets + .iter() + .flat_map(|(id, refs)| refs.iter().filter_map(move |(si, ai)| d.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p))) + .collect(); + let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else { + return ok(); + }; + // shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does + // (or to a key guide). + let (r, along_x, along_y) = align_reference(s, p, ids, false, Some(bounds))?; + let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); + let target = |q: Point| { + let x = match h { + Some("left") => r.x0, + Some("center") => r.center().x, + Some("right") => r.x1, + _ => q.x, + }; + let y = match v { + Some("top") => r.y0, + Some("center") => r.center().y, + Some("bottom") => r.y1, + _ => q.y, + }; + Point::new(x, y) + }; + if pts.iter().all(|q| { + let dv = target(*q) - *q; + dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9 + }) { + return ok(); + } + s.edit("Align", |d, _| { + for (id, refs) in &targets { + let path = crate::cmd::path::path_mut(d, *id)?; + for &(si, ai) in refs { + if let Some(a) = path.anchor_mut(si, ai) { + a.translate(target(a.p) - a.p); + } + } + } + Ok(()) + })?; + ok() +} + +/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct +/// Selection (they may all be on one path). +pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) { + Ok(()) + } else { + has_multi(s) + } +} + +/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide +/// across the axis (#1597), which has no size. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Spaced { + Object(NodeId), + Guide(usize), +} + +/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected +/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds. +/// Guides along the axis are left out, so they stay where they are. +fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result> { + let st = s.doc()?; + let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r)); + let guides = st.selection.guides.iter().filter_map(|&i| { + let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) + }); + Ok(objects.chain(guides).collect()) +} + +/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing: +/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when +/// aligning to the key (#2143). +fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option { + let key = match key_root(s, ids) { + Some(id) => Spaced::Object(id), + None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?, + }; + items.iter().position(|(it, _)| *it == key) +} + +/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution), +/// aligning to the key. It has no line there, so a spacing from it leaves everything where it +/// is, as Align does. +fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool { + key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz) +} + +/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as +/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position. +fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result { + let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) }; + let (mut moves, mut guides) = (vec![], vec![]); + let d = &s.doc()?.doc; + for &(it, delta) in deltas { + match it { + Spaced::Object(id) => moves.push((id, along(delta))), + Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + } + } + apply_moves(s, label, p, ids, &moves, &guides) +} + +/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them +/// give all theirs): the points between the first and the last along the axis are spaced evenly, +/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis +/// gives the same result. +fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option) -> Result { + let targets = crate::cmd::path::anchor_targets(s)?; + let d = &s.doc()?.doc; + let along = |q: Point| if horiz { q.x } else { q.y }; + let mut pts: Vec<(NodeId, usize, usize, f64)> = targets + .iter() + .flat_map(|(id, refs)| { + refs.iter().filter_map(move |&(si, ai)| Some((*id, si, ai, along(d.node(*id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p)))) + }) + .collect(); + pts.sort_by(|a, b| a.3.total_cmp(&b.3)); + let (Some(first), Some(last)) = (pts.first().map(|q| q.3), pts.last().map(|q| q.3)) else { + return ok(); + }; + // Two points are the ends already; with a gap the second is placed from the first. + if pts.len() < if gap.is_some() { 2 } else { 3 } { + return ok(); + } + let step = gap.unwrap_or((last - first) / (pts.len() - 1) as f64); + let moves: Vec<(NodeId, usize, usize, f64)> = + pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect(); + if moves.iter().all(|m| m.3.abs() <= 1e-9) { + return ok(); + } + s.edit(label, |d, _| { + for &(id, si, ai, delta) in &moves { + if let Some(a) = crate::cmd::path::path_mut(d, id)?.anchor_mut(si, ai) { + a.translate(if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }); + } + } + Ok(()) + })?; + ok() +} + +pub(super) fn distribute(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["patterns"], "object.distribute")?; + check_numbers(p, &["artboard"], "object.distribute")?; + let ids = selected_roots(s)?; + let preview = preview_bounds(s, p, "object.distribute")?; + let board = distribute_to(s, p, &ids, preview, "object.distribute")?; + let spacing = spacing(p, "object.distribute")?; + // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them. + if !s.doc()?.selection.anchors.is_empty() { + return match direction(p, "object.distribute")? { + (None, None) => Err(bad("object.distribute", "give horizontal or vertical")), + (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), + }; + } + let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { + (Some(h), _) => ( + true, + match h { + "left" => |r: &Rect, _| r.x0, + "right" => |r: &Rect, _| r.x1, + _ => |r: &Rect, _| r.center().x, + }, + ), + (_, Some(v)) => ( + false, + match v { + "top" => |r: &Rect, _| r.y0, + "bottom" => |r: &Rect, _| r.y1, + _ => |r: &Rect, _| r.center().y, + }, + ), + _ => return Err(bad("object.distribute", "give horizontal or vertical")), + }; + // The selected guides across the axis are spaced with the objects (#1597). A guide has no + // size, so its left edge, center and right edge are all its line. + let mut items = spaced_items(s, &ids, preview, horiz)?; + items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz))); + let n = items.len(); + if spacing.is_some() && key_guide_along(s, p, horiz) { + return ok(); + } + // With a spacing, the key object (or the compound path or selected group it is in) or the key + // guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in + // their order. Without a key the spacing is not used. `from_key` is the key's index in + // `items` and the spacing. + let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g)); + // Two items are enough on an artboard, where they go to its edges, and when they are spaced + // from a key. + if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) { + return ok(); + } + let (first_box, last_box) = (&items[0].1, &items[n - 1].1); + // `first` and `last` are the lines that the first and last items end on. Without an artboard + // the first and last items stay where they are. On an artboard the first item moves until + // its left (top) edge is on the artboard's left (top) edge, and the last item moves until its + // right (bottom) edge is on the artboard's right (bottom) edge. + let (first, last) = match board { + Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1), + Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1), + None => (key(first_box, horiz), key(last_box, horiz)), + }; + let deltas: Vec<(Spaced, f64)> = items + .iter() + .enumerate() + .map(|(i, (it, r))| { + let target = match from_key { + Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64), + None => first + (last - first) * i as f64 / (n - 1) as f64, + }; + (*it, target - key(r, horiz)) + }) + .collect(); + apply_spaced(s, "Distribute", p, &ids, horiz, &deltas) +} + +pub(super) fn distribute_spacing(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["patterns"], "object.distributeSpacing")?; + check_numbers(p, &["artboard"], "object.distributeSpacing")?; + let ids = selected_roots(s)?; + let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical"); + let preview = preview_bounds(s, p, "object.distributeSpacing")?; + let board = distribute_to(s, p, &ids, preview, "object.distributeSpacing")?; + let spacing = spacing(p, "object.distributeSpacing")?; + if !s.doc()?.selection.anchors.is_empty() { + return distribute_anchors(s, "Distribute Spacing", horiz, spacing); + } + // The selected guides across the axis are spaced with the objects, as items with no size (#1597). + let mut items = spaced_items(s, &ids, preview, horiz)?; + items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); + if spacing.is_some() && key_guide_along(s, p, horiz) { + return ok(); + } + let n = items.len(); + let &[(_, first), .., (_, last)] = items.as_slice() else { + // Selected guides along the axis have nothing to space. + return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() }; + }; + let size = |r: &Rect| if horiz { r.width() } else { r.height() }; + let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; + // The items start at `lo`, and without a spacing the last one ends at `hi`. These are the + // first item's left (top) edge and the last one's right (bottom) edge, or the artboard's + // edges with Align to Artboard and no spacing. + let (lo, hi) = match board.filter(|_| spacing.is_none()) { + Some(r) if horiz => (r.x0, r.x1), + Some(r) => (r.y0, r.y1), + None if horiz => (first.x0, last.x1), + None => (first.y0, last.y1), + }; + let gap = match spacing { + Some(g) => g, + None => (hi - lo - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64, + }; + let mut pos = lo; + let mut deltas = vec![]; + for (it, r) in &items { + deltas.push((*it, pos - start(r))); + pos += size(r) + gap; + } + // With a spacing the key stays where it is: the key object, with the compound path or + // selected group it is in, or the key guide (#2143). The others are spaced from it. + let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1); + let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect(); + apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas) +} diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs new file mode 100644 index 000000000..b60c11f4c --- /dev/null +++ b/crates/engine/src/cmd/object/arrange.rs @@ -0,0 +1,275 @@ +use super::*; + +pub(super) enum Arrange { + Front, + Forward, + Backward, + Back, +} + +pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result { + let mut ids = selected_roots(s)?; + if matches!(how, Arrange::Forward | Arrange::Front) { + ids.reverse(); + } + let label = match how { + Arrange::Front => "Bring to Front", + Arrange::Forward => "Bring Forward", + Arrange::Backward => "Send Backward", + Arrange::Back => "Send to Back", + }; + // When nothing changes order, the document and its undo history stay as they are. + crate::cmd::overprint::edit_counted(s, label, |d| { + let mut moved = 0; + // Arrange within each parent independently (selections may span layers/groups). + let mut by_parent: Vec<(Option, Vec)> = vec![]; + for id in &ids { + let par = d.parent_of(*id); + match by_parent.iter_mut().find(|(p, _)| *p == par) { + Some((_, v)) => v.push(*id), + None => by_parent.push((par, vec![*id])), + } + } + for (par, mut group) in by_parent { + // In a clip group or a clipping layer the bottom child is the clipping path: it stays + // there, and the art above it never goes below it (#3188). + let floor = usize::from(par.and_then(|p| d.node(p)).is_some_and(vectorcraft_doc::Node::clips)); + if floor > 0 { + group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); + } + for (k, id) in group.iter().enumerate() { + let Some((_, idx, len)) = d.position(*id) else { continue }; + if len <= floor { + continue; + } + let last = len - 1; + let to = match how { + Arrange::Front => last.saturating_sub(k).max(floor), + Arrange::Back => (floor + k).min(last), + Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx), + Arrange::Backward => idx.saturating_sub(1).max(floor + k.min(idx.saturating_sub(floor))), + }; + if to != idx { + d.move_node(*id, par, to)?; + moved += 1; + } + } + } + Ok(moved) + })?; + ok() +} + +pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // Ids are reused after undo: the remembered current layer must still be a layer. + let st = s.doc()?; + let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?; + // When the current layer is hidden or locked, or is inside a hidden or locked layer, the + // command fails with the error that `DocState::target_parent` gives for new art. + if !st.doc.is_editable(layer) { + return Err(EngineError::Other("the target layer is locked or hidden".into())); + } + // When the selected objects are already the topmost in the current layer, in paint order, the + // document and its undo history stay as they are. + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + return ok(); + } + s.edit("Send to Current Layer", |d, _| { + let moved = d.remove_many(&ids)?; + d.children_mut(Some(layer))?.extend(moved); + Ok(()) + })?; + ok() +} + +pub(super) fn group(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let gid = s.edit("Group", |d, sel| { + let gid = group_nodes(d, &ids)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": gid.0 })) +} + +/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was. +pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result { + if ids.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + let gid = d.alloc_id(); + Ok(d.wrap(ids, |children| Node::group(gid, children))?) +} + +pub(super) fn ungroup(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // With no group selected, the document, the selection and the undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) { + return ok(); + } + s.edit("Ungroup", |d, sel| { + let mut new_sel = vec![]; + let mut ungrouped = vec![]; + for id in &ids { + let Some(n) = d.node(*id) else { continue }; + let NodeKind::Group { children, clip } = &n.kind else { + new_sel.push(*id); + continue; + }; + let mut out = Vec::with_capacity(children.len()); + for (k, c) in children.iter().enumerate() { + let mut c = (**c).clone(); + // The clipping path keeps its paint (none, unless painted after Make). + if *clip + && k == 0 + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; + } + c.opacity *= n.opacity; + new_sel.push(c.id); + out.push(c); + } + ungrouped.push((*id, out)); + } + d.insert_beside(ungrouped, true)?; + sel.set(new_sel); + Ok(()) + })?; + ok() +} + +pub(super) fn lock(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // With every selected object already locked, the document and its undo history stay as they + // are, and the objects are deselected. + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) { + s.select(|_, sel| sel.clear())?; + return ok(); + } + s.edit("Lock", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.locked = true; + } + } + sel.clear(); + Ok(()) + })?; + ok() +} + +fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if !n.is_layer() && f(n) { + v.push(n.id) + } + }); + v +} + +pub(super) fn unlock_all(s: &mut Session, _: &Value) -> Result { + let ids = collect_flag(&s.doc()?.doc, |n| n.locked); + // With nothing locked, the document, the selection and the undo history stay as they are. + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } + s.edit("Unlock All", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.locked = false; + } + } + sel.set(ids.iter().copied()); + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +pub(super) fn hide(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // With every selected object already hidden, the document and its undo history stay as they + // are, and the objects are deselected. + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) { + s.select(|_, sel| sel.clear())?; + return ok(); + } + s.edit("Hide", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.visible = false; + } + } + sel.clear(); + Ok(()) + })?; + ok() +} + +pub(super) fn show_all(s: &mut Session, _: &Value) -> Result { + let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); + // With nothing hidden, the document, the selection and the undo history stay as they are. + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } + s.edit("Show All", |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.visible = true; + } + } + sel.set(ids.iter().copied()); + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id") + .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) + .ok_or_else(|| bad("object.isolate", "missing id"))?; + let guides_locked = s.menu.guides_locked; + let st = s.doc_mut()?; + // Groups, layers and single objects (a path, a compound path, an image…); type isn't + // isolated: a double-click on it edits its text. + if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) { + return Err(bad("object.isolate", "type objects can't be isolated")); + } + let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + // A hidden or locked object, one in a hidden or locked group or layer, and a guide path while + // Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them. + // A Shaper Group isolates its source group, so that group is the one checked. + if !st.doc.is_editable(isolation) { + return Err(bad("object.isolate", "the object is hidden or locked")); + } + if guides_locked && crate::is_guide_path(&st.doc, isolation) { + return Err(EngineError::Other("guides are locked".into())); + } + st.isolation = Some(isolation); + st.selection.clear(); + // New art on a locked artboard, now deselected, is protected. + st.sync_new_art(); + st.revision += 1; + ok() +} + +pub(super) fn exit_isolation(s: &mut Session, _: &Value) -> Result { + let menu = s.menu.clone(); + let st = s.doc_mut()?; + if let Some(i) = st.isolation.take() { + crate::cmd::distortcmds::finish_edit_text(st, i); + let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); + st.selection.set([target]); + // A guide path isolated while Lock Guides is on isn't selected (#1534). + crate::deselect_hidden(&menu, &st.doc, &mut st.selection); + // The art on a locked artboard can't be selected. + st.protect_locked_artboards(&[], &Default::default(), None); + } + st.revision += 1; + ok() +} diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs new file mode 100644 index 000000000..073fda3dd --- /dev/null +++ b/crates/engine/src/cmd/object/bounds.rs @@ -0,0 +1,254 @@ +use super::spans::Spans; +use super::transform::{TRANSFORM_SWITCHES, about, apply_transform, scaling_options, transforms_objects}; +use super::*; + +pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result { + match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? { + Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b), + Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false), + } +} + +/// What `object.setBounds` does with its values ([`bounds_transform`]). +enum Bounds { + /// With anchors selected with Direct Selection: the objects and the box of the anchors that + /// [`set_anchor_bounds`] moves or scales. + Anchors(Vec, Rect), + /// The objects and the transform that gives them the values. + Objects(Vec, Affine), +} + +/// A previewed `object.setBounds` that transforms objects runs as `object.transform` with the +/// transform [`bounds_transform`] finds for it, so the interaction commits that transform (Transform +/// Again repeats it, and the journal holds it). A bounding-box handle drag with Use Preview Bounds +/// previews `object.setBounds`. The objects are those `object.transform` transforms (a path of a +/// compound path selected on its own is one), and the art keeps at least 1e-4 of its width and +/// height, as with [`vectorcraft_tools::bbox::scale_for_drag`]. `None` with anchors selected with +/// Direct Selection and when the values are not valid. +pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { + if cmd != "object.setBounds" { + return None; + } + let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None }; + let mut p = params.as_object().cloned().unwrap_or_default(); + for k in ["x", "y", "width", "height", "reference", "proportional"] { + p.remove(k); + } + p.insert("matrix".into(), json!(xf.as_coeffs())); + Some(("object.transform".to_string(), Value::Object(p))) +} + +/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the +/// art with a width or height below `least` of the one it has keeps `least` of it. +fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64) -> Result { + check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?; + let st = s.doc()?; + check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; + check_switches(p, &["proportional"], "object.setBounds")?; + if transforms_objects(p) + && !bool_or(p, "copy", false) + && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + { + return Ok(Bounds::Anchors(ids, b)); + } + let d = &s.doc()?.doc; + // Sizes are measured square to the bounding box, which a rotation turns with the objects: + // the math below runs in its frame. + let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + let g = frame.rect; + // With Use Preview Bounds the values measure the visual box as drawn, whose margins around the + // geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of + // `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their + // weight when an instance scales, so the rest of the margins stay. + let (v, vs) = if s.prefs.use_preview_bounds { + (effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) + } else { + (g, g) + }; + let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1]; + let (m, all) = (margins(vs), margins(v)); + let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]]; + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; + let rp = vectorcraft_geom::reference_point(v, refi); + let (w, h) = wanted_size(p, v); + // The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is + // exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the + // scales where it misses. + let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); + let ratio = |size: f64, geo: f64, margin: f64| if geo > 1e-9 { (size - margin) / geo } else { 1.0 }; + let f = |sx: f64, sy: f64| if strokes { (sx * sy).abs().sqrt() } else { 1.0 }; + // The strokes' scale `f0`: the size left after the margins in symbols' art, which stay, over + // `vs`, whose margins scale. + let (lx, ly) = (ratio(w - fixed[0] - fixed[2], vs.width(), 0.0), ratio(h - fixed[1] - fixed[3], vs.height(), 0.0)); + let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 }); + // When a size of 0 or more is smaller than the margins and some of them stay, the geometry + // gets no width or height: the visual box is then as small as it can be. The margins in + // symbols' art stay, and so does every margin when strokes do not scale. + let fit = |size: f64, geo: f64, scaling: f64, kept: f64| { + let s = ratio(size, geo, scaling * f0 + kept); + let stays = if strokes { kept } else { scaling + kept }; + if s < 0.0 && size >= 0.0 && stays > 0.0 { 0.0 } else { s } + }; + let sx = fit(w, g.width(), m[0] + m[2], fixed[0] + fixed[2]); + let sy = fit(h, g.height(), m[1] + m[3], fixed[1] + fixed[3]); + let scale = about(rp, Affine::scale_non_uniform(sx, sy)); + // Move the new visual box's reference point to x, y (default: where it was): the geometric + // box's reference point moves with the art, the margin from it to `vs`'s by `f`, and the + // margin from that one to the visual box's stays. + let gp = vectorcraft_geom::reference_point(g, refi); + let sp = vectorcraft_geom::reference_point(vs, refi); + let new_rp = scale * gp + (sp - gp) * f(sx, sy) + (rp - sp); + // x, y place the reference point on the page. + let page = frame.to_doc() * rp; + // When every value given is the current one, the transform is the identity. Computing it can + // leave a rounding error in it on a rotated object or with Use Preview Bounds. + let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now); + if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) { + return Ok(Bounds::Objects(ids, Affine::IDENTITY)); + } + let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); + // The scales and the point above can miss the size or the reference point when the margins of + // the objects differ, the members of groups and the objects in symbols' art included, and with + // strokes that scale, when the sizes are out of proportion. The objects are then measured one + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are + // measured as drawn after each scale tried instead ([`refit_drawn`]). + let symbols = d.symbol_boxes(); + let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n)); + let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { + None + } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { + let options = scaling_options(s, p); + let to_local = frame.to_doc().inverse(); + let measure = |sx: f64, sy: f64| { + let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy))); + let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() }; + ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { + let mut n = n.clone(); + n.transform(xf, sc); + vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local)) + }) + }; + // The scales above keep the effects' reach as a margin that stays, and give 0 for a size + // smaller than that reach. Strokes and effects that scale shrink the reach with the art, so + // the search then starts from the size wanted over the size as drawn. + let seed = |s: f64, size: f64, now: f64| if s <= 0.0 && size > 0.0 && now > 1e-9 { size / now } else { s }; + refit_drawn(measure, refi, [w, h], (seed(sx, w, v.width()), seed(sy, h, v.height())), [g.width() > 1e-9, g.height() > 1e-9]) + } else { + Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp)) + }; + let (sx, sy, new_rp) = solved.unwrap_or((sx, sy, new_rp)); + // A scale below `least` of art with a width (height) keeps `least`, and the reference point + // moves with the scales as above. + let keep = |s: f64, geo: f64| if geo > 1e-9 && (0.0..least).contains(&s) { least } else { s }; + let (kx, ky) = (keep(sx, g.width()), keep(sy, g.height())); + let scale = about(rp, Affine::scale_non_uniform(kx, ky)); + let new_rp = if (kx, ky) == (sx, sy) { new_rp } else { scale * gp + (sp - gp) * f(kx, ky) + (rp - sp) }; + let xf = frame.conjugate(Affine::translate(to - new_rp) * scale); + Ok(Bounds::Objects(ids, xf)) +} + +/// The scales along x and y that give the box `measure` returns for them the size `size`, and point +/// `refi` of that box: the point that goes to the reference point's place. The search starts at +/// `start`. Each step measures how the width and the height change with each scale that `free` +/// marks true, by measuring again with that scale moved by 1e-4 of it (at least 1e-6), and moves +/// those scales by the least-squares solution of that linear change, never below 0. The width can +/// change with the y scale and the height with the x scale (a geometry effect can turn the art). A +/// scale that `free` marks false stays as it is. The search stops when both sizes are within 1e-9 +/// of the sizes wanted (of the largest of 1 and those sizes), when no scale moves, when `measure` +/// finds no box, or after 16 steps, and returns the scales that came nearest. `None` when `measure` +/// finds no box for `start`. +fn refit_drawn( + measure: impl Fn(f64, f64) -> Option, + refi: usize, + size: [f64; 2], + start: (f64, f64), + free: [bool; 2], +) -> Option<(f64, f64, Point)> { + let [w, h] = size; + let tolerance = 1e-9 * w.max(h).max(1.0); + let miss = |b: Rect| (b.width() - w).abs() + (b.height() - h).abs(); + let (mut sx, mut sy) = start; + let mut b = measure(sx, sy)?; + let mut best = (sx, sy, b); + for _ in 0..16 { + let f = [b.width() - w, b.height() - h]; + if f[0].abs() <= tolerance && f[1].abs() <= tolerance { + break; + } + // How the width and the height change with the x scale (`a`) and the y scale (`c`). + let change = |x: bool| -> Option<[f64; 2]> { + let (on, s) = if x { (free[0], sx) } else { (free[1], sy) }; + if !on { + return Some([0.0, 0.0]); + } + let ds = (s.abs() * 1e-4).max(1e-6); + let m = if x { measure(sx + ds, sy) } else { measure(sx, sy + ds) }?; + Some([(m.width() - b.width()) / ds, (m.height() - b.height()) / ds]) + }; + let (Some(a), Some(c)) = (change(true), change(false)) else { break }; + let dot = |u: [f64; 2], v: [f64; 2]| u[0] * v[0] + u[1] * v[1]; + let (aa, ac, cc, af, cf) = (dot(a, a), dot(a, c), dot(c, c), dot(a, f), dot(c, f)); + let det = aa * cc - ac * ac; + let (dx, dy) = if det > 1e-12 * aa * cc && det > 0.0 { + ((ac * cf - cc * af) / det, (ac * af - aa * cf) / det) + } else if aa >= cc && aa > 0.0 { + (-af / aa, 0.0) + } else if cc > 0.0 { + (0.0, -cf / cc) + } else { + break; + }; + let (nx, ny) = ((sx + dx).max(0.0), (sy + dy).max(0.0)); + if !(nx.is_finite() && ny.is_finite()) || (nx == sx && ny == sy) { + break; + } + let Some(next) = measure(nx, ny) else { break }; + (sx, sy, b) = (nx, ny, next); + if miss(b) < miss(best.2) { + best = (sx, sy, b); + } + } + let (sx, sy, b) = best; + Some((sx, sy, vectorcraft_geom::reference_point(b, refi))) +} + +/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or +/// with `proportional` follows the one given. +fn wanted_size(p: &Value, b: Rect) -> (f64, f64) { + let mut w = f64_or(p, "width", b.width()); + let mut h = f64_or(p, "height", b.height()); + if bool_or(p, "proportional", false) { + if p.get("width").is_some() && b.width() > 0.0 { + h = b.height() * w / b.width(); + } else if p.get("height").is_some() && b.height() > 0.0 { + w = b.width() * h / b.height(); + } + } + (w, h) +} + +/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of +/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only +/// the anchors, their handles with them, and those objects move or scale. +fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; + let rp = vectorcraft_geom::reference_point(b, refi); + let (w, h) = wanted_size(p, b); + // A single anchor, or anchors in a line, have no size to scale on that side. + let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 }; + let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height()))); + let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y)); + apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true) +} + +/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with +/// symbol instances measured without the strokes in their art +/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]). +fn scaling_box(d: &Document, ids: &[NodeId], frame: &OrientedBox) -> Option { + let symbols = d.symbol_boxes().without_art_strokes(); + let to_local = frame.to_doc().inverse(); + ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { + let b = if frame.angle == 0.0 { n.visual_bounds_with(&symbols) } else { n.bounds_in(&symbols, to_local, true) }; + vectorcraft_geom::union_opt(acc, b) + }) +} diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs new file mode 100644 index 000000000..1ac0b1102 --- /dev/null +++ b/crates/engine/src/cmd/object/commands.rs @@ -0,0 +1,287 @@ +use super::align::{align, can_distribute, distribute, distribute_spacing}; +use super::arrange::{Arrange, arrange, exit_isolation, group, hide, isolate, lock, send_to_current_layer, show_all, ungroup, unlock_all}; +use super::bounds::set_bounds; +use super::compound::{clip_edit, clip_make, clip_release, compound_make, compound_release}; +use super::props::set_props; +use super::shape::{expand_shape, set_live_shape}; +use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.transform", + "Transform", + [], + None, + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)", + has_doc, + transform + ), + cmd!( + "object.move", + "Move…", + ["Object", "Transform"], + Some("Cmd+Shift+M"), + "{ids?: [id…] (selected first; default: the selection), dx, dy, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + has_selection_or_guides, + move_cmd + ), + cmd!( + "object.rotate", + "Rotate…", + ["Object", "Transform"], + None, + "{ids?: [id…] (selected first; default: the selection), angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + has_selection, + rotate + ), + cmd!( + "object.scale", + "Scale…", + ["Object", "Transform"], + None, + "{ids?: [id…] (selected first; default: the selection), sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + has_selection, + scale + ), + cmd!( + "object.reflect", + "Reflect…", + ["Object", "Transform"], + None, + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + has_selection, + reflect + ), + cmd!( + "object.shear", + "Shear…", + ["Object", "Transform"], + None, + "{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + has_selection, + shear + ), + cmd!( + "object.transformAgain", + "Transform Again", + ["Object", "Transform"], + Some("Cmd+D"), + "{copy?, strokes?, corners?, patterns?, typeAreas?: bool (as object.transform's; default: the value the transform it repeats used, else object.transform's default)} the last transform's matrix on the selection (after a perspective move or scale: that plane-space transform, without these params)", + has_selection, + transform_again + ), + cmd!( + "object.nudge", + "Nudge", + [], + None, + "{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, along the x and y axes turned by the constrainAngle preference; else ruler guides, along the page axes)", + has_selection_or_guides, + nudge + ), + cmd!( + "object.arrange.bringToFront", + "Bring to Front", + ["Object", "Arrange"], + Some("Cmd+Shift+]"), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + |s, _| arrange(s, Arrange::Front) + ), + cmd!( + "object.arrange.bringForward", + "Bring Forward", + ["Object", "Arrange"], + Some("Cmd+]"), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + |s, _| arrange(s, Arrange::Forward) + ), + cmd!( + "object.arrange.sendBackward", + "Send Backward", + ["Object", "Arrange"], + Some("Cmd+["), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + |s, _| arrange(s, Arrange::Backward) + ), + cmd!( + "object.arrange.sendToBack", + "Send to Back", + ["Object", "Arrange"], + Some("Cmd+Shift+["), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + |s, _| arrange(s, Arrange::Back) + ), + cmd!( + "object.arrange.sendToCurrentLayer", + "Send to Current Layer", + ["Object", "Arrange"], + None, + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + send_to_current_layer + ), + cmd!( + "object.group", + "Group", + ["Object"], + Some("Cmd+G"), + "{ids?: [id…] (selected first; default: the selection)} → {id}", + has_selection, + group + ), + cmd!( + "object.ungroup", + "Ungroup", + ["Object"], + Some("Cmd+Shift+G"), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + ungroup + ), + cmd!( + "object.lock", + "Selection", + ["Object", "Lock"], + Some("Cmd+2"), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + lock + ), + cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all), + cmd!( + "object.hide", + "Selection", + ["Object", "Hide"], + Some("Cmd+3"), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + hide + ), + cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all), + cmd!( + "object.compoundPath.make", + "Make", + ["Object", "Compound Path"], + Some("Cmd+8"), + "{ids?: [id…] (selected first; default: the selection)}", + has_selection, + compound_make + ), + cmd!( + "object.compoundPath.release", + "Release", + ["Object", "Compound Path"], + Some("Cmd+Alt+Shift+8"), + "{ids?: [id…] (selected first; default: the selection)} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath", + has_selection, + compound_release + ), + cmd!( + "object.clippingMask.make", + "Make", + ["Object", "Clipping Mask"], + Some("Cmd+7"), + "{ids?: [id…] (selected first; default: the selection)} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn. Paint given to the clipping path later (paint commands with its id) shows: its fill behind the clipped art, its stroke over it → {id} of the clip group", + has_multi, + clip_make + ), + cmd!( + "object.clippingMask.release", + "Release", + ["Object", "Clipping Mask"], + Some("Cmd+Alt+7"), + "{ids?: [id…] (selected first; default: the selection)} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, with the paint it has (none unless painted after Make)", + has_selection, + clip_release + ), + cmd!( + "object.clippingMask.editContents", + "Edit Contents", + ["Object", "Clipping Mask"], + None, + "{} select the clipped art of the selected clip groups → {count}", + has_selection, + |s, _| clip_edit(s, false) + ), + cmd!( + "object.clippingMask.editMask", + "Edit Clipping Path", + ["Object", "Clipping Mask"], + None, + "{} select the clipping paths of the selected clip groups → {count}", + has_selection, + |s, _| clip_edit(s, true) + ), + cmd!( + "object.isolate", + "Enter Isolation Mode", + [], + None, + "{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused", + has_doc, + isolate + ), + cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation), + cmd!( + "object.setProps", + "Object Properties", + [], + None, + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", + has_doc, + set_props + ), + cmd!( + "object.align", + "Align", + ["Window", "Align"], + None, + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", + has_selection_or_guides, + align + ), + cmd!( + "object.distribute", + "Distribute", + ["Window", "Align"], + None, + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", + can_distribute, + distribute + ), + cmd!( + "object.distributeSpacing", + "Distribute Spacing", + ["Window", "Align"], + None, + "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", + can_distribute, + distribute_spacing + ), + cmd!( + "object.setBounds", + "Set Bounds", + [], + None, + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + has_selection, + set_bounds + ), + cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape), + cmd!( + "object.setLiveShape", + "Live Shape Properties", + [], + None, + "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", + has_selection, + set_live_shape + ), + ] +} diff --git a/crates/engine/src/cmd/object/compound.rs b/crates/engine/src/cmd/object/compound.rs new file mode 100644 index 000000000..66206ef55 --- /dev/null +++ b/crates/engine/src/cmd/object/compound.rs @@ -0,0 +1,221 @@ +use super::*; + +/// A closed contour smaller than this (in square document units) has no usable winding direction, +/// so Make Compound Path leaves its orientation alone rather than flipping it on noise. +const MIN_WINDING_AREA: f64 = 1e-6; + +pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) }; + let id = s.edit("Make Compound Path", |d, sel| { + d.node(backmost).ok_or(EngineError::NoNode(backmost))?; + let mut units: Vec>> = vec![]; + let mut appearance = None; + for id in &ids { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) { + return Err(EngineError::Other("compound paths can only contain paths".into())); + } + compound_members(n, &mut appearance, &mut units); + } + let cid = d.alloc_id(); + let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); + c.appearance = appearance.unwrap_or_default(); + let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?; + // The paths in front run against the backmost one, so where they overlap it they cut holes + // under the non-zero fill rule (#1197). A compound path's paths turn together, by its + // first: its own holes (a letter's counters) stay holes. + let direction = |n: &Node| { + n.path_data() + .and_then(|pd| pd.subpaths.first()) + .filter(|sp| sp.closed && sp.signed_area().abs() > MIN_WINDING_AREA) + .map_or(0.0, |sp| sp.signed_area().signum()) + }; + let first = |unit: &[Arc]| unit.first().map_or(0.0, |p| direction(p)); + let back = units.first().map_or(0.0, |u| first(u)); + for (i, unit) in units.into_iter().enumerate() { + let flip = i > 0 && back != 0.0 && first(&unit) == back; + for mut p in unit { + let pn = Arc::make_mut(&mut p); + pn.appearance = Appearance::default().into(); + if let NodeKind::Path { path, live, .. } = &mut pn.kind { + *live = None; + if flip { + path.reverse(); + } + } + ch.push(p); + } + } + d.wrap(&ids, |_| c)?; + sel.set([cid]); + Ok(cid) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint +/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of +/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A +/// group has no paint of its own to give: a group of outlined type made a compound path that drew +/// nothing. +fn compound_members(n: &Node, paint: &mut Option>, out: &mut Vec>>) { + match (&n.kind, n.children()) { + (NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]), + (NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()), + (_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)), + } + paint.get_or_insert_with(|| n.appearance.clone()); +} + +/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d` +/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)? +fn releases(n: &Node) -> bool { + match &n.kind { + NodeKind::Compound { .. } => true, + NodeKind::Path { path, .. } => path.subpaths.len() > 1, + _ => false, + } +} + +pub(super) fn compound_release(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // With no compound path selected, the document, the selection and the undo history stay as + // they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) { + return ok(); + } + s.edit("Release Compound Path", |d, sel| { + let mut out = vec![]; + let mut released = vec![]; + for id in &ids { + let Some(n) = d.node(*id).cloned().filter(releases) else { continue }; + let mut parts = vec![]; + match &n.kind { + NodeKind::Compound { children, .. } => { + for c in children { + let mut c = (**c).clone(); + c.appearance = n.appearance.clone(); + out.push(c.id); + parts.push(c); + } + } + // Each subpath becomes a path of its own with the path's paint, in the same + // stacking order; the first keeps the path's id. + NodeKind::Path { path, .. } => { + for (k, sp) in path.subpaths.iter().enumerate() { + let mut c = if k == 0 { n.clone() } else { d.reid(&n) }; + if let NodeKind::Path { path, live, .. } = &mut c.kind { + (*path, *live) = (PathData::single(sp.clone()), None); + } + out.push(c.id); + parts.push(c); + } + } + _ => {} + } + released.push((*id, parts)); + } + d.insert_beside(released, true)?; + sel.set(out); + Ok(()) + })?; + ok() +} + +/// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it +/// stays unpainted after Release). +pub(in super::super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> { + as_clipping_path(d.node_mut(top).ok_or(EngineError::NoNode(top))?) +} + +/// Turn path, compound path or text `c` into a clipping path (its paint goes). +pub(in super::super) fn as_clipping_path(c: &mut Node) -> Result<()> { + if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into())); + } + c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into(); + match &mut c.kind { + NodeKind::Path { clipping, .. } => *clipping = true, + NodeKind::Text(t) => { + for r in &mut t.runs { + (r.style.fill, r.style.stroke) = (Paint::None, Paint::None); + } + } + _ => {} + } + Ok(()) +} + +/// Stop clip group or clipped layer `id` clipping; its clipping path stays, with its paint. +pub(in super::super) fn release_clip(d: &mut Document, id: NodeId) { + let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return }; + n.set_clips(false); + let clip = n.children().and_then(|c| c.first()).map(|c| c.id); + if let Some(c) = clip.and_then(|c| d.node_mut(c)) + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; + } +} + +pub(super) fn clip_make(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let Some(&top) = ids.last() else { return Err(EngineError::Other("nothing selected".into())) }; + let gid = s.edit("Make Clipping Mask", |d, sel| { + make_clipping_path(d, top)?; + let gid = d.alloc_id(); + // The topmost object, the clipping path, goes first, and the others follow in paint order. + d.wrap(&ids, |mut children| { + children.rotate_right(1); + Node::new(gid, NodeKind::Group { children, clip: true }) + })?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": gid.0 })) +} + +pub(super) fn clip_release(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // With no clip group selected, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) { + return ok(); + } + s.edit("Release Clipping Mask", |d, _| { + for id in &ids { + release_clip(d, *id); + } + Ok(()) + })?; + ok() +} + +/// Selects the clipping path (`mask`) or the clipped contents of every selected clip group (or of the +/// clip group containing a selected object), like Illustrator's Edit Contents / Edit Clipping Path toggle. +pub(super) fn clip_edit(s: &mut Session, mask: bool) -> Result { + let st = s.doc()?; + let mut ids = vec![]; + for id in st.selection.objects.iter().copied() { + let mut cur = Some(id); + while let Some(c) = cur { + if let Some(NodeKind::Group { children, clip: true }) = st.doc.node(c).map(|n| &n.kind) { + if mask { + ids.extend(children.first().map(|c| c.id)); + } else { + ids.extend(children.iter().skip(1).map(|c| c.id)); + } + break; + } + cur = st.doc.position(c).and_then(|(par, _, _)| par); + } + } + ids.dedup(); + if ids.is_empty() { + return Err(EngineError::Other("select a clip group".into())); + } + s.select(|_, sel| sel.set(ids.iter().copied()))?; + Ok(json!({ "count": ids.len() })) +} diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs new file mode 100644 index 000000000..7f566ef4f --- /dev/null +++ b/crates/engine/src/cmd/object/props.rs @@ -0,0 +1,128 @@ +use super::*; + +/// The most data entries `object.setProps` takes at once. +const MAX_DATA: usize = 256; + +/// `object.setProps`'s `data`: each key (a data-* attribute's name: letters, digits, `-`, `_`, +/// `.`) with its value, or `None` to remove it. +fn data_param(m: &serde_json::Map) -> Result)>> { + const C: &str = "object.setProps"; + if m.len() > MAX_DATA { + return Err(bad(C, format!("data takes at most {MAX_DATA} keys at once"))); + } + m.iter() + .map(|(k, v)| { + let k = k.strip_prefix("data-").unwrap_or(k); + if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || k == "name" || k.starts_with("vc-") { + return Err(bad(C, format!("data key `{k}`: letters, digits, -, _ and . (not name or vc-…)"))); + } + let v = match v { + Value::Null => None, + Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + }; + Ok((k.to_string(), v)) + }) + .collect() +} + +pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?; + check_numbers(p, &["opacity"], "object.setProps")?; + let ids = targets(s, p)?; + let opacity = p.get("opacity").and_then(Value::as_f64).map(percent); + let blend = match str_param(p, "blend") { + Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?), + None => None, + }; + let data = match p.get("data") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(data_param(m)?), + Some(v) => return Err(bad("object.setProps", format!("data must be an object of key: value, not {v}"))), + }; + let knockout = match p.get("knockout") { + Some(v) => Some( + Knockout::from_value(v) + .ok_or_else(|| bad("object.setProps", format!("knockout must be \"on\", \"off\", \"neutral\" or a bool, not {v}")))?, + ), + None => None, + }; + let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true); + // When every object already has every value given, the document and its undo history stay as + // they are. + let flag = |k: &str| p.get(k).and_then(Value::as_bool); + let has_values = |n: &Node| { + str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty())) + && flag("visible").is_none_or(|v| n.visible == v) + && flag("locked").is_none_or(|v| n.locked == v) + && opacity.is_none_or(|v| n.opacity == v) + && blend.is_none_or(|b| n.blend == b) + && flag("isolate").is_none_or(|v| n.isolate == v) + && knockout.is_none_or(|k| n.knockout == k) + && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) + && data.iter().flatten().all(|(k, v)| { + n.extras().attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref() + }) + }; + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + // Hidden or locked objects can't stay selected. + if hides { + s.select(|d, sel| sel.deselect_uneditable(d))?; + } + return ok(); + } + s.edit("Object Properties", |d, sel| { + for id in &ids { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + if let Some(v) = str_param(p, "name") { + n.name = if v.is_empty() { None } else { Some(v.to_string()) }; + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + n.visible = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + n.locked = v; + } + if let Some(v) = opacity { + n.opacity = v; + } + if let Some(b) = blend { + n.blend = b; + } + if let Some(v) = p.get("isolate").and_then(Value::as_bool) { + n.isolate = v; + } + if let Some(k) = knockout { + n.knockout = k; + } + if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) { + n.knockout_shape = v; + } + if let Some(changes) = &data { + n.edit_attrs(|a| { + for (k, v) in changes { + match (a.data.iter().position(|(key, _)| key == k), v) { + (Some(i), Some(v)) => { + if let Some(e) = a.data.get_mut(i) { + e.1 = v.clone(); + } + } + (Some(i), None) => { + a.data.remove(i); + } + (None, Some(v)) => a.data.push((k.clone(), v.clone())), + (None, None) => {} + } + } + }); + } + } + // Hidden or locked objects can't stay selected. + if hides { + sel.deselect_uneditable(d); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs new file mode 100644 index 000000000..e29591b5a --- /dev/null +++ b/crates/engine/src/cmd/object/shape.rs @@ -0,0 +1,262 @@ +use super::spans::Span; +use super::*; + +/// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`, +/// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across +/// together, as the closed form in [`set_bounds`] does, and 0 when the spans reach farther than +/// `size` at the scale 0. +pub(super) fn scale_across(spans: &[Span], k: f64, size: f64) -> f64 { + let o = spans.iter().map(|s| s.lo).fold(f64::INFINITY, f64::min); + let across = spans.iter().map(|s| s.hi).fold(f64::NEG_INFINITY, f64::max) - o; + // How far the spans reach together after a scale by `s` about `o`, and how fast that changes + // with `s`: the far geometric end minus the near one, of the objects that reach farthest at the + // two ends. + let reach = |s: f64| { + let (mut start, mut end, mut slope_start, mut slope_end) = (f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.0); + for sp in spans { + let (a, b) = sp.scaled(o, s, k); + if a < start { + (start, slope_start) = (a, sp.lo - o); + } + if b > end { + (end, slope_end) = (b, sp.hi - o); + } + } + (end - start, slope_end - slope_start) + }; + if across.is_nan() || across <= 1e-9 { + return 1.0; + } + let least = reach(0.0).0; + if least.is_nan() || least > size { + return 0.0; + } + // `reach` is convex in `s` and at most `size` at 0, so it equals `size` at one largest scale. + // Newton's method, started at a larger scale, reaches it one linear piece of `reach` at a + // time; bisection finishes the search when a step makes no progress. + let (mut lo, mut hi) = (0.0, size / across); + for _ in 0..64 { + if reach(hi).0 >= size { + break; + } + hi = 2.0 * hi + 1.0; + } + for _ in 0..64 { + let (r, slope) = reach(hi); + if r <= size { + return hi; + } + let next = hi - (r - size) / slope; + if next.is_nan() || next <= lo || next >= hi { + break; + } + hi = next; + } + for _ in 0..64 { + let mid = lo + (hi - lo) / 2.0; + if mid <= lo || mid >= hi { + break; + } + if reach(mid).0 <= size { + lo = mid; + } else { + hi = mid; + } + } + if size - reach(lo).0 <= reach(hi).0 - size { lo } else { hi } +} + +pub(super) fn expand_shape(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + // With no live shape selected, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) { + return ok(); + } + s.edit("Expand Shape", |d, _| { + for id in &ids { + if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *live = None; + } + } + Ok(()) + })?; + ok() +} + +/// The `corners` of `object.setLiveShape`: anchor indices of a path's uncut outline. +fn corners_param(p: &Value) -> Result>> { + let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) }; + let err = || bad("object.setLiveShape", format!("`corners` must list anchor indices (0, 1, 2…), not {v}")); + let list = v.as_array().ok_or_else(err)?; + list.iter().map(|k| k.as_u64().and_then(|k| usize::try_from(k).ok()).ok_or_else(err)).collect::>().map(Some) +} + +pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?; + check_numbers(p, &["radius", "sides"], "object.setLiveShape")?; + // `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them. + let items = match p.get("items").filter(|v| !v.is_null()) { + None => None, + Some(Value::Array(a)) => Some( + a.iter() + .map(|it| { + let id = it + .get("id") + .and_then(Value::as_u64) + .map(NodeId) + .ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?; + Ok((id, corners_param(it)?)) + }) + .collect::>>()?, + ), + Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))), + }; + let ids = match &items { + Some(items) => items.iter().map(|(id, _)| *id).collect(), + None => targets(s, p)?, + }; + let shared = corners_param(p)?; + let corners_of = |id: NodeId| match &items { + Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()), + None => shared.clone(), + }; + let kind = p + .get("kind") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()) + .map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) + }) + .transpose()?; + let radius = p.get("radius").and_then(Value::as_f64); + let relative_radii = p.get("relativeRadii").and_then(Value::as_bool); + let sides = p.get("sides").and_then(Value::as_u64); + // A number that must be `what` (None when absent). + let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && ok(*x)) + .map(Some) + .ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))), + } + }; + let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); + let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); + // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). + let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); + let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); + // A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal. + let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?); + let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true); + // An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole + // ellipse as 0° to 360°). + let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; + let pie_of = |pie: (f64, f64)| { + let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); + if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie } + }; + // When every value given is the current one, the document and its undo history stay as they + // are. + let st = s.doc()?; + let has_values = |id: &NodeId| { + let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; + match live.as_deref() { + Some(l @ LiveShape::Polygon { sides: now, .. }) + if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) + && (!equal || l.polygon_sides_equal()) + && polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a)) + && side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) => + { + return false; + } + Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false, + _ => {} + } + if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) { + return false; + } + if radius.is_none() && kind.is_none() { + return true; + } + let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true }; + let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); + // Setting corners folds a scale left in a rectangle's transform into its size, which + // changes it. + live.as_deref().is_none_or(|l| l.folded() == *l) + && picked.iter().all(|k| { + *k < c.base.anchor_count() + && radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9) + && kind.is_none_or(|kd| c.kind(*k) == kd) + }) + }; + if ids.iter().all(has_values) { + return ok(); + } + s.edit("Live Shape", |d, sel| { + for id in &ids { + let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut() + && (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal) + { + if let Some(n) = sides { + l.set_sides(n); + } + if equal { + l.make_sides_equal(); + } + if let Some(a) = polygon_angle { + l.set_polygon_angle(a); + } + // A side length sets the radius that gives it (with the new side count). + if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) { + l.set_polygon_radius(r); + } + *path = l.to_path(); + } + if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut() + && (pie_start.is_some() || pie_end.is_some() || invert) + { + if let LiveShape::Ellipse { pie, .. } = l { + *pie = pie_of(*pie); + } + *path = l.to_path(); + } + if let Some(on) = relative_radii + && let Some(l) = live.as_deref_mut() + { + convert_radii_relative(l, on); + *path = l.to_path(); + } + if radius.is_none() && kind.is_none() { + continue; + } + let partial = sel.anchors.get_mut(id); + // The corners to edit, the Direct-Selected ones and the anchor layout before the edit. + let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue }; + let corners = corners_of(*id); + let picked = match &corners { + Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => { + let n = c.base.anchor_count(); + let past: Vec<_> = k.range(n..).collect(); + return Err(bad("object.setLiveShape", format!("object {id} has anchors 0–{} for `corners`, not {past:?}", n.saturating_sub(1)))); + } + Some(k) => k.clone(), + None => c.picked(partial.as_deref()), + }; + let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()); + set_corners(path, live, &picked, radius, kind); + // Direct-Selected corners stay selected as they gain or lose anchors. + if let (Some(anchors), Some(selected)) = (partial, selected) + && let Some(c) = LiveCorners::new(path, live.as_deref()) + && c.sources() != layout + { + *anchors = c.anchors_of(&selected); + } + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/object/spans.rs b/crates/engine/src/cmd/object/spans.rs new file mode 100644 index 000000000..48bc9f28d --- /dev/null +++ b/crates/engine/src/cmd/object/spans.rs @@ -0,0 +1,233 @@ +use super::shape::scale_across; +use super::*; + +/// One axis of an object that [`set_bounds`] sizes, square to its frame: the geometric box runs +/// from `lo` to `hi`, and the visual box reaches past each end (before, after) by a margin that +/// scales with the strokes and one that stays. +#[derive(Clone, Copy, Debug)] +pub(super) struct Span { + pub(super) lo: f64, + pub(super) hi: f64, + scaling: [f64; 2], + kept: [f64; 2], +} + +impl Span { + /// The ends of the visual box after a scale by `s` about `c`, with the margins that scale + /// grown by `k`. A negative scale turns the box over, and its margins with it. + pub(super) fn scaled(self, c: f64, s: f64, k: f64) -> (f64, f64) { + let ([sb, sa], [kb, ka]) = (self.scaling, self.kept); + let (before, after) = (sb * k + kb, sa * k + ka); + let (a, b) = (c + s * (self.lo - c), c + s * (self.hi - c)); + if s < 0.0 { (b - after, a + before) } else { (a - before, b + after) } + } +} + +/// The objects that [`set_bounds`] sizes, measured one by one square to its frame. +pub(super) struct Spans { + x: Vec, + y: Vec, + /// Whether strokes scale. The margins that scale grow by the square root of the product of the + /// two scales; with strokes that do not scale, every margin stays. + strokes: bool, +} + +impl Spans { + /// The objects of `ids` that have a box, measured one by one as [`Document::oriented_bounds`] + /// measures them square to `frame`, each plain group member by member and each symbol instance + /// that [`open`] opens object by object in its art. Past [`MAX_SPANS`] parts, each object of + /// `ids` is measured as one. The margins of the visual box without the strokes in symbols' art + /// ([`scaling_box`]) scale with the strokes, and the rest stay. With `strokes` false, every + /// margin stays. + pub(super) fn measure(d: &Document, ids: &[NodeId], frame: &OrientedBox, strokes: bool) -> Self { + let (symbols, without, art) = (d.symbol_boxes(), d.symbol_boxes().without_art_strokes(), d.symbol_boxes().quick()); + let to_local = frame.to_doc().inverse(); + let measure = |n: &Node, s: &vectorcraft_doc::SymbolBoxes, visual: bool| match (frame.angle == 0.0, visual) { + (true, false) => n.geometric_bounds_with(s), + (true, true) => n.visual_bounds_with(s), + (false, _) => n.bounds_in(s, to_local, visual), + }; + let roots: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect(); + let mut parts = vec![]; + if !roots.iter().all(|n| open(n, 0.0, d, &symbols, to_local, &mut parts)) { + parts = roots.iter().map(|n| Part::Object(n, 0.0)).collect(); + } + let mut out = Self { x: vec![], y: vec![], strokes }; + for part in parts { + let (g, v, vs) = match part { + Part::Object(n, grow) => { + let Some(g) = measure(n, &symbols, false) else { continue }; + let v = measure(n, &symbols, true).unwrap_or(g).inflate(grow, grow); + let vs = if strokes { measure(n, &without, true).unwrap_or(g).inflate(grow, grow) } else { g }; + (g, v, vs) + } + // Measured as the instance's own box measures its art (`Node::visual_bounds_with`): + // the strokes at their own weight, and the instances in it by their art boxes. + Part::Art(n, xf, grow, kept) => { + let mut placed = n.clone(); + placed.transform(xf, false); + let Some(g) = placed.geometric_bounds_with(&art) else { continue }; + let v = placed.visual_bounds_with(&art).map_or(g, |v| v.union(g)).inflate(kept + grow, kept + grow); + let vs = if strokes { g.inflate(grow, grow) } else { g }; + (g, v, vs) + } + }; + out.x.push(Span { lo: g.x0, hi: g.x1, scaling: [g.x0 - vs.x0, vs.x1 - g.x1], kept: [vs.x0 - v.x0, v.x1 - vs.x1] }); + out.y.push(Span { lo: g.y0, hi: g.y1, scaling: [g.y0 - vs.y0, vs.y1 - g.y1], kept: [vs.y0 - v.y0, v.y1 - vs.y1] }); + } + out + } + + /// The visual box after a scale by `sx`, `sy` about `c`. + fn visual(&self, c: Point, sx: f64, sy: f64) -> Option { + let k = if self.strokes { (sx * sy).abs().sqrt() } else { 1.0 }; + let boxes = self.x.iter().zip(&self.y).map(|(x, y)| { + let ((x0, x1), (y0, y1)) = (x.scaled(c.x, sx, k), y.scaled(c.y, sy, k)); + Rect::new(x0, y0, x1, y1) + }); + boxes.reduce(|a, b| a.union(b)) + } + + /// The scales along x and y that give the visual box the size `size` with a scale about `c`, + /// or come as near to it as the margins that stay allow ([`Self::solve`]), and the point that + /// goes to the reference point's place: point `refi` of the visual box they give. Returns + /// `None`, which keeps `closed` (the scales and the point of [`set_bounds`]'s closed form), when + /// the scales of `closed` put point `refi` at the point of `closed` and either give the size + /// `size` or are the scales [`Self::solve`] finds, to within rounding (1e-12 of the largest + /// value compared, or of 1). + pub(super) fn refit(&self, c: Point, refi: usize, size: [f64; 2], closed: (f64, f64, Point)) -> Option<(f64, f64, Point)> { + let ([w, h], (sx, sy, at)) = (size, closed); + let b = self.visual(c, sx, sy)?; + let p = vectorcraft_geom::reference_point(b, refi); + let tolerance = 1e-12 * [b.x0, b.y0, b.x1, b.y1, w, h, at.x, at.y].iter().fold(1.0, |m: f64, v| m.max(v.abs())); + let near = |a: f64, b: f64| (a - b).abs() <= tolerance; + let placed = near(p.x, at.x) && near(p.y, at.y); + if placed && near(b.width(), w) && near(b.height(), h) { + return None; + } + let (tx, ty) = self.solve(w, h); + let same = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0); + if placed && same(tx, sx) && same(ty, sy) { + return None; + } + let b = self.visual(c, tx, ty)?; + Some((tx, ty, vectorcraft_geom::reference_point(b, refi))) + } + + /// The scales of 0 or more along x and y that give the visual box the size `w` × `h`, each + /// found by [`scale_across`]. With strokes that scale, the margins that scale grow by `k`, + /// found where it is the square root of the product of the two scales. + fn solve(&self, w: f64, h: f64) -> (f64, f64) { + let at = |k: f64| (scale_across(&self.x, k, w), scale_across(&self.y, k, h)); + if !self.strokes { + return at(1.0); + } + // The scales get smaller as `k` grows, so the square root of their product minus `k` is at + // least 0 at `k` 0 and at most 0 at `k` equal to that root. Bisection finds the `k` between + // the two where it is 0. + let (sx, sy) = at(0.0); + let (mut lo, mut hi) = (0.0, (sx * sy).sqrt()); + if hi.is_nan() || hi <= 0.0 { + return (sx, sy); + } + for _ in 0..200 { + let mid = lo + (hi - lo) / 2.0; + if mid <= lo || mid >= hi { + break; + } + let (sx, sy) = at(mid); + if (sx * sy).sqrt() > mid { + lo = mid; + } else { + hi = mid; + } + } + let miss = |k: f64| { + let (sx, sy) = at(k); + ((sx * sy).sqrt() - k).abs() + }; + at(if miss(lo) < miss(hi) { lo } else { hi }) + } +} + +/// Most parts [`Spans::measure`] opens the objects it sizes into: past it, each object is one part. +const MAX_SPANS: usize = 4096; + +/// What [`Spans::measure`] measures as one span. +enum Part<'a> { + /// An object of the selection, or a member of a plain group in it, and the outset of the + /// strokes of the plain groups around it, which scale with its own. + Object(&'a Node, f64), + /// An object in the art of a symbol instance and the transform that places it square to the + /// frame. The outset of the strokes of the instance and of the plain groups around the instance + /// scales with the strokes; the outset of the strokes of the plain groups around the object in + /// the art stays, as the strokes in the art do. + Art(&'a Node, Affine, f64, f64), +} + +/// Adds the parts that [`Spans::measure`] measures `n` by to `parts`, `grow` being the outset of +/// the strokes of the plain groups around it ([`Part::Object`]). A plain group ([`opens`]) adds the +/// parts of each member. A symbol instance without effects or an opacity mask of its own adds the +/// objects of its art, with plain groups there opened too ([`Part::Art`]), when its symbol's art +/// measures it, it is not in perspective, and its transform keeps the art square to the frame (a +/// turned or slanted instance is measured by the box around its turned art box). Anything else is +/// one part. Returns false when the parts pass [`MAX_SPANS`]. +fn open<'a>(n: &'a Node, grow: f64, d: &'a Document, symbols: &vectorcraft_doc::SymbolBoxes, to_local: Affine, parts: &mut Vec>) -> bool { + if opens(n) { + match &n.kind { + NodeKind::Group { children, .. } => { + let grow = grow + n.appearance.outset(); + return children.iter().all(|c| open(c, grow, d, symbols, to_local, parts)); + } + NodeKind::SymbolInstance { symbol, xf } => { + let xf = to_local * *xf; + if n.projection().is_none() + && square(xf) + && symbols.art_bounds(symbol).is_some() + && let Some(art) = d.symbols.iter().find(|s| s.name == *symbol) + { + return open_art(&art.art, xf, grow + n.appearance.outset(), 0.0, parts); + } + } + _ => {} + } + } + parts.push(Part::Object(n, grow)); + parts.len() <= MAX_SPANS +} + +/// [`open`] for `n` in symbol art that `xf` places square to the frame: a plain group adds the parts +/// of each member, and anything else, a symbol instance included, is one part. `kept` is the outset +/// of the strokes of the plain groups around `n` in the art, and `grow` that of the instance and the +/// plain groups around it. +fn open_art<'a>(n: &'a Node, xf: Affine, grow: f64, kept: f64, parts: &mut Vec>) -> bool { + if let NodeKind::Group { children, .. } = &n.kind + && opens(n) + { + let kept = kept + n.appearance.outset(); + return children.iter().all(|c| open_art(c, xf, grow, kept, parts)); + } + parts.push(Part::Art(n, xf, grow, kept)); + parts.len() <= MAX_SPANS +} + +/// Whether [`open`] opens `n`: a group without a clip, a Shaper construction, effects or an opacity +/// mask of its own, or a symbol instance without effects or an opacity mask of its own. +fn opens(n: &Node) -> bool { + let visible = |effects: &[vectorcraft_doc::Effect]| effects.iter().any(|e| e.visible); + let effects = visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible(i.effects())); + let kind = match n.kind { + NodeKind::Group { clip, .. } => !clip && n.extras().shaper.is_none(), + NodeKind::SymbolInstance { .. } => true, + _ => false, + }; + kind && !effects && n.extras().mask.is_none() +} + +/// Whether `a` takes boxes square to the frame to boxes square to it: it scales, mirrors, turns by a +/// quarter turn and moves, and nothing else, to within rounding. +fn square(a: Affine) -> bool { + let [m0, m1, m2, m3, ..] = a.as_coeffs(); + let tolerance = 1e-9 * [m0, m1, m2, m3].iter().fold(0.0, |m: f64, v| m.max(v.abs())); + m1.abs() <= tolerance && m2.abs() <= tolerance || m0.abs() <= tolerance && m3.abs() <= tolerance +} diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs new file mode 100644 index 000000000..ecb6b3a11 --- /dev/null +++ b/crates/engine/src/cmd/object/transform.rs @@ -0,0 +1,375 @@ +use super::align::choice; +use super::*; + +fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { + if let Some(o) = point_param(p, "origin") { + return Ok(o); + } + // Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre. + let st = s.doc()?; + if !bool_or(p, "copy", false) + && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + { + return Ok(b.center()); + } + s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) +} + +/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from +/// its `strokes` and `corners` params, else the preferences. The values join the command's journal +/// entry, so replaying it scales the same way whatever the preferences are then. +pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling { + let sc = scaling_options(s, p); + s.note_journal("strokes", json!(sc.strokes)); + s.note_journal("corners", json!(!sc.keep_corners)); + sc +} + +/// [`scaling`] without noting the values in the journal. +pub(super) fn scaling_options(s: &Session, p: &Value) -> Scaling { + let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); + let corners = bool_or(p, "corners", s.prefs.scale_corners); + Scaling { + strokes, + effects: strokes.then_some(vectorcraft_render::effects::scale_effect), + keep_type_strokes: !strokes, + keep_corners: !corners, + ..Scaling::default() + } +} + +/// Transform Patterns: do the pattern fills and strokes of `ids` transform with them (`patterns` +/// param, else General › Transform Pattern Tiles)? Noted in the journal when they use a pattern, +/// so a replay moves their tiles alike whatever the preference is then. +pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result { + let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles); + let d = &s.doc()?.doc; + let mut uses = false; + let paths = d.index_paths(ids.iter().copied()); + for n in ids.iter().filter_map(|id| paths.get(id)).filter_map(|p| d.node_at(p)) { + n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None)); + } + if uses { + s.note_journal("patterns", json!(on)); + } + Ok(on) +} + +/// Transform Objects (`objects` param, on unless it is false): off, a transform leaves the art +/// where it is and, with Transform Patterns, moves only its pattern tiles. +pub(crate) fn transforms_objects(p: &Value) -> bool { + bool_or(p, "objects", true) +} + +/// The on/off params of the transform commands that [`apply_transform`], [`scaling`], +/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file +/// check them with [`check_switches`] before they change anything. +pub(super) const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; + +/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. +pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { + if objects { + n.transform(xf, sc); + } else if sc.patterns { + vectorcraft_doc::pattern::transform_patterns_only(n, xf); + } +} + +/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]; +/// `patterns`: see [`transform_patterns`]; `objects`: see [`transforms_objects`]; `typeAreas`: +/// see [`resize_type_area`]). Outside an +/// interaction, records `xf` and the options it used for Transform Again. In an interaction whose +/// document is still its snapshot, a transform with `copy` keeps its copies ([`preview_copies`]), +/// and the next one that copies the same objects of the same snapshot transforms those copies. +/// +/// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no +/// copy, only those anchors transform, their handles with them, and the objects selected whole. +pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value, anchors: bool) -> Result { + apply_transform_moving_guides(s, label, ids, xf, p, anchors, None) +} + +/// [`apply_transform`], with the selected ruler guides moved by `guides` instead of by the +/// translation `xf` when given (a nudge along a turned Constrain Angle moves the art along it and +/// the guides along the page's axes). +fn apply_transform_moving_guides( + s: &mut Session, + label: &str, + ids: Vec, + xf: Affine, + p: &Value, + anchors: bool, + guides: Option, +) -> Result { + let copy = bool_or(p, "copy", false); + let areas = bool_or(p, "typeAreas", false); + let objects = transforms_objects(p); + let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; + sc.patterns = transform_patterns(s, p, &ids)?; + let c = xf.as_coeffs(); + let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; + let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); + let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; + let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); + // A transform of ids that name no object, or an identity transform without a copy, moves + // nothing: the document and its undo history stay as they are, and Transform Again repeats it. + // With a copy, the reply lists the copies, and there are none. + let d = &s.doc()?.doc; + let none = d.index_paths(ids.iter().copied()).is_empty() && !has_guides; + let ids = if anchors && objects && !copy && !s.doc()?.selection.anchors.is_empty() { + if xf != Affine::IDENTITY { + crate::cmd::path::transform_anchors(s, label, xf, sc)?; + } + ids + } else if none || (!copy && xf == Affine::IDENTITY) { + if copy { vec![] } else { ids } + } else { + let kept = if copy { preview_copies(s, &ids, with_guides)? } else { None }; + s.edit(label, |d, sel| { + let targets = match &kept { + // The document becomes the snapshot with the kept copies, untransformed, and the + // loop below transforms only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None if copy => { + let guide_copies = if with_guides { + let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + (!copies.is_empty()).then_some(copies) + } else { + None + }; + let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; + if let Some(copies) = guide_copies { + let first = d.guides.len(); + d.guides.extend(copies); + sel.guides = (first..d.guides.len()).collect(); + } + new_ids + } + None => ids.clone(), + }; + // Transforms don't move nodes in the tree, so the paths found up front stay right. + let paths = d.index_paths(targets.iter().copied()); + for id in &targets { + if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) + && !(objects && areas && resize_type_area(n, xf)) + { + transform_node(n, xf, sc, objects); + } + } + if with_guides && (delta.x != 0.0 || delta.y != 0.0) { + for i in sel.guides.clone() { + if let Some(g) = d.guides.get_mut(i) { + *g = g.moved(delta); + } + } + } + Ok(targets) + })? + }; + let st = s.doc_mut()?; + if st.interaction.is_none() { + let mut options = json!({ "copy": copy, "typeAreas": areas, "patterns": sc.patterns, "objects": objects }); + if Scaling::factor(xf).is_some() { + options["strokes"] = json!(sc.strokes); + options["corners"] = json!(!sc.keep_corners); + } + st.last_transform = Some(LastTransform::Matrix(xf, options)); + st.last_perspective = None; + } + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +/// Returns the copies of `roots` for a transform or a perspective edit with `copy` in an interaction +/// whose document is still its snapshot, as in a preview ([`Session::preview`] puts the snapshot +/// back first). These are the copies the interaction keeps when they were made of that snapshot for +/// the same `roots`; otherwise this copies `roots` with [`duplicate_in`] in a clone of the snapshot, +/// untransformed, and the interaction keeps that clone. It returns `None` outside an interaction, +/// and when the document is no longer the snapshot, as for a command run in a typing session or a +/// script batch after an earlier edit there: the command then copies the current document. +pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: bool) -> Result>> { + let st = s.doc_mut()?; + let Some(it) = &mut st.interaction else { return Ok(None) }; + if !Arc::ptr_eq(&st.doc, &it.doc) { + return Ok(None); + } + let has_guide_copies = with_guides && !st.selection.guides.is_empty(); + if let Some(k) = &it.copies + && Arc::ptr_eq(&k.from, &it.doc) + && k.roots == roots + && (k.selection.guides.is_empty() != has_guide_copies) + { + return Ok(Some(k.clone())); + } + let mut doc = it.doc.clone(); + let mut selection = st.selection.clone(); + let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; + if has_guide_copies { + let d = Arc::make_mut(&mut doc); + let first = d.guides.len(); + let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + d.guides.extend(copies); + selection.guides = (first..d.guides.len()).collect(); + } + let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); + it.copies = Some(k.clone()); + Ok(Some(k)) +} + +/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text +/// reflows at its size. False for anything else (and type in perspective), which transforms as +/// usual. +fn resize_type_area(n: &mut Node, xf: Affine) -> bool { + if n.extras().perspective.is_some() { + return false; + } + let NodeKind::Text(t) = &mut n.kind else { return false }; + crate::cmd::typecmd::reshape_area_with(t, |t| t.transform_area(xf)) +} + +pub(super) fn transform(s: &mut Session, p: &Value) -> Result { + check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; + check_switches(p, &["anchors"], "object.transform")?; + let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; + let (ids, anchors) = match ids_param(p, "ids") { + Some(v) => (outermost(&s.doc()?.doc, v), false), + None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), + }; + apply_transform(s, "Transform", ids, m, p, anchors) +} + +pub(super) fn move_cmd(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["dx", "dy"], "object.move")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.move")?; + let ids = selected_roots_to_transform(s)?; + apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true) +} + +/// The arrow keys' step `(dx, dy)` (page axes, y down) along the x and y axes turned by General › +/// Constrain Angle (`deg`, counter-clockwise on the page): with 30°, → moves 30° up from the +/// horizontal, as the reference app's arrow keys follow the constrain angle (users set it to −90° +/// to nudge along a view rotated by 90°: +/// ). +pub(crate) fn along_constrain_angle(dx: f64, dy: f64, deg: f64) -> (f64, f64) { + // Whole turns are the page's axes, exactly; quarter turns are exact too. + let Some(axes) = vectorcraft_geom::constrain_axes(deg) else { return (dx, dy) }; + let v = axes * Point::new(dx, dy); + (v.x, v.y) +} + +pub(super) fn nudge(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["dx", "dy"], "object.nudge")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?; + check_switches(p, &["big"], "object.nudge")?; + let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; + let (dx, dy) = (f64_or(p, "dx", 0.0) * k, f64_or(p, "dy", 0.0) * k); + // Guides keep to the page's axes; art and anchors move along the constrain angle's. + let (gx, gy) = (dx, dy); + let (dx, dy) = along_constrain_angle(dx, dy, s.prefs.constrain_angle); + if !s.doc()?.selection.anchors.is_empty() { + // When the anchors move by 0, the document and its undo history stay as they are. + if dx == 0.0 && dy == 0.0 { + return ok(); + } + return crate::cmd::path::move_anchors(s, &json!({ "dx": dx, "dy": dy, "patterns": p.get("patterns") })); + } + if s.doc()?.selection.is_empty() { + let copy = bool_or(p, "copy", false); + // When guides move by 0 without a copy, the document and its undo history stay as they are. + if gx == 0.0 && gy == 0.0 && !copy { + return ok(); + } + return crate::cmd::docmenu::guide_move(s, &json!({ "dx": gx, "dy": gy, "copy": copy })); + } + let ids = selected_roots_to_transform(s)?; + apply_transform_moving_guides(s, "Move", ids, Affine::translate((dx, dy)), p, true, Some(Vec2::new(gx, gy))) +} + +pub(super) fn about(o: Point, a: Affine) -> Affine { + Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) +} + +pub(super) fn rotate(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["angle"], "object.rotate")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?; + check_switches(p, &["absolute"], "object.rotate")?; + let ids = selected_roots_to_transform(s)?; + let o = origin_of(s, p, &ids)?; + let mut angle = f64_or(p, "angle", 0.0); + if bool_or(p, "absolute", false) { + angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids)); + } + // Angles are counter-clockwise; y is down, so negate. + let a = about(o, Affine::rotate(-angle.to_radians())); + apply_transform(s, "Rotate", ids, a, p, true) +} + +pub(super) fn scale(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["sy"], "object.scale")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?; + let ids = selected_roots_to_transform(s)?; + let o = origin_of(s, p, &ids)?; + let sx = f64_req(p, "sx", "object.scale")? / 100.0; + let sy = f64_or(p, "sy", sx * 100.0) / 100.0; + if sx == 0.0 || sy == 0.0 { + return Err(bad("object.scale", "scale must be non-zero")); + } + apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true) +} + +pub(super) fn reflect(s: &mut Session, p: &Value) -> Result { + check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; + let ids = selected_roots_to_transform(s)?; + let o = origin_of(s, p, &ids)?; + let m = match p.get("axis") { + Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), + Some(Value::Number(n)) => { + let t = -n.as_f64().unwrap_or(90.0).to_radians(); + Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) + } + None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0), + Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0), + Some(v) => { + let given = v.as_str().map_or_else(|| v.to_string(), str::to_string); + return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`"))); + } + }; + apply_transform(s, "Reflect", ids, about(o, m), p, true) +} + +pub(super) fn shear(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["angle"], "object.shear")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?; + let ids = selected_roots_to_transform(s)?; + let o = origin_of(s, p, &ids)?; + let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); + let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical"); + let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) }; + apply_transform(s, "Shear", ids, about(o, m), p, true) +} + +/// The last transform on the selection, with the options `last_transform` keeps for it; an option +/// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform +/// Each is repeated by running it again (its transform is per object, about each one's bounds). +pub(super) fn transform_again(s: &mut Session, p: &Value) -> Result { + check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?; + if let Some(again) = s.doc()?.last_perspective.clone() { + return crate::cmd::distortcmds::transform_again(s, &again); + } + let last = s.doc()?.last_transform.clone().ok_or_else(|| EngineError::Other("no previous transform".into()))?; + let merge = |mut options: Value| { + if let (Some(o), Some(given)) = (options.as_object_mut(), p.as_object()) { + o.extend(given.iter().filter(|(_, v)| v.is_boolean()).map(|(k, v)| (k.clone(), v.clone()))); + } + options + }; + match last { + LastTransform::Matrix(m, options) => { + let ids = selected_roots_to_transform(s)?; + apply_transform(s, "Transform Again", ids, m, &merge(options), true) + } + LastTransform::Each(params) => crate::cmd::menucmds::transform_each_again(s, &merge(params)), + } +} diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index a874bc88f..91579a1b9 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -179,7 +179,7 @@ impl<'a> Collector<'a> { /// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the /// copy of `source` finds it only by its name, beside it. fn renamed(&mut self, lines: &mut Vec, source: &str, path: &str, name: &str, copy: &str) { - if Path::new(copy).file_name().and_then(|n| n.to_str()) != Some(name) { + if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) { let message = format!( "{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview" ); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 2c527a820..b22d77ac9 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\"} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", has_doc, transparency ), @@ -504,6 +504,13 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - let ItemTarget::Item { index, explicit } = item else { + let ItemTarget::Item { index, explicit, aligned } = item else { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. @@ -759,7 +766,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result { } if opacity.is_some() || blend.is_some() { let ids: Vec = item_ids.iter().map(|id| id.0).collect(); - s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?; + s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend, "aligned": aligned }))?; } if q.keys().all(|k| k == "ids") { return ok(); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b2a2aaec9..5b814b10b 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -2,7 +2,7 @@ //! Character/Paragraph attributes. use serde_json::{Value, json}; -use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify}; +use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; use super::*; @@ -47,6 +47,15 @@ pub fn specs() -> Vec { has_doc, set_format ), + cmd!( + "text.convertListToText", + "Convert to Text", + [], + None, + "{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them; in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}", + has_doc, + convert_list_to_text + ), ] } @@ -642,3 +651,355 @@ fn set_format(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } + +/// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, +/// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it +/// and the numbered paragraphs the frame counts before it. +struct ParagraphPart { + frame: usize, + index: usize, + bytes: std::ops::Range, + list: Option, + marker: Option, + count: u32, +} + +/// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on +/// with the paragraph the frame before it ends in, which may be an empty part after a paragraph +/// break), and whether the call converts it (a part of it is in a paragraph the call names). +struct StoryParagraph { + parts: Vec, + converts: bool, +} + +impl StoryParagraph { + /// The part whose style a re-flowed thread gives the whole paragraph + /// (`vectorcraft_text::thread::story_paras`) and whose marker the layout draws: the first part + /// with text, else the last part. + fn main_part(&self) -> Option<&ParagraphPart> { + self.parts.iter().find(|p| !p.bytes.is_empty()).or(self.parts.last()) + } +} + +/// The style of the character at byte `b` of `t`, as the layout reads it: the run that holds `b`, +/// else the one before it, else the first. +fn char_style_at(t: &vectorcraft_doc::TextObject, b: usize) -> vectorcraft_doc::CharStyle { + let mut runs = vec![]; + let mut off = 0; + for r in &t.runs { + runs.push((off..off + r.text.len(), &r.style)); + off += r.text.len(); + } + let find = |b: usize| runs.iter().find(|(r, _)| r.contains(&b)).map(|(_, s)| (*s).clone()); + find(b).or_else(|| b.checked_sub(1).and_then(find)).unwrap_or_else(|| t.first_style()) +} + +/// The list marker that `lay` (the layout of `t`) draws before paragraph `index` (bytes `bytes`): +/// the width of its characters and the width of the tab after them. A paragraph the layout leaves +/// out (overset text) is laid out on its own, as a paragraph that starts there, numbered on from +/// `count`. +fn marker_extent( + t: &vectorcraft_doc::TextObject, + lay: &vectorcraft_text::TextLayout, + index: usize, + bytes: std::ops::Range, + count: u32, +) -> (f64, f64) { + let width = |lay: &vectorcraft_text::TextLayout, start: usize| { + lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| { + let advance = |g: usize| lay.glyphs.get(g).map_or(0.0, |g| g.advance); + (m.glyphs.clone().filter(|&g| g != m.tab).map(advance).sum::(), advance(m.tab)) + }) + }; + width(lay, bytes.start).unwrap_or_else(|| { + let mut probe = t.clone(); + probe.kind = vectorcraft_doc::TextKind::Point; + probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end); + probe.ruby.clear(); + probe.set_all_paras(t.para_at(index).clone()); + probe.list_count = count; + probe.first_para_continues = false; + width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0)) + }) +} + +/// Whether tab stop `tab` is at position `x`. +fn stop_at(tab: &vectorcraft_doc::TabStop, x: f64) -> bool { + (tab.position - x).abs() <= 1e-6 +} + +/// Whether `tabs` hold one stop at position `x`, left-aligned and without a leader. +fn plain_stop_at(tabs: &[vectorcraft_doc::TabStop], x: f64) -> bool { + let there: Vec<&vectorcraft_doc::TabStop> = tabs.iter().filter(|tab| stop_at(tab, x)).collect(); + matches!(there.as_slice(), [only] if only.align == vectorcraft_doc::TabAlign::Left && only.leader.is_empty()) +} + +/// Whether tab stop `tab` lies between positions `from` and `x` and is not left-aligned. +fn aligned_between(tab: &vectorcraft_doc::TabStop, from: f64, x: f64) -> bool { + tab.align != vectorcraft_doc::TabAlign::Left && tab.position > from + 1e-6 && tab.position < x - 1e-6 +} + +/// The frames of the story that type object `id` belongs to (its thread in thread order, else `id` +/// alone), each with the length of its text in bytes. +fn story_frames(s: &Session, id: NodeId) -> Vec<(NodeId, usize)> { + let Some(st) = s.active() else { return vec![] }; + let d = &st.doc; + let frames = d.text_threads.iter().find(|thread| thread.contains(&id)).cloned().unwrap_or_else(|| vec![id]); + frames + .into_iter() + .filter_map(|f| match d.node(f).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((f, t.plain_text().len())), + _ => None, + }) + .collect() +} + +/// After Convert to Text inserted `inserted` (each a frame, the byte it went in at and its length), +/// the Type tool's anchor and caret in frame `id` (`editing`, read before the call, in a story +/// whose frames had the lengths `before`) move past the characters inserted at or before them in +/// the story. In threaded type the caret stays in the frame it was in while its place is still +/// there, and else goes to the frame its character moved to, with the anchor in that frame; that +/// frame becomes the selection when it was not selected. +fn move_editing_selection( + s: &mut Session, + (id, anchor, caret): (NodeId, usize, usize), + before: &[(NodeId, usize)], + inserted: &[(NodeId, usize, usize)], +) -> Result<()> { + // Story positions: the bytes of the frames before a frame, then the byte in it. + let start_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().take_while(|(g, _)| *g != f).map(|(_, n)| n).sum::(); + let len_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().find(|(g, _)| *g == f).map_or(0, |(_, n)| *n); + let inserted: Vec<(usize, usize)> = + inserted.iter().filter(|(f, _, _)| before.iter().any(|(g, _)| g == f)).map(|&(f, at, n)| (start_of(before, f) + at, n)).collect(); + if inserted.is_empty() { + return Ok(()); + } + let story = |at: usize| { + let at = start_of(before, id) + at.min(len_of(before, id)); + at + inserted.iter().filter(|(b, _)| *b <= at).map(|(_, n)| n).sum::() + }; + let (anchor, caret) = (story(anchor), story(caret)); + let after = story_frames(s, id); + let fits = |f: NodeId| (start_of(&after, f)..=start_of(&after, f) + len_of(&after, f)).contains(&caret); + let frame = if fits(id) { Some(id) } else { after.iter().map(|(f, _)| *f).find(|f| fits(*f)) }; + let Some(frame) = frame else { return Ok(()) }; + let (start, len) = (start_of(&after, frame), len_of(&after, frame)); + let local = |at: usize| at.saturating_sub(start).min(len); + if frame == id { + s.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)})); + return Ok(()); + } + s.set_tool_option("textSelection", &json!({"editing": frame.0, "anchor": local(anchor), "caret": local(caret)})); + // The Type tool ends editing after a command when the frame it edits is not selected, itself + // or through a selected group, so that frame becomes the selection, as a click in it does. + let selected = s.active().is_some_and(|st| { + let mut cur = Some(frame); + while let Some(c) = cur { + if st.selection.contains(c) { + return true; + } + cur = st.doc.parent_of(c); + } + false + }); + if !selected { + s.select(|_, sel| sel.set([frame]))?; + } + Ok(()) +} + +/// Type › Bullets and Numbering › Convert to Text (`text.convertListToText`): the markers of the +/// list paragraphs the call names become characters of the text, and the lists are turned off. +/// While the Type tool edits, its typing ends first and its anchor and caret move past the +/// characters inserted at or before them ([`move_editing_selection`]). +fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.convertListToText"; + use vectorcraft_doc::text::ParaList; + let ids = super::typecmd::text_targets(s, p, C)?; + let range = super::typecmd::TextRange::parse(p, C)?; + let editing = match super::textedit::editing_range(s)? { + Some((id, _, _)) => { + let options = s.tool_options(); + let at = |k: &str| options.get(k).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + at("anchor").zip(at("caret")).map(|(anchor, caret)| (id, anchor, caret)) + } + None => None, + }; + let story_before = editing.map(|(id, _, _)| story_frames(s, id)).unwrap_or_default(); + let d = &s.doc()?.doc; + let text = |id: NodeId| match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.as_ref()), + _ => None, + }; + // Each story once: the frames of a thread in thread order, or one type object. + let mut stories: Vec> = vec![]; + for id in &ids { + if stories.iter().any(|story| story.contains(id)) { + continue; + } + let thread = d.text_threads.iter().find(|thread| thread.contains(id)); + stories.push(thread.map_or_else(|| vec![*id], |thread| thread.iter().copied().filter(|f| text(*f).is_some()).collect())); + } + // Per frame: the paragraphs whose list goes (with the style each gets) and the characters that + // go in at a byte. + type Stops = Vec<(usize, vectorcraft_doc::ParaStyle)>; + type Inserts = Vec<(usize, TextRun)>; + let mut edits: Vec<(NodeId, Stops, Inserts)> = vec![]; + let mut converted = 0; + for story in &stories { + let frames: Vec<&vectorcraft_doc::TextObject> = story.iter().filter_map(|id| text(*id)).collect(); + let mut paras: Vec = vec![]; + let mut named_parts = vec![]; + let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); + for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() { + let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 }; + let mut count = t.list_count; + let mut start = 0; + for (k, piece) in plain.split('\n').enumerate() { + let bytes = start..start + piece.len(); + start = bytes.end + 1; + let before = count; + let list = t.para_at(k).list; + let marker = ParaList::next_marker(list, piece.is_empty(), &mut count); + // A later frame's first paragraph goes on with the one the frame before it ends in. + if !(k == 0 && f > 0) || paras.is_empty() { + paras.push(StoryParagraph { parts: vec![], converts: false }); + } + if let Some(para) = paras.last_mut() { + para.parts.push(ParagraphPart { frame: f, index: k, bytes, list, marker, count: before }); + named_parts.push((paras.len() - 1, named.contains(&k))); + } + } + } + for (i, named) in named_parts { + if let Some(para) = paras.get_mut(i) { + let listed = para.main_part().is_some_and(|p| p.list.is_some()); + para.converts |= named && listed; + } + } + // Each converted paragraph's main part, whether the paragraph holds a tab of its own, and + // the paragraph. + let main_parts: Vec<(&ParagraphPart, bool, &StoryParagraph)> = paras + .iter() + .filter(|p| p.converts) + .filter_map(|p| { + let tabs = p.parts.iter().any(|q| plains.get(q.frame).and_then(|s| s.get(q.bytes.clone())).is_some_and(|s| s.contains('\t'))); + p.main_part().map(|m| (m, tabs, p)) + }) + .collect(); + let layouts: Vec> = frames + .iter() + .enumerate() + .map(|(f, t)| { + main_parts + .iter() + .any(|(p, _, _)| p.marker.is_some() && p.frame == f) + .then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)) + }) + .collect(); + let mut frame_edits: Vec<(Stops, Inserts)> = vec![(vec![], vec![]); frames.len()]; + for (para, own_tabs, story_para) in main_parts { + converted += 1; + let mut stop = None; + let mut direction = None; + if let (Some(marker), Some(t), Some(Some(lay))) = (¶.marker, frames.get(para.frame), layouts.get(para.frame)) { + // The marker's tab went to the left indent when it was ahead of the marker and no + // explicit stop came first, as the layout sets it; otherwise to the next tab stop, + // without the stop's leader, and for a right, center or decimal stop measured with + // the whole paragraph after it, where a converted tab measures the rest of its line. + // A plain left stop where the marker's tab ended takes the converted tab there: one + // at the left indent in the first case, and in the second, where a stop there or + // past it has a leader or is not left-aligned, or a right, center or decimal stop + // lies between the marker and that place. Those right, center and decimal stops + // between the marker and that place are removed. No other tab reaches a stop before + // the left indent, nor one past it in a paragraph with no tab of its own. + let style = t.para_at(para.index); + let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count); + let pen = style.left_indent + style.first_line_indent + width; + let indent = style.left_indent; + let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6); + let end = pen + gap; + let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x)); + if indent - pen > 1e-6 && !before { + stop = Some((pen, indent)).filter(|&(_, x)| needed(x)); + } else if (end < indent - 1e-6 || !own_tabs) + && style.tabs.iter().any(|tab| { + let past = tab.position > end - 1e-6 && (!tab.leader.is_empty() || tab.align != vectorcraft_doc::TabAlign::Left); + past || aligned_between(tab, pen, end) + }) + { + stop = Some((pen, end)).filter(|&(_, x)| needed(x)); + } + // A paragraph with no direction set runs the way its first strong character does. + // When the marker's characters would change that (a letter or a roman numeral + // before right-to-left text), the paragraph gets the direction it ran in. + if style.direction.is_none() { + let body = plains.get(para.frame).and_then(|s| s.get(para.bytes.clone())).unwrap_or_default(); + let was = vectorcraft_text::paragraph_is_rtl(body, None); + if vectorcraft_text::paragraph_is_rtl(&format!("{marker}\t{body}"), None) != was { + direction = Some(if was { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight }); + } + } + let run = TextRun { text: format!("{marker}\t"), style: ParaList::marker_style(&char_style_at(t, para.bytes.start)), inline: None }; + if let Some((_, inserts)) = frame_edits.get_mut(para.frame) { + inserts.push((para.bytes.start, run)); + } + } + // A paragraph in several frames is changed in its main part and in its empty parts: + // re-flowing the thread gives the other parts with text the main part's style. + let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue }; + style.list = None; + if direction.is_some() { + style.direction = direction; + } + // A plain left stop at `x`, in place of any stop there and of the right, center and + // decimal stops between the marker's end `from` and `x`. + if let Some((from, x)) = stop { + style.tabs.retain(|tab| !stop_at(tab, x) && !aligned_between(tab, from, x)); + let i = style.tabs.iter().position(|tab| tab.position > x).unwrap_or(style.tabs.len()); + let tab = vectorcraft_doc::TabStop { position: x, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' }; + style.tabs.insert(i, tab); + } + for part in story_para.parts.iter().filter(|q| std::ptr::eq(*q, para) || q.bytes.is_empty()) { + if let Some((stops, _)) = frame_edits.get_mut(part.frame) { + stops.push((part.index, style.clone())); + } + } + } + for (id, (stops, inserts)) in story.iter().zip(frame_edits) { + if !stops.is_empty() { + edits.push((*id, stops, inserts)); + } + } + } + let changed: Vec = edits.iter().map(|(id, _, _)| id.0).collect(); + let inserted: Vec = edits + .iter() + .flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| json!({"id": id.0, "at": at, "length": run.text.len()}))) + .collect(); + let insertions: Vec<(NodeId, usize, usize)> = + edits.iter().flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| (*id, *at, run.text.len()))).collect(); + if !edits.is_empty() { + s.edit("Convert to Text", |d, _| { + for (id, stops, mut inserts) in edits { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + for (k, style) in stops { + t.edit_paras(Some(k..k + 1), |para| *para = style.clone()); + } + // From the last paragraph to the first, so that each byte still names its place. + inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at)); + for (at, run) in inserts { + let len = run.text.len(); + vectorcraft_text::edit::replace_range_styled(&mut t.runs, at, at, &[run]); + t.ruby_after_replace(at, at, len); + } + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + if let Some(editing) = editing { + move_editing_selection(s, editing, &story_before, &insertions)?; + } + } + Ok(json!({ "ids": changed, "converted": converted, "inserted": inserted })) +} diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index f55378a34..f56abc8f3 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; -use vectorcraft_doc::{NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; use super::create::anchor_from_json; @@ -19,11 +19,19 @@ pub fn specs() -> Vec { "Add Anchor (Pen)", [], None, - "{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath", + "{id, x, y, in?: [x,y], out?: [x,y], subpath?: usize (the open subpath to extend, 0 included; an invalid or closed one is an error; default: the last open subpath)} append to the end of an open subpath", has_doc, append_anchor ), - cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close), + cmd!( + "path.close", + "Close Path", + [], + None, + "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}", + has_doc, + close + ), cmd!( "path.moveAnchors", "Move Anchors", @@ -65,7 +73,7 @@ pub fn specs() -> Vec { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", has_doc, reverse ), @@ -74,7 +82,7 @@ pub fn specs() -> Vec { "Join", ["Object", "Path"], Some("Cmd+J"), - "{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead", + "{ids?: [path, ...] (default: the selected paths; a compound path ID expands to its open member paths), subpaths?: [usize, usize] (which subpaths to join), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, for two paths/subpaths; default without ids: direct-selected ends if any, else the nearest pair; default with ids: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two or more open paths/subpaths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead", has_selection, join ), @@ -141,23 +149,42 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< fn append_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; + let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { let path = path_mut(d, id)?; - match path.subpaths.last_mut() { - Some(sp) if !sp.closed => sp.anchors.push(a), - _ => path.subpaths.push(SubPath::new(vec![a], false)), - } - sel.set([id]); - let last = path.subpaths.len() - 1; - let ai = path.subpaths[last].anchors.len() - 1; - sel.anchors.insert(id, BTreeSet::from([(last, ai)])); + let si = match subpath { + // An explicit subpath is honoured, 0 included, or refused: never swapped for another. + Some(si) => match path.subpaths.get_mut(si).filter(|sp| !sp.closed) { + Some(sp) => { + sp.anchors.push(a); + si + } + None => return Err(bad("path.appendAnchor", format!("subpath {si} is not an open subpath"))), + }, + None => match path.subpaths.iter_mut().enumerate().rev().find(|(_, sp)| !sp.closed) { + Some((si, sp)) => { + sp.anchors.push(a); + si + } + None => { + path.subpaths.push(SubPath::new(vec![a], false)); + path.subpaths.len() - 1 + } + }, + }; + let ai = path.subpaths[si].anchors.len() - 1; + let top = compound_or_self(d, id); + sel.set([top]); + sel.anchors.insert(id, BTreeSet::from([(si, ai)])); Ok(()) })?; ok() } +/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor. fn close(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let h_in = match p.get("in").filter(|h| !h.is_null()) { None => None, Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?), @@ -165,7 +192,10 @@ fn close(s: &mut Session, p: &Value) -> Result { let independent = switch_or(p, "independent", false, "path.close")?; s.edit("Close Path", |d, sel| { let path = path_mut(d, id)?; - let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; + let sp = match subpath { + Some(si) => path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?, + None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?, + }; sp.closed = true; if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) { first.h_in = h; @@ -174,7 +204,8 @@ fn close(s: &mut Session, p: &Value) -> Result { first.kind = AnchorKind::Smooth; } } - sel.set([id]); + let top = compound_or_self(d, id); + sel.set([top]); Ok(()) })?; ok() @@ -288,9 +319,41 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { ok() } +/// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. +fn compound_or_self(d: &Document, id: NodeId) -> NodeId { + d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// The open member paths of a compound path, or its first child if none are open. +fn open_members(n: &Node) -> Vec { + let NodeKind::Compound { children, .. } = &n.kind else { return vec![] }; + let open: Vec = + children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.id).collect(); + if open.is_empty() { children.first().map(|c| vec![c.id]).unwrap_or_default() } else { open } +} + +/// The selected paths to join or average, resolving compound paths to their open member paths. fn selected_paths(s: &Session) -> Result> { let st = s.doc()?; - Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect()) + let mut out = vec![]; + for &id in &st.selection.objects { + let Some(n) = st.doc.node(id) else { continue }; + match &n.kind { + NodeKind::Path { .. } => out.push(id), + NodeKind::Compound { .. } => { + let open = open_members(n); + let with_anchors: Vec = + open.iter().filter(|id| st.selection.anchors.get(id).is_some_and(|s| !s.is_empty())).copied().collect(); + if !with_anchors.is_empty() { + out.extend(with_anchors); + } else { + out.extend(open); + } + } + _ => {} + } + } + Ok(out) } /// The paths in the subtrees of `ids` (in groups and compound paths too), each once. @@ -309,6 +372,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec Result { const C: &str = "path.reverse"; let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?; + let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let ids = paths_in(&s.doc()?.doc, &targets(s, p)?); if ids.is_empty() { return Err(bad(C, "select paths")); @@ -316,18 +380,22 @@ fn reverse(s: &mut Session, p: &Value) -> Result { let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| { let mut changed = 0; for id in &ids { - // The subpaths to flip: all, or those not yet running the wanted way. - let flip: Vec = d - .node(*id) - .and_then(|n| n.path_data()) - .map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect()); - if flip.contains(&true) { - for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) { - if f { - sp.reverse(); + let path = path_mut(d, *id)?; + if let Some(si) = subpath { + if let Some(sp) = path.subpaths.get_mut(si) { + sp.reverse(); + changed += 1; + } + } else { + let flip: Vec = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(); + if flip.contains(&true) { + for (sp, f) in path.subpaths.iter_mut().zip(flip) { + if f { + sp.reverse(); + } } + changed += 1; } - changed += 1; } } Ok(changed) @@ -340,6 +408,14 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> { Some((sp.anchors.first()?.p, sp.anchors.last()?.p)) } +/// Orientations (reverse first, reverse second) that bring the nearest ends together. +fn nearest_ends(sp_a: &SubPath, sp_b: &SubPath) -> Option<(bool, bool)> { + let (fa, la) = end_points(sp_a)?; + let (fb, lb) = end_points(sp_b)?; + let cands = [(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)]; + cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map(|(_, rx, ry)| (rx, ry)) +} + /// Corner or smooth on an anchor a join leaves behind. fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) { if smooth { @@ -386,6 +462,70 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: before - sp.anchors.len() } +/// Close subpath `si` of path `id`, merging coincident endpoints if present. +fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option) -> Result<()> { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; + if sp.anchors.len() > 2 + && end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6) + && let Some(last) = sp.anchors.pop() + && let Some(first) = sp.anchors.first_mut() + { + first.h_in = last.h_in; + sp.closed = true; + if let Some(smooth) = join_kind { + join_point(sp, 0, smooth); + } + } + sp.closed = true; + Ok(()) +} + +/// Join subpaths `sa` and `sb` in the same path node `id`. +fn join_subpaths_in_node(d: &mut Document, id: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option) -> Result<()> { + if sa == sb { + return close_subpath_in_node(d, id, sa, join_kind); + } + let path = path_mut(d, id)?; + let mut x = path.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let mut y = path.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + if x.closed || y.closed { + return Err(EngineError::Other("join needs open paths".into())); + } + if rx { + x.reverse(); + } + if ry { + y.reverse(); + } + let junction = x.anchors.len().saturating_sub(1); + if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p)) + && xe.distance(ys) < 1e-6 + { + let first = y.anchors.remove(0); + if let Some(l) = x.anchors.last_mut() { + l.h_out = first.h_out; + } + } + x.anchors.extend(y.anchors); + if let Some(smooth) = join_kind { + join_point(&mut x, junction, smooth); + } + if sa > sb { + path.subpaths.remove(sb); + if let Some(sub) = path.subpaths.get_mut(sa - 1) { + *sub = x; + } + } else { + if let Some(sub) = path.subpaths.get_mut(sa) { + *sub = x; + } + path.subpaths.remove(sb); + } + Ok(()) +} + +/// Join open paths into a single path, or close an open path, preserving compound path structure. fn join(s: &mut Session, p: &Value) -> Result { const C: &str = "path.join"; let join_kind = match p.get("to").and_then(Value::as_str) { @@ -394,19 +534,163 @@ fn join(s: &mut Session, p: &Value) -> Result { Some("corner") => Some(false), Some(_) => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")), }; - let ids = match ids_param(p, "ids") { - Some(ids) => { - let st = s.doc()?; - if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) { - return Err(bad(C, format!("{} isn't a path", id.0))); + let subpaths = match p.get("subpaths") { + None => None, + Some(v) => match v.as_array().and_then(|a| { + if a.len() == 2 + && let (Some(sa), Some(sb)) = (a[0].as_u64(), a[1].as_u64()) + { + Some((sa as usize, sb as usize)) + } else { + None } - ids - } - None => selected_paths(s)?, + }) { + Some(pair) => Some(pair), + None => return Err(bad(C, "`subpaths` must be two integer indices")), + }, }; + // The ends to join, as (reverse the first path, reverse the second): the first path's end + // must come last, the second's first. + let ends = match p.get("ends") { + None => None, + Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::>()).as_deref() { + Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")), + _ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")), + }, + }; + + if let Some(ids) = ids_param(p, "ids") { + if ends.is_some() && ids.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } + if ids.len() == 2 && ids[0] == ids[1] { + let id = ids[0]; + let (sa, sb) = subpaths.ok_or_else(|| bad(C, "subpaths required when joining the same path"))?; + let st = s.doc()?; + let p = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let spa = p.subpaths.get(sa).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let spb = p.subpaths.get(sb).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (rx, ry) = match ends { + Some(e) => e, + None => nearest_ends(spa, spb).unwrap_or((false, false)), + }; + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, id, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + return ok(); + } + if ids.len() == 2 + && let Some((sa, sb)) = subpaths + { + let (a, b) = (ids[0], ids[1]); + let st = s.doc()?; + let pa = st.doc.node(a).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(a))?; + let pb = st.doc.node(b).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(b))?; + let spa = pa.subpaths.get(sa).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let spb = pb.subpaths.get(sb).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (rx, ry) = match ends { + Some(e) => e, + None => nearest_ends(spa, spb).unwrap_or((false, false)), + }; + s.edit("Join", |d, sel| { + join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + return ok(); + } + + let st = s.doc()?; + let mut resolved = vec![]; + for &id in &ids { + let Some(n) = st.doc.node(id) else { + return Err(bad(C, format!("{} isn't a path", id.0))); + }; + match &n.kind { + NodeKind::Path { .. } => resolved.push(id), + NodeKind::Compound { .. } => { + let open = open_members(n); + if ids.len() > 1 && open.len() != 1 { + return Err(bad(C, format!("compound {} must contain exactly one open path to join", id.0))); + } + resolved.extend(open); + } + _ => return Err(bad(C, format!("{} isn't a path", id.0))), + } + } + match resolved.as_slice() { + [one] => { + let id = *one; + let si = match subpaths { + Some((si, _)) => si, + None => { + let pd = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + pd.subpaths + .iter() + .position(|sp| !sp.closed && !sp.anchors.is_empty()) + .ok_or_else(|| EngineError::Other("path is already closed".into()))? + } + }; + s.edit("Join", |d, sel| { + close_subpath_in_node(d, id, si, join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + } + [a, b, ..] => { + if resolved.iter().collect::>().len() != resolved.len() { + return Err(bad(C, "join two different paths")); + } + let (a, b) = (*a, *b); + if ends.is_some() && resolved.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } + if let Some((rx, ry)) = ends { + let st = s.doc()?; + let pa = st.doc.node(a).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(a))?; + let pb = st.doc.node(b).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(b))?; + let sa = match subpaths { + Some((s, _)) => s, + None => pa.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?, + }; + let sb = match subpaths { + Some((_, s)) => s, + None => pb.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?, + }; + s.edit("Join", |d, sel| { + join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + return ok(); + } + let st = s.doc()?; + let mut open_subs = Vec::with_capacity(resolved.len()); + for &id in &resolved { + let pd = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let si = pd + .subpaths + .iter() + .position(|sp| !sp.closed && !sp.anchors.is_empty()) + .ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + open_subs.push((id, si)); + } + s.edit("Join", |d, sel| { + let top = join_nearest(d, open_subs, join_kind)?; + sel.set([top]); + Ok(()) + })?; + } + [] => return Err(EngineError::Other("select paths to join".into())), + } + return ok(); + } + // Direct-selected anchors lying on one another along a path (two corners brought together by // Average) become one anchor; the paths themselves are joined only when there are none. - if p.get("ids").is_none() { + { let st = s.doc()?; let mut merged = vec![]; for (id, picked) in &st.selection.anchors { @@ -423,96 +707,279 @@ fn join(s: &mut Session, p: &Value) -> Result { for (id, path) in merged { *path_mut(d, id)? = path; } - // The anchor indices changed: the paths stay selected, whole. sel.set(objects); Ok(()) })?; return ok(); } } - // The ends to join, as (reverse the first path, reverse the second): the first path's end - // must come last, the second's first. - let ends = match p.get("ends") { - None => None, - Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::>()).as_deref() { - Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")), - _ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")), - }, - }; - match ids.as_slice() { - [one] => { - let id = *one; - s.edit("Join", |d, _| { - let path = path_mut(d, id)?; - let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; - // Merge coincident end points. - if sp.anchors.len() > 2 - && end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6) - && let Some(last) = sp.anchors.pop() - && let Some(first) = sp.anchors.first_mut() - { - first.h_in = last.h_in; - // Closed first, so the merged anchor smooths between both its neighbours. - sp.closed = true; - if let Some(smooth) = join_kind { - join_point(sp, 0, smooth); + + // An explicit `ends` names the ends to join: the direct selection only decides when it is absent. + let direct_ends: Vec<(NodeId, usize, &'static str)> = if ends.is_some() { + vec![] + } else { + let st = s.doc()?; + let mut ends = vec![]; + for (&id, picked) in &st.selection.anchors { + if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) { + for (si, sp) in pd.subpaths.iter().enumerate() { + if sp.closed || sp.anchors.is_empty() { + continue; + } + let last_idx = sp.anchors.len() - 1; + let f = picked.contains(&(si, 0)); + let l = picked.contains(&(si, last_idx)); + if f && l && last_idx > 0 { + ends.push((id, si, "both")); + } else if f { + ends.push((id, si, "first")); + } else if l { + ends.push((id, si, "last")); } } - sp.closed = true; - Ok(()) - })?; - } - [a, b, ..] => { - let (a, b) = (*a, *b); - if a == b { - return Err(bad(C, "join two different paths")); } - s.edit("Join", |d, sel| { - let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?; - let pa = path_mut(d, a)?; - let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else { - return Err(EngineError::Other("join needs open paths".into())); - }; - let mut x = pa.subpaths[sa].clone(); - let mut y = pb.subpaths[sb].clone(); - let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else { - return Err(EngineError::Other("join needs open paths".into())); - }; - // The ends asked for, else the closest pair. - let cands = - [(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)]; - let (rx, ry) = - ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry))); - if rx { - x.reverse(); - } - if ry { - y.reverse(); - } - let junction = x.anchors.len() - 1; - if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p)) - && xe.distance(ys) < 1e-6 - { - let first = y.anchors.remove(0); - if let Some(l) = x.anchors.last_mut() { - l.h_out = first.h_out; + } + ends + }; + + // More than two selected ends can't name one join: refuse it instead of joining everything. + // A single end (left selected by the Pen) falls through to joining the selected paths. + if direct_ends.len() > 2 { + return Err(bad(C, "select exactly two path ends to join")); + } + + if direct_ends.len() == 1 && direct_ends[0].2 == "both" { + let (id, si, _) = direct_ends[0]; + s.edit("Join", |d, sel| { + close_subpath_in_node(d, id, si, join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + return ok(); + } + + if direct_ends.len() == 2 { + let (ida, sa, ea) = direct_ends[0]; + let (idb, sb, eb) = direct_ends[1]; + let (rx, ry) = match (ea, eb) { + ("first", "first") => (true, false), + ("first", "last") => (true, true), + ("last", "first") => (false, false), + ("last", "last") => (false, true), + _ => { + let st = s.doc()?; + let pa = st.doc.node(ida).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(ida))?; + let pb = st.doc.node(idb).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(idb))?; + let (fa, la) = pa.subpaths.get(sa).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (fb, lb) = pb.subpaths.get(sb).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let mut best: Option<(bool, bool, f64)> = None; + let cands = [ + (la.distance(fb), false, false, (ea == "last" || ea == "both") && (eb == "first" || eb == "both")), + (la.distance(lb), false, true, (ea == "last" || ea == "both") && (eb == "last" || eb == "both")), + (fa.distance(fb), true, false, (ea == "first" || ea == "both") && (eb == "first" || eb == "both")), + (fa.distance(lb), true, true, (ea == "first" || ea == "both") && (eb == "last" || eb == "both")), + ]; + for (dist, rx_c, ry_c, valid) in cands { + if valid && best.as_ref().is_none_or(|b| dist < b.2) { + best = Some((rx_c, ry_c, dist)); } } - x.anchors.extend(y.anchors); - if let Some(smooth) = join_kind { - join_point(&mut x, junction, smooth); + match best { + Some((rx, ry, _)) => (rx, ry), + None => (ea == "first", eb == "last"), } - pa.subpaths[sa] = x; - d.remove(b)?; - sel.set([a]); + } + }; + if ida == idb { + if sa == sb { + s.edit("Join", |d, sel| { + close_subpath_in_node(d, ida, sa, join_kind)?; + sel.set([compound_or_self(d, ida)]); + Ok(()) + })?; + } else { + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, ida)]); + Ok(()) + })?; + } + return ok(); + } else { + s.edit("Join", |d, sel| { + join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, ida)]); + Ok(()) + })?; + return ok(); + } + } + + let ids = selected_paths(s)?; + if ids.is_empty() { + return Err(EngineError::Other("select paths to join".into())); + } + let mut open_subs: Vec<(NodeId, usize)> = vec![]; + { + let st = s.doc()?; + for &id in &ids { + if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) { + for (si, sp) in pd.subpaths.iter().enumerate() { + if !sp.closed && !sp.anchors.is_empty() { + open_subs.push((id, si)); + } + } + } + } + } + if open_subs.is_empty() { + if ids.len() == 1 { + return Err(EngineError::Other("path is already closed".into())); + } + return Err(EngineError::Other("select paths to join".into())); + } + if ends.is_some() && open_subs.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } + if open_subs.len() == 1 { + let (id, si) = open_subs[0]; + s.edit("Join", |d, sel| { + close_subpath_in_node(d, id, si, join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + return ok(); + } + if let Some((rx, ry)) = ends + && open_subs.len() == 2 + { + let (a, sa) = open_subs[0]; + let (b, sb) = open_subs[1]; + if a == b { + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, a, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + } else { + s.edit("Join", |d, sel| { + join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); Ok(()) })?; } - [] => return Err(EngineError::Other("select paths to join".into())), + return ok(); } + + s.edit("Join", |d, sel| { + let top = join_nearest(d, open_subs, join_kind)?; + sel.set([top]); + Ok(()) + })?; ok() } +/// Join open subpaths, always the two whose ends lie nearest each other, until one is left. +/// `open_subs` lists `(node, subpath)` pairs. Returns the node holding the result (or its compound). +fn join_nearest(d: &mut Document, mut open_subs: Vec<(NodeId, usize)>, join_kind: Option) -> Result { + let err = || EngineError::Other("join needs open paths".into()); + while open_subs.len() > 1 { + let mut ends_info = Vec::with_capacity(open_subs.len()); + for &(id, si) in &open_subs { + let p = d.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let sp = p.subpaths.get(si).ok_or_else(err)?; + ends_info.push(end_points(sp).ok_or_else(err)?); + } + let mut best: Option<(usize, usize, bool, bool, f64)> = None; + for (i, &(fa, la)) in ends_info.iter().enumerate() { + for (j, &(fb, lb)) in ends_info.iter().enumerate().skip(i + 1) { + let cands = + [(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)]; + for (dist, rx, ry) in cands { + if best.as_ref().is_none_or(|b| dist < b.4) { + best = Some((i, j, rx, ry, dist)); + } + } + } + } + let Some((i, j, rx, ry, _)) = best else { return Err(err()) }; + let ((ida, sa), (idb, sb)) = (open_subs[i], open_subs[j]); + if ida == idb { + join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?; + // Subpath `sb` is gone and `sa` took the merged result; later subpaths shift down. + open_subs.remove(j); + if sa > sb { + open_subs[i].1 -= 1; + } + for (k, item) in open_subs.iter_mut().enumerate() { + if k != i && item.0 == ida && item.1 > sb { + item.1 -= 1; + } + } + } else { + // `join_two` removes `b` only when `sb` was its last subpath; otherwise `b` keeps its + // other subpaths, which stay in `open_subs` with their indexes shifted down. + join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?; + open_subs.remove(j); + for item in &mut open_subs { + if item.0 == idb && item.1 > sb { + item.1 -= 1; + } + } + } + } + let first = open_subs.first().map(|(id, _)| *id).ok_or_else(err)?; + Ok(compound_or_self(d, first)) +} + +/// Join path `b` into path `a` along the specified subpaths and orientations. +fn join_two(d: &mut Document, a: NodeId, b: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option) -> Result<()> { + let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?; + let pa = path_mut(d, a)?; + let mut x = pa.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let mut y = pb.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + if x.closed || y.closed || x.anchors.is_empty() || y.anchors.is_empty() { + return Err(EngineError::Other("join needs open paths".into())); + } + if rx { + x.reverse(); + } + if ry { + y.reverse(); + } + let junction = x.anchors.len().saturating_sub(1); + if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p)) + && xe.distance(ys) < 1e-6 + { + let first = y.anchors.remove(0); + if let Some(l) = x.anchors.last_mut() { + l.h_out = first.h_out; + } + } + x.anchors.extend(y.anchors); + if let Some(smooth) = join_kind { + join_point(&mut x, junction, smooth); + } + if let Some(sub) = pa.subpaths.get_mut(sa) { + *sub = x; + } + if pb.subpaths.len() <= 1 { + let parent = d.parent_of(b).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))); + d.remove(b)?; + if let Some(parent) = parent + && d.node(parent).is_some_and(|n| n.children().is_some_and(|children| children.is_empty())) + { + d.remove(parent)?; + } + } else { + let pb_mut = path_mut(d, b)?; + if sb < pb_mut.subpaths.len() { + pb_mut.subpaths.remove(sb); + } + } + Ok(()) +} + pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>; pub(super) fn anchor_targets(s: &Session) -> Result { @@ -624,6 +1091,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result { let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); without_segments(sp, &cuts) }) + .filter(|s| s.anchors.len() >= 2) .collect(); } else { // Remove every selected anchor against the original indexes, keeping surviving @@ -635,6 +1103,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result { let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); without_anchors(sp, &gone) }) + .filter(|s| s.anchors.len() >= 2) .collect(); } path.subpaths.retain(|s| s.anchors.len() >= 2); @@ -693,7 +1162,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { } /// Remove connections, splitting open/closed contours into their surviving open runs. -fn without_segments(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_segments(sp: SubPath, removed: &BTreeSet) -> Vec { let cuts: BTreeSet = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect(); let Some(&first) = cuts.first() else { return vec![sp] }; let n = sp.anchors.len(); diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index f898ff40e..8f3e0f117 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -23,90 +23,90 @@ pub fn specs() -> Vec { "Unite", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Unite) + |s, p| run_pf(s, p, PathfinderOp::Unite) ), cmd!( "object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::MinusFront) + |s, p| run_pf(s, p, PathfinderOp::MinusFront) ), cmd!( "object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Intersect) + |s, p| run_pf(s, p, PathfinderOp::Intersect) ), cmd!( "object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Exclude) + |s, p| run_pf(s, p, PathfinderOp::Exclude) ), cmd!( "object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Divide) + |s, p| run_pf(s, p, PathfinderOp::Divide) ), cmd!( "object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Trim) + |s, p| run_pf(s, p, PathfinderOp::Trim) ), cmd!( "object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Merge) + |s, p| run_pf(s, p, PathfinderOp::Merge) ), cmd!( "object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_multi, - |s, _| run_pf(s, PathfinderOp::Crop) + |s, p| run_pf(s, p, PathfinderOp::Crop) ), cmd!( "object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Outline) + |s, p| run_pf(s, p, PathfinderOp::Outline) ), cmd!( "object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::MinusBack) + |s, p| run_pf(s, p, PathfinderOp::MinusBack) ), cmd!( "object.pathfinder.trap", @@ -117,6 +117,15 @@ pub fn specs() -> Vec { has_selection, trap ), + cmd!( + "object.pathfinder.options", + "Pathfinder Options…", + ["Window", "Pathfinder"], + None, + "{precision?: pt 0.001..10 (0.01; how closely the Pathfinder operations follow the paths: results are refit to within it and pieces thinner than it are dropped), removeRedundantPoints?: bool (false; the results lose the anchors that don't change their shape), removeUnpainted?: bool (false; Divide and Outline leave out the pieces of objects with no fill)} Pathfinder Options, kept in the preferences and used by every later Shape Mode and Pathfinder operation (not Trap); {} reads them → the options", + always, + pathfinder_options + ), cmd!( "object.path.outlineStroke", "Outline Stroke", @@ -379,6 +388,82 @@ pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> { // ---------- Pathfinder ---------- +/// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`, the `pathfinder` preference +/// group), which every Shape Mode and Pathfinder operation of the panel uses. The defaults leave +/// the results as they were before the options existed. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PathfinderOptions { + /// How closely results follow the paths (pt): they are refit to within it and pieces thinner + /// than it are dropped ([`po::pathfinder_with`]). + pub precision: f64, + /// Results lose the anchors that don't change their shape (to within `precision`). + pub remove_redundant_points: bool, + /// Divide and Outline leave out the pieces of objects with no fill. + pub remove_unpainted: bool, +} + +impl Default for PathfinderOptions { + fn default() -> Self { + Self { precision: po::DEFAULT_PRECISION, remove_redundant_points: false, remove_unpainted: false } + } +} + +/// The range of Pathfinder Options › Precision (pt). +pub const PATHFINDER_PRECISION_RANGE: std::ops::RangeInclusive = 0.001..=10.0; + +impl PathfinderOptions { + /// These options with those `p` names (`precision` in pt or a string with a unit), or why `p` + /// can't set them. + pub(crate) fn merged(&self, p: &Value) -> std::result::Result { + let given = match p { + Value::Null => return Ok(*self), + Value::Object(given) => given, + _ => return Err("`pathfinder` must be an object".into()), + }; + let mut o = *self; + for (key, v) in given { + let flag = || v.as_bool().ok_or_else(|| format!("`{key}` must be true or false")); + match key.as_str() { + "precision" => { + o.precision = + len_param(p, key).filter(|v| PATHFINDER_PRECISION_RANGE.contains(v)).ok_or("precision must be from 0.001 to 10 pt")?; + } + "removeRedundantPoints" => o.remove_redundant_points = flag()?, + "removeUnpainted" => o.remove_unpainted = flag()?, + k => { + let path = "pathfinder"; + return Err(format!("unknown option `{path}.{k}`")); + } + } + } + Ok(o) + } +} + +fn pathfinder_options(s: &mut Session, p: &Value) -> Result { + s.prefs.pathfinder = s.prefs.pathfinder.merged(p).map_err(|e| bad("object.pathfinder.options", e))?; + Ok(json!(s.prefs.pathfinder)) +} + +/// The last part of the id of `op`'s command (`object.pathfinder.…`). +fn pf_id(op: PathfinderOp) -> &'static str { + match op { + PathfinderOp::Unite => "unite", + PathfinderOp::MinusFront => "minusFront", + PathfinderOp::Intersect => "intersect", + PathfinderOp::Exclude => "exclude", + PathfinderOp::Divide => "divide", + PathfinderOp::Trim => "trim", + PathfinderOp::Merge => "merge", + PathfinderOp::Crop => "crop", + PathfinderOp::Outline => "outline", + PathfinderOp::MinusBack => "minusBack", + PathfinderOp::HardMix => "hardMix", + PathfinderOp::SoftMix => "softMix", + } +} + fn pf_label(op: PathfinderOp) -> &'static str { match op { PathfinderOp::Unite => "Unite", @@ -396,7 +481,25 @@ fn pf_label(op: PathfinderOp) -> &'static str { } } -fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { +/// The Pathfinder Options an operation uses: those `p` gives, else the preferences'. They join the +/// command's journal entry, so replaying it gives the same result whatever the preferences are then. +fn pf_options(s: &mut Session, p: &Value, id: &str) -> Result { + let mut stored = s.prefs.pathfinder; + if !PATHFINDER_PRECISION_RANGE.contains(&stored.precision) { + // A preferences file edited by hand: the default, rather than refitting everything away. + stored.precision = po::DEFAULT_PRECISION; + } + let keys = ["precision", "removeRedundantPoints", "removeUnpainted"]; + let given: serde_json::Map = + p.as_object().into_iter().flatten().filter(|(k, _)| keys.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); + let options = stored.merged(&Value::Object(given)).map_err(|e| bad(id, e))?; + s.note_journal("precision", json!(options.precision)); + s.note_journal("removeRedundantPoints", json!(options.remove_redundant_points)); + s.note_journal("removeUnpainted", json!(options.remove_unpainted)); + Ok(options) +} + +fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { let roots = selected_roots(s)?; if roots.is_empty() { return Err(EngineError::Other("nothing selected".into())); @@ -404,6 +507,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); let label = pf_label(op); + let options = pf_options(s, p, &format!("object.pathfinder.{}", pf_id(op)))?; let ids = s.edit(label, |d, sel| { // Build the stack (back → front); names are separate from paint/style identity. let mut styles: Vec - x - x - x - x - "#, - |doc, st| { - assert_eq!(st.own(by_id(doc, "t0"), "fill").as_deref(), Some("#111")); - assert_eq!(st.own(by_id(doc, "t1"), "fill").as_deref(), Some("#333"), "child + class beats descendant"); - assert_eq!(st.own(by_id(doc, "t1"), "stroke").as_deref(), Some("blue"), "multiple classes"); - assert_eq!(st.own(by_id(doc, "t2"), "fill").as_deref(), Some("#666"), "style attribute beats #id"); - assert_eq!(st.own(by_id(doc, "t3"), "fill").as_deref(), Some("#555"), "!important beats style"); - assert_eq!(st.own(by_id(doc, "t3"), "font-weight").as_deref(), Some("bold")); - assert_eq!(st.own(by_id(doc, "t2"), "stroke"), None, "`.a.b` needs both classes"); - assert_eq!(st.prop(by_id(doc, "t2"), "font-size").as_deref(), Some("9px")); - }, - ); - } - - #[test] - fn descendant_matching_does_not_backtrack_combinatorially() { - // Many nested groups share class `a` and the first rule stacks eight descendant - // `.a` compounds under a leftmost class that matches nothing: an unmemoized - // matcher enumerates ancestor combinations for it and effectively never - // finishes (#1703). With suffix states memoized this returns at once, and the - // matching descendant and mixed child/descendant rules still apply. - let depth = 48; - let mut svg = String::from( - r#""#, - ); - for _ in 0..depth { - svg.push_str(r#""#); - } - svg.push_str(r#"x"#); - for _ in 0..depth { - svg.push_str(""); - } - svg.push_str(""); - with_doc(&svg, |doc, st| { - let t = by_id(doc, "t"); - assert_eq!(st.own(t, "fill").as_deref(), Some("#222"), "descendant rule matches, the .missing one does not"); - assert_eq!(st.own(t, "stroke").as_deref(), Some("blue"), "mixed child/descendant rule matches"); - }); - } - - #[test] - fn presentation_attributes_lose_to_rules() { - with_doc( - r#"x"#, - |doc, st| { - assert_eq!(st.own(by_id(doc, "a"), "fill").as_deref(), Some("blue")); - assert_eq!(st.own(by_id(doc, "a"), "stroke").as_deref(), Some("red")); - }, - ); - } - - /// What [`Styles::declared`] gave before the rules were indexed: every rule tested against `n`. - fn declared_by_every_rule(st: &Styles, n: XNode) -> HashMap { - type Key = (bool, bool, (u32, u32, u32), usize); - let mut best: HashMap = HashMap::new(); - let mut put = |name: &str, value: &str, key: Key| { - if best.get(name).is_none_or(|(k, _)| key >= *k) { - best.insert(name.to_string(), (key, value.to_string())); - } - }; - let mut order = 0; - for r in st.rules.iter().filter(|r| r.selector.matches(n)) { - for (k, v, imp) in r.decls.iter() { - order += 1; - put(k, v, (*imp, false, r.selector.specificity, order)); - } - } - if let Some(s) = n.attribute("style") { - for (k, v, imp) in parse_decls(s) { - order += 1; - put(&k, &v, (imp, true, (0, 0, 0), order)); - } - } - best.into_iter().map(|(k, (_, v))| (k, v)).collect() - } - - /// Checks that each element of `svg` gets the properties [`declared_by_every_rule`] gives it. - fn assert_same_as_every_rule(svg: &str) { - let doc = roxmltree::Document::parse(svg).unwrap(); - let st = Styles::new(&doc); - for n in doc.descendants().filter(|n| n.is_element()) { - assert_eq!(*st.declared(n), declared_by_every_rule(&st, n), "{n:?} in {svg}"); - } - } - - /// Id, class, type, universal and attribute selectors, descendant and child combinators, and - /// rules that override each other give each element what testing every rule gives it. - #[test] - fn indexed_rules_give_what_testing_every_rule_gives() { - let svg = r#" - - - - - - - tu - - "#; - assert_same_as_every_rule(svg); - with_doc(svg, |doc, st| { - let own = |id: &str, name: &str| st.own(by_id(doc, id), name); - assert_eq!(own("a", "fill").as_deref(), Some("f2"), "#a beats rect.a"); - assert_eq!(own("r2", "fill").as_deref(), Some("f4"), "rect.a beats g rect"); - assert_eq!(own("r2", "stroke").as_deref(), Some("s5"), "classes apart by a tab and a line feed, one repeated"); - assert_eq!(own("r3", "fill").as_deref(), Some("f8"), "!important"); - assert_eq!(own("r3", "display").as_deref(), Some("none")); - assert_eq!(own("r4", "fill").as_deref(), Some("f1"), "g rect beats rect"); - assert_eq!(own("r4", "font-size").as_deref(), Some("9px")); - assert_eq!(own("c5", "fill").as_deref(), Some("f11"), "of rules alike, the last one"); - assert_eq!(own("c5", "stroke").as_deref(), Some("s")); - assert_eq!(own("t7", "font-size").as_deref(), Some("8px")); - assert_eq!(own("r8", "fill").as_deref(), Some("f9")); - }); - } - - /// An element's candidates hold each rule once, however often the element repeats a class. - #[test] - fn repeated_classes_give_each_rule_once() { - with_doc( - r#""#, - |doc, st| assert_eq!(st.index.candidates(by_id(doc, "r")), [0, 1, 2, 3]), - ); - } - - /// A compound selector over a few tags, ids, classes and attributes. - fn arb_compound() -> impl Strategy { - let tag = prop::sample::select(vec!["", "*", "g", "rect", "text", "tspan"]); - let id = prop::sample::select(vec!["", "#a", "#b"]); - let classes = prop::collection::vec(prop::sample::select(vec![".a", ".b", ".c"]), 0..3); - let attr = prop::sample::select(vec!["", "[data-k]", "[data-k=v]"]); - (tag, id, classes, attr).prop_map(|(t, i, c, a)| match format!("{t}{i}{}{a}", c.concat()) { - s if s.is_empty() => "*".to_string(), - s => s, - }) - } - - /// One to three compounds joined by descendant and child combinators. - fn arb_selector() -> impl Strategy { - (arb_compound(), prop::collection::vec((prop::sample::select(vec![" ", " > ", ">"]), arb_compound()), 0..3)) - .prop_map(|(first, rest)| rest.into_iter().fold(first, |s, (comb, c)| format!("{s}{comb}{c}"))) - } - - /// A rule of one or two selectors that sets one to three properties, some of them `!important`. - fn arb_rule() -> impl Strategy { - let decl = (prop::sample::select(vec!["fill", "stroke", "display"]), 0..1000u16, any::()) - .prop_map(|(name, v, important)| format!("{name}: v{v}{}", if important { " !important" } else { "" })); - (prop::collection::vec(arb_selector(), 1..3), prop::collection::vec(decl, 1..4)) - .prop_map(|(selectors, decls)| format!("{} {{ {} }}", selectors.join(", "), decls.join("; "))) - } - - /// An element that may have an id, classes (some repeated, apart by spaces, tabs and line feeds) - /// and a `data-k` or `style` attribute, with up to four children on each of the `depth` levels - /// below it. - fn arb_element(depth: u32) -> BoxedStrategy { - let tag = prop::sample::select(vec!["g", "rect", "text", "tspan"]); - let id = prop::sample::select(vec!["", r#" id="a""#, r#" id="b""#, r#" id="c""#]); - let classes = prop::collection::vec((prop::sample::select(vec![" ", " ", " "]), prop::sample::select(vec!["a", "b", "c", "x"])), 0..4) - .prop_map(|v| match v.iter().map(|(gap, c)| format!("{gap}{c}")).collect::() { - s if s.is_empty() => s, - s => format!(r#" class="{s}""#), - }); - let more = prop::sample::select(vec!["", r#" data-k="v""#, r#" data-k="w""#, r#" style="fill: s""#, r#" style="stroke: s !important""#]); - let children = if depth == 0 { Just(vec![]).boxed() } else { prop::collection::vec(arb_element(depth - 1), 0..5).boxed() }; - (tag, id, classes, more, children).prop_map(|(t, i, c, m, ch)| format!("<{t}{i}{c}{m}>{}", ch.concat())).boxed() - } - - proptest! { - #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] - - /// Random style sheets give each element of a random document what testing every rule - /// gives it. - #[test] - fn random_sheets_give_what_testing_every_rule_gives(rules in prop::collection::vec(arb_rule(), 0..24), body in prop::collection::vec(arb_element(3), 1..6)) { - assert_same_as_every_rule(&format!(r#"{}"#, rules.join("\n"), body.concat())); - } - } - - /// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within - /// 20 s. - #[test] - fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { - const N: usize = 50_000; - let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect(); - let rects: String = (0..N).map(|i| format!(r#""#)).collect(); - let svg = format!(r#"{rects}"#); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let doc = roxmltree::Document::parse(&svg).unwrap(); - let st = Styles::new(&doc); - let fills: Vec> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect(); - tx.send(fills).ok(); - }); - let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s")); - assert_eq!(fills.len(), N); - for (i, fill) in fills.iter().enumerate() { - assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str())); - } - } - - #[test] - fn the_font_shorthand_sets_its_longhands() { - let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense"); - let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::>(); - assert_eq!(get("font-style"), [("italic", false)]); - assert_eq!(get("font-weight"), [("bold", false), ("300", true)]); - assert_eq!(get("font-size"), [("30px", false)]); - assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]); - assert_eq!(get("font-variant"), [("normal", false)]); - assert_eq!(get("fill"), [("red", false)]); - assert!(get("font").is_empty(), "the shorthand itself is not kept"); - } - - #[test] - fn unsupported_selectors_are_dropped() { - for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", "", "a:not(b c)", "a:not(.x", "a:not(b > c)", "a:not(:hover)"] { - assert!(Selector::parse(s).is_none(), "{s}"); - } - let s = Selector::parse("g > text.x#y").unwrap(); - assert_eq!(s.specificity, (1, 1, 2)); - assert_eq!(s.parts.len(), 2); - assert!(s.parts[0].1, "text must be a child of g"); - } - - /// #1557: `:not()` of a list of compound selectors; it counts as its most specific argument. - #[test] - fn not_selectors() { - assert_eq!(Selector::parse("rect:not(.keep)").unwrap().specificity, (0, 1, 1)); - assert_eq!(Selector::parse("g :not(#a, .b.c, text)").unwrap().specificity, (1, 0, 1)); - assert_eq!(Selector::parse(":not(rect):not([x])").unwrap().specificity, (0, 1, 1)); - // Nesting is bounded: a hostile sheet can't recurse without end. - let nested = |n: usize| format!("{}.x{}", ":not(".repeat(n), ")".repeat(n)); - assert!(Selector::parse(&nested(MAX_NOT_NESTING)).is_some()); - assert!(Selector::parse(&nested(MAX_NOT_NESTING + 1)).is_none()); - assert!(Selector::parse(&nested(200_000)).is_none()); - with_doc( - r#" - - xx - "#, - |doc, st| { - assert_eq!(st.own(by_id(doc, "r0"), "fill").as_deref(), Some("green")); - assert_eq!(st.own(by_id(doc, "r1"), "fill").as_deref(), Some("red")); - assert_eq!(st.own(by_id(doc, "t0"), "stroke").as_deref(), Some("blue")); - assert_eq!(st.own(by_id(doc, "t1"), "stroke"), None); - assert_eq!(st.own(by_id(doc, "r2"), "stroke"), None); - assert_eq!(st.own(by_id(doc, "r2"), "opacity").as_deref(), Some("0.5")); - assert_eq!(st.own(by_id(doc, "r0"), "opacity"), None); - assert!(st.has_not_rules()); - assert_eq!(st.set_by_not_rules(by_id(doc, "r0")), [("fill".to_string(), "green".to_string())]); - assert!(st.set_by_not_rules(by_id(doc, "r1")).is_empty()); - }, - ); - } -} +mod tests; diff --git a/crates/svg/src/import/css/tests.rs b/crates/svg/src/import/css/tests.rs new file mode 100644 index 000000000..89b519858 --- /dev/null +++ b/crates/svg/src/import/css/tests.rs @@ -0,0 +1,332 @@ +use proptest::prelude::*; + +use super::*; + +#[test] +fn important_flag_is_case_insensitive() { + for s in ["fill: red !IMPORTANT", "fill: red !Important", "fill: red ! important"] { + assert_eq!(parse_decls(s), [("fill".to_string(), "red".to_string(), true)], "{s}"); + } + assert_eq!(parse_decls("fill: red !importantx"), [("fill".to_string(), "red !importantx".to_string(), false)]); +} + +#[test] +fn semicolons_in_quotes_and_parens_do_not_split() { + let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false); + assert_eq!(parse_decls("font-family:'A;B'; fill:red"), [d("font-family", "'A;B'"), d("fill", "red")]); + assert_eq!(parse_decls("font-family:\"A;B\"; fill:red"), [d("font-family", "\"A;B\""), d("fill", "red")]); + assert_eq!( + parse_decls("fill:url(data:image/svg+xml;base64,AAA); stroke:red"), + [d("fill", "url(data:image/svg+xml;base64,AAA)"), d("stroke", "red")] + ); + let _ = parse_decls("font-family:'A;B; fill:red"); +} + +fn with_doc(svg: &str, f: impl FnOnce(&roxmltree::Document, &Styles)) { + let doc = roxmltree::Document::parse(svg).unwrap(); + let st = Styles::new(&doc); + f(&doc, &st); +} + +fn by_id<'a, 'i>(doc: &'a roxmltree::Document<'i>, id: &str) -> XNode<'a, 'i> { + doc.descendants().find(|n| n.attribute("id") == Some(id)).unwrap() +} + +#[test] +fn selectors_and_precedence() { + with_doc( + r#" + x + x + x + x + "#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "t0"), "fill").as_deref(), Some("#111")); + assert_eq!(st.own(by_id(doc, "t1"), "fill").as_deref(), Some("#333"), "child + class beats descendant"); + assert_eq!(st.own(by_id(doc, "t1"), "stroke").as_deref(), Some("blue"), "multiple classes"); + assert_eq!(st.own(by_id(doc, "t2"), "fill").as_deref(), Some("#666"), "style attribute beats #id"); + assert_eq!(st.own(by_id(doc, "t3"), "fill").as_deref(), Some("#555"), "!important beats style"); + assert_eq!(st.own(by_id(doc, "t3"), "font-weight").as_deref(), Some("bold")); + assert_eq!(st.own(by_id(doc, "t2"), "stroke"), None, "`.a.b` needs both classes"); + assert_eq!(st.prop(by_id(doc, "t2"), "font-size").as_deref(), Some("9px")); + }, + ); +} + +#[test] +fn descendant_matching_does_not_backtrack_combinatorially() { + // Many nested groups share class `a` and the first rule stacks eight descendant + // `.a` compounds under a leftmost class that matches nothing: an unmemoized + // matcher enumerates ancestor combinations for it and effectively never + // finishes (#1703). With suffix states memoized this returns at once, and the + // matching descendant and mixed child/descendant rules still apply. + let depth = 48; + let mut svg = String::from( + r#""#, + ); + for _ in 0..depth { + svg.push_str(r#""#); + } + svg.push_str(r#"x"#); + for _ in 0..depth { + svg.push_str(""); + } + svg.push_str(""); + with_doc(&svg, |doc, st| { + let t = by_id(doc, "t"); + assert_eq!(st.own(t, "fill").as_deref(), Some("#222"), "descendant rule matches, the .missing one does not"); + assert_eq!(st.own(t, "stroke").as_deref(), Some("blue"), "mixed child/descendant rule matches"); + }); +} + +#[test] +fn presentation_attributes_lose_to_rules() { + with_doc( + r#"x"#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "a"), "fill").as_deref(), Some("blue")); + assert_eq!(st.own(by_id(doc, "a"), "stroke").as_deref(), Some("red")); + }, + ); +} + +/// What [`Styles::declared`] gave before the rules were indexed: every rule tested against `n`. +fn declared_by_every_rule(st: &Styles, n: XNode) -> HashMap { + type Key = (bool, bool, (u32, u32, u32), usize); + let mut best: HashMap = HashMap::new(); + let mut put = |name: &str, value: &str, key: Key| { + if best.get(name).is_none_or(|(k, _)| key >= *k) { + best.insert(name.to_string(), (key, value.to_string())); + } + }; + let mut order = 0; + for r in st.rules.iter().filter(|r| r.selector.matches(n)) { + for (k, v, imp) in r.decls.iter() { + order += 1; + put(k, v, (*imp, false, r.selector.specificity, order)); + } + } + if let Some(s) = n.attribute("style") { + for (k, v, imp) in parse_decls(s) { + order += 1; + put(&k, &v, (imp, true, (0, 0, 0), order)); + } + } + best.into_iter().map(|(k, (_, v))| (k, v)).collect() +} + +/// Checks that each element of `svg` gets the properties [`declared_by_every_rule`] gives it. +fn assert_same_as_every_rule(svg: &str) { + let doc = roxmltree::Document::parse(svg).unwrap(); + let st = Styles::new(&doc); + for n in doc.descendants().filter(|n| n.is_element()) { + assert_eq!(*st.declared(n), declared_by_every_rule(&st, n), "{n:?} in {svg}"); + } +} + +/// Id, class, type, universal and attribute selectors, descendant and child combinators, and +/// rules that override each other give each element what testing every rule gives it. +#[test] +fn indexed_rules_give_what_testing_every_rule_gives() { + let svg = r#" + + + + + + + tu + + "#; + assert_same_as_every_rule(svg); + with_doc(svg, |doc, st| { + let own = |id: &str, name: &str| st.own(by_id(doc, id), name); + assert_eq!(own("a", "fill").as_deref(), Some("f2"), "#a beats rect.a"); + assert_eq!(own("r2", "fill").as_deref(), Some("f4"), "rect.a beats g rect"); + assert_eq!(own("r2", "stroke").as_deref(), Some("s5"), "classes apart by a tab and a line feed, one repeated"); + assert_eq!(own("r3", "fill").as_deref(), Some("f8"), "!important"); + assert_eq!(own("r3", "display").as_deref(), Some("none")); + assert_eq!(own("r4", "fill").as_deref(), Some("f1"), "g rect beats rect"); + assert_eq!(own("r4", "font-size").as_deref(), Some("9px")); + assert_eq!(own("c5", "fill").as_deref(), Some("f11"), "of rules alike, the last one"); + assert_eq!(own("c5", "stroke").as_deref(), Some("s")); + assert_eq!(own("t7", "font-size").as_deref(), Some("8px")); + assert_eq!(own("r8", "fill").as_deref(), Some("f9")); + }); +} + +/// An element's candidates hold each rule once, however often the element repeats a class. +#[test] +fn repeated_classes_give_each_rule_once() { + with_doc( + r#""#, + |doc, st| assert_eq!(st.index.candidates(by_id(doc, "r")), [0, 1, 2, 3]), + ); +} + +/// A compound selector over a few tags, ids, classes and attributes. +fn arb_compound() -> impl Strategy { + let tag = prop::sample::select(vec!["", "*", "g", "rect", "text", "tspan"]); + let id = prop::sample::select(vec!["", "#a", "#b"]); + let classes = prop::collection::vec(prop::sample::select(vec![".a", ".b", ".c"]), 0..3); + let attr = prop::sample::select(vec!["", "[data-k]", "[data-k=v]"]); + (tag, id, classes, attr).prop_map(|(t, i, c, a)| match format!("{t}{i}{}{a}", c.concat()) { + s if s.is_empty() => "*".to_string(), + s => s, + }) +} + +/// One to three compounds joined by descendant and child combinators. +fn arb_selector() -> impl Strategy { + (arb_compound(), prop::collection::vec((prop::sample::select(vec![" ", " > ", ">"]), arb_compound()), 0..3)) + .prop_map(|(first, rest)| rest.into_iter().fold(first, |s, (comb, c)| format!("{s}{comb}{c}"))) +} + +/// A rule of one or two selectors that sets one to three properties, some of them `!important`. +fn arb_rule() -> impl Strategy { + let decl = (prop::sample::select(vec!["fill", "stroke", "display"]), 0..1000u16, any::()) + .prop_map(|(name, v, important)| format!("{name}: v{v}{}", if important { " !important" } else { "" })); + (prop::collection::vec(arb_selector(), 1..3), prop::collection::vec(decl, 1..4)) + .prop_map(|(selectors, decls)| format!("{} {{ {} }}", selectors.join(", "), decls.join("; "))) +} + +/// An element that may have an id, classes (some repeated, apart by spaces, tabs and line feeds) +/// and a `data-k` or `style` attribute, with up to four children on each of the `depth` levels +/// below it. +fn arb_element(depth: u32) -> BoxedStrategy { + let tag = prop::sample::select(vec!["g", "rect", "text", "tspan"]); + let id = prop::sample::select(vec!["", r#" id="a""#, r#" id="b""#, r#" id="c""#]); + let classes = prop::collection::vec((prop::sample::select(vec![" ", " ", " "]), prop::sample::select(vec!["a", "b", "c", "x"])), 0..4) + .prop_map(|v| match v.iter().map(|(gap, c)| format!("{gap}{c}")).collect::() { + s if s.is_empty() => s, + s => format!(r#" class="{s}""#), + }); + let more = prop::sample::select(vec!["", r#" data-k="v""#, r#" data-k="w""#, r#" style="fill: s""#, r#" style="stroke: s !important""#]); + let children = if depth == 0 { Just(vec![]).boxed() } else { prop::collection::vec(arb_element(depth - 1), 0..5).boxed() }; + (tag, id, classes, more, children).prop_map(|(t, i, c, m, ch)| format!("<{t}{i}{c}{m}>{}", ch.concat())).boxed() +} + +proptest! { + #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] + + /// Random style sheets give each element of a random document what testing every rule + /// gives it. + #[test] + fn random_sheets_give_what_testing_every_rule_gives(rules in prop::collection::vec(arb_rule(), 0..24), body in prop::collection::vec(arb_element(3), 1..6)) { + assert_same_as_every_rule(&format!(r#"{}"#, rules.join("\n"), body.concat())); + } +} + +/// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within +/// 20 s. +#[test] +fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { + const N: usize = 50_000; + let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect(); + let rects: String = (0..N).map(|i| format!(r#""#)).collect(); + let svg = format!(r#"{rects}"#); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let doc = roxmltree::Document::parse(&svg).unwrap(); + let st = Styles::new(&doc); + let fills: Vec> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect(); + tx.send(fills).ok(); + }); + let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s")); + assert_eq!(fills.len(), N); + for (i, fill) in fills.iter().enumerate() { + assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str())); + } +} + +#[test] +fn the_font_shorthand_sets_its_longhands() { + let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense"); + let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::>(); + assert_eq!(get("font-style"), [("italic", false)]); + assert_eq!(get("font-weight"), [("bold", false), ("300", true)]); + assert_eq!(get("font-size"), [("30px", false)]); + assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]); + assert_eq!(get("font-variant"), [("normal", false)]); + assert_eq!(get("fill"), [("red", false)]); + assert!(get("font").is_empty(), "the shorthand itself is not kept"); +} + +#[test] +fn unsupported_selectors_are_dropped() { + for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", "", "a:not(b c)", "a:not(.x", "a:not(b > c)", "a:not(:hover)"] { + assert!(Selector::parse(s).is_none(), "{s}"); + } + let s = Selector::parse("g > text.x#y").unwrap(); + assert_eq!(s.specificity, (1, 1, 2)); + assert_eq!(s.parts.len(), 2); + assert!(s.parts[0].1, "text must be a child of g"); +} + +/// #1557: `:not()` of a list of compound selectors; it counts as its most specific argument. +#[test] +fn not_selectors() { + assert_eq!(Selector::parse("rect:not(.keep)").unwrap().specificity, (0, 1, 1)); + assert_eq!(Selector::parse("g :not(#a, .b.c, text)").unwrap().specificity, (1, 0, 1)); + assert_eq!(Selector::parse(":not(rect):not([x])").unwrap().specificity, (0, 1, 1)); + // Nesting is bounded: a hostile sheet can't recurse without end. + let nested = |n: usize| format!("{}.x{}", ":not(".repeat(n), ")".repeat(n)); + assert!(Selector::parse(&nested(MAX_NOT_NESTING)).is_some()); + assert!(Selector::parse(&nested(MAX_NOT_NESTING + 1)).is_none()); + assert!(Selector::parse(&nested(200_000)).is_none()); + with_doc( + r#" + + xx + "#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "r0"), "fill").as_deref(), Some("green")); + assert_eq!(st.own(by_id(doc, "r1"), "fill").as_deref(), Some("red")); + assert_eq!(st.own(by_id(doc, "t0"), "stroke").as_deref(), Some("blue")); + assert_eq!(st.own(by_id(doc, "t1"), "stroke"), None); + assert_eq!(st.own(by_id(doc, "r2"), "stroke"), None); + assert_eq!(st.own(by_id(doc, "r2"), "opacity").as_deref(), Some("0.5")); + assert_eq!(st.own(by_id(doc, "r0"), "opacity"), None); + assert!(st.has_not_rules()); + assert_eq!(st.set_by_not_rules(by_id(doc, "r0")), [("fill".to_string(), "green".to_string())]); + assert!(st.set_by_not_rules(by_id(doc, "r1")).is_empty()); + }, + ); +} diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs new file mode 100644 index 000000000..f487eb4ee --- /dev/null +++ b/crates/svg/src/import/importer.rs @@ -0,0 +1,641 @@ +use super::convert::{aff, bezpath, blend, clip_shapes, gradient_stops, is_plain, made_up, rule, uncut, unique_name, without_ids}; +use super::layers::filter_names; +use super::prepass::link; +use super::*; + +/// How many clip paths deep (``) an object's clip is read. +const MAX_CLIP_NEST: usize = 16; + +/// Points per inch. +pub(crate) const PT_PER_IN: f64 = 72.0; + +/// CSS pixels per inch. +pub(super) const CSS_PX_PER_IN: f64 = 96.0; + +/// A `` made a symbol: its name, its art with no object ids (what each `` has to show +/// to be an instance of it) and the instance transforms the art can stand for. +pub(super) struct SymbolMade { + name: String, + art: Node, + reuse: Reuse, +} + +impl Importer { + pub(super) fn warn(&mut self, s: String) { + if !self.warnings.contains(&s) { + self.warnings.push(s); + } + } + + /// The child of `g` when it's all `g` holds and only wraps art: an id-less group that leaves + /// it as it is (but for a transform) and isn't a text. + pub(super) fn wrapper<'g>(&self, g: &'g usvg::Group) -> Option<&'g usvg::Group> { + match g.children() { + [usvg::Node::Group(w)] if w.id().is_empty() && is_plain(w) && self.text_slot(w).is_none() => Some(w), + _ => None, + } + } + + pub(super) fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec> { + g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect() + } + + /// The object usvg node `n` makes under transform `acc`, with what the file kept on its element + /// (data, event and other applications' attributes); `None` for what imports as nothing. + pub(super) fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option { + let mut out = match n { + usvg::Node::Group(g) => self.group(g, acc), + usvg::Node::Path(p) => self.path(p, acc), + usvg::Node::Image(i) => self.image(i, acc), + // Only present when usvg had fonts; text is read from the XML instead. + usvg::Node::Text(_) => None, + }?; + if self.hidden.contains(n.id()) { + out.visible = false; + } + self.keep_data(n.id(), &mut out); + self.keep_events(n.id(), &mut out); + self.keep_foreign(n.id(), &mut out); + Some(out) + } + + /// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an + /// inner element made of it keeps). + pub(super) fn keep_data(&self, id: &str, n: &mut Node) { + if let Some(data) = self.data.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.data.is_empty())) { + n.edit_attrs(|a| a.data = data.clone()); + } + } + + /// Give `n` the event attributes of element `id`, unless it has events already (what an inner + /// element made of it keeps). + pub(super) fn keep_events(&self, id: &str, n: &mut Node) { + if let Some(events) = self.events.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.events.is_empty())) { + n.edit_attrs(|a| a.events = events.clone()); + } + } + + /// Give `n` the attributes of element `id` in other applications' namespaces, unless it has + /// some already (what an inner element made of it keeps). + pub(super) fn keep_foreign(&self, id: &str, n: &mut Node) { + if let Some(kept) = self.foreign.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.foreign.is_empty())) { + n.edit_attrs(|a| a.foreign = kept.clone()); + } + } + + /// A gradient's stops, midpoints restored. + fn stops(&self, g: &usvg::BaseGradient) -> Vec { + gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice)) + } + + /// The object name for an element id: its label (see [`labels`]), else the id itself. + pub(super) fn name_of<'a>(&'a self, id: &'a str) -> &'a str { + self.labels.get(id).map_or(id, String::as_str) + } + + /// A new object named after element `id` (unless it has none or a made-up one). + fn named(&mut self, id: &str, kind: NodeKind) -> Node { + let nid = self.doc.alloc_id(); + let mut n = Node::new(nid, kind); + if !id.is_empty() && !made_up(id) { + n.name = Some(self.name_of(id).to_string()); + } + n + } + + fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let url = self.links.get(g.id()).cloned(); + let mut n = match self.instance(g, acc) { + Some(n) => n, + None => self.group_node(g, acc)?, + }; + if let Some(url) = url { + link(&mut n, &url); + } + Some(n) + } + + /// The group of a `` of a `` → an instance of the symbol made from it (on its + /// first ``). `None` when the group has to be plain art: it shows the symbol differently + /// (properties inherited from the ``), or the canvas would paint the instance differently + /// under its transform (see [`Reuse`]), or its viewport cuts the art, or it filters. + fn instance(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let sym = self.uses.get(g.id())?.clone(); + if !g.filters().is_empty() { + return None; + } + // usvg puts the art in a group moved by the ``'s position and the symbol's viewBox. + let ts = acc * aff(g.transform()); + let (mut xf, mut inner) = (ts, g); + while let [usvg::Node::Group(s)] = inner.children() + && s.id().is_empty() + && is_plain(s) + && self.text_slot(s).is_none() + { + xf *= aff(s.transform()); + inner = s; + } + let mut kids = self.children(inner, Affine::IDENTITY); + let art = match kids.len() { + 0 => return None, + 1 => Arc::unwrap_or_clone(kids.pop()?), + _ => self.named("", NodeKind::Group { children: kids, clip: false }), + }; + // usvg clips a `` of a symbol to its viewport: that only counts when it cuts the art. + let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf)); + let name = match self.symbols.get(&sym) { + Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(), + Some(_) => return None, + None => { + let name = unique_name(self.name_of(&sym), |n| self.doc.symbols.iter().any(|s| s.name == n)); + let reuse = Reuse::of(&self.doc, &art); + let made = SymbolMade { name: name.clone(), art: without_ids(&art), reuse }; + self.doc.symbols.push(vectorcraft_doc::Symbol { name: name.clone(), art: Arc::new(art) }); + self.symbols.insert(sym, made); + if !whole || !reuse.allows(xf) { + return None; + } + name + } + }; + let label = if made_up(g.id()) { "a symbol instance".to_string() } else { format!("'{}'", g.id()) }; + let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); + let mut n = self.named(g.id(), NodeKind::SymbolInstance { symbol: name, xf }); + n.opacity = g.opacity().get(); + n.blend = blend(g.blend_mode()); + n.isolate = g.isolate(); + n.extras_mut().mask = mask; + Some(n) + } + + /// The text a placeholder group stands for (see [`text`]) and the index of its main path. + pub(super) fn text_slot(&self, g: &usvg::Group) -> Option<(usize, usize)> { + g.children().iter().enumerate().find_map(|(k, c)| match c { + usvg::Node::Path(p) => self.slots.find(p).map(|i| (i, k)), + _ => None, + }) + } + + /// A text placeholder group → its text object (nothing when the text is hidden). + fn text(&mut self, g: &usvg::Group, ts: Affine) -> Option>> { + let (i, main) = self.text_slot(g)?; + let kids = g.children(); + if !matches!(kids.get(main), Some(usvg::Node::Path(p)) if p.is_visible()) { + return Some(vec![]); + } + let text::PendingText { name, mut obj, servers, paints, non_scaling, under, .. } = self.slots.texts.get(i)?.clone(); + // The scale the type draws at becomes its size (last, below): text space grows by `grow`. + let xf = ts * obj.xf; + let grow = text::size_scale(&obj, xf); + // usvg resolved the `url(#…)` paints (the paths after the main one) in the element's user + // space; runs paint in text space. + let to_text = Affine::scale(grow) * obj.xf.inverse(); + let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) { + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { + let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m)); + im.paint(f.paint(), m, Some(area)) + }), + _ => Paint::None, + }; + let resolved: Vec = (0..paints).map(|k| slot_paint(self, k, to_text)).collect(); + let server = |k: usize| resolved.get(k).cloned().unwrap_or(Paint::None); + for (run, (fill, stroke)) in obj.runs.iter_mut().zip(servers) { + if let Some(k) = fill { + run.style.fill = server(k); + } + if let Some(k) = stroke { + run.style.stroke = server(k); + } + } + obj.xf = xf; + if non_scaling && !vector_effect::unscale_text(&mut obj, self.screen) { + let label = if name.is_empty() || made_up(&name) { "a text".to_string() } else { format!("text '{name}'") }; + self.warn(format!("non-scaling stroke on {label} approximated: the text is stretched or skewed")); + } + // A stroke under the characters is the object's own: it paints in the document. + let under = under.map(|(mut sl, k)| { + if let Some(k) = k { + sl.paint = slot_paint(self, k, ts); + } + let mut ap = Appearance { items: vec![AppearanceItem::Stroke(sl)], ..Default::default() }; + ap.scale_strokes(if non_scaling { self.screen } else { obj.xf.determinant().abs().sqrt() }); + ap.set_contents_at(1); + ap + }); + text::fold_scale(&mut obj, grow); + let mut n = self.named(&name, NodeKind::Text(Box::new(obj))); + if let Some(ap) = under { + n.appearance = ap.into(); + } + Some(vec![Arc::new(n)]) + } + + fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let ts = acc * aff(g.transform()); + // A text placeholder becomes its text, named after the element (the group stays unnamed). + let text = self.text(g, ts); + if text.as_ref().is_some_and(Vec::is_empty) { + return None; + } + let is_text = text.is_some(); + // Made-up ids aren't names. + let id = if is_text || made_up(g.id()) { "" } else { g.id() }; + let label = match (&text, id) { + (Some(t), _) => t.first().and_then(|n| n.name.as_deref()).map_or_else(|| "a text".to_string(), |n| format!("text '{n}'")), + (None, "") => "a group".to_string(), + (None, id) => format!("'{id}'"), + }; + let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); + // Blurs, shadows and glows become live effects (see [`fx`]). + let effects = match g.filters() { + [] => vec![], + filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| { + // usvg wraps a filtered element in a group of its own: name the element then. + let on = match g.children() { + [usvg::Node::Group(_)] | [] => label.clone(), + [one] if made_up(g.id()) || g.id().is_empty() => { + if one.id().is_empty() || made_up(one.id()) { + "an object".to_string() + } else { + format!("'{}'", one.id()) + } + } + _ => label.clone(), + }; + self.warn(format!("filter {} on {on} isn't supported; drawn without it", filter_names(filters))); + vec![] + }), + }; + let mut children = match text { + Some(t) => t, + None => self.children(g, ts), + }; + let mut n = if let Some(cp) = g.clip_path() { + // SVG filters before it clips: the effects go on the art inside the clip. + if !effects.is_empty() { + let mut inner = self.named("", NodeKind::Group { children, clip: false }); + inner.appearance.effects = effects.clone(); + children = vec![Arc::new(inner)]; + } + let children = self.clipped(cp, ts, children, &label)?; + self.named(id, NodeKind::Group { children, clip: true }) + } else { + if children.is_empty() { + return None; + } + // An id-less wrapper around a single object (usvg adds these for opacity/transform on + // shapes): fold its opacity, blend and effects into the object (unless the object + // blends or masks before the effects). A text takes its own mask too. A group with + // `data-*` or event attributes of its own stays a group, and keeps them. + let own = self.data.contains_key(g.id()) || self.events.contains_key(g.id()); + if (is_text || id.is_empty() && mask.is_none() && !own) + && children.len() == 1 + && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) + && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].extras().mask.is_none()) + && let Some(only) = children.pop() + { + let mut c = Arc::unwrap_or_clone(only); + c.opacity *= g.opacity().get(); + if g.blend_mode() != usvg::BlendMode::Normal { + c.blend = blend(g.blend_mode()); + } + c.isolate |= g.isolate(); + if mask.is_some() { + c.extras_mut().mask = mask; + } + c.appearance.effects.extend(effects); + return Some(c); + } + let mut n = self.named(id, NodeKind::Group { children, clip: false }); + n.appearance.effects = effects; + n + }; + n.opacity = g.opacity().get(); + n.blend = blend(g.blend_mode()); + n.isolate = g.isolate(); + n.extras_mut().mask = mask; + if !is_text && self.sublayers.contains(g.id()) { + self.sublayer_groups.insert(n.id); + } + Some(n) + } + + /// `` → opacity mask. An alpha mask's art comes in white with its opacity kept, so its + /// luminance is its alpha (images in it keep their colours, with a warning). A mask our export wrote gets its + /// options back ([`mask_flags`]) and its art without what stands for them: the inverting + /// filter group and the backdrop rectangle. + fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option> { + if m.mask().is_some() { + self.warn(format!("nested mask on {label} ignored")); + } + let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false)); + let (mut nodes, mut ts) = (m.root().children(), ts); + if invert + && let [usvg::Node::Group(g)] = nodes + && !g.filters().is_empty() + { + (nodes, ts) = (g.children(), ts * aff(g.transform())); + } + if (!clip || invert) + && let [usvg::Node::Path(_), rest @ ..] = nodes + { + nodes = rest; + } + let mut children: Vec> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect(); + if m.kind() == usvg::MaskType::Alpha { + children.iter_mut().for_each(|c| vectorcraft_doc::node::whiten(Arc::make_mut(c))); + let mut image = false; + children.iter().for_each(|c| c.walk(&mut |n| image |= matches!(n.kind, NodeKind::Image(_)))); + if image { + self.warn(format!("image in the alpha mask on {label} counts by its luminance")); + } + } + // Mask art that is all hidden draws nothing: that is an empty mask, not no mask (black all + // over when clipped, white when not, inverted when the mask is). + let art = match children.len() { + 1 => Arc::unwrap_or_clone(children.pop()?), + _ => self.named("", NodeKind::Group { children, clip: false }), + }; + let mut mask = vectorcraft_doc::OpacityMask::new(art, clip); + mask.invert = invert; + Some(Box::new(mask)) + } + + /// `children` clipped by `cp` (on an element whose user space `ts` maps to the document): the + /// children of a clip group or clipping layer, its clipping path first. A clip path clipped in + /// turn (``) nests one clip group in another, so the clips intersect. + /// `None` when the clip path has no shape (nothing shows). + pub(super) fn clipped(&mut self, cp: &usvg::ClipPath, ts: Affine, mut children: Vec>, label: &str) -> Option>> { + let mut clips = vec![self.clip_node(cp, ts)?]; + let mut next = cp.clip_path(); + while let Some(c) = next { + if clips.len() > MAX_CLIP_NEST { + self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored")); + break; + } + clips.push(self.clip_node(c, ts)?); + next = c.clip_path(); + } + let outer = clips.pop()?; + for clip in clips { + let mut ch = vec![Arc::new(clip)]; + ch.extend(children); + children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))]; + } + let mut ch = vec![Arc::new(outer)]; + ch.extend(children); + Some(ch) + } + + /// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer + /// becomes a clip group in it, holding the art it clipped. + pub(super) fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec>) { + if art.is_empty() { + return; + } + if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind { + *clip = false; + let clipped = std::mem::take(children); + children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true }))); + } + if let Some(ch) = l.children_mut() { + let i = index.min(ch.len()); + ch.splice(i..i, art); + } + } + + fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { + let shapes = clip_shapes(cp, ts); + if shapes.is_empty() { + return None; + } + let r = shapes[0].1; + let mut subs = Vec::new(); + for (bp, _) in &shapes { + subs.extend(PathData::from_bezpath(bp).subpaths); + } + let id = cp.id().to_string(); + if subs.len() == 1 { + let path = PathData::new(subs); + return Some(self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false })); + } + let children = subs + .into_iter() + .map(|sp| { + let nid = self.doc.alloc_id(); + Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false })) + }) + .collect(); + Some(self.named(&id, NodeKind::Compound { children, rule: r })) + } + + /// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or + /// repeated gradient has to reach). + fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option) -> Paint { + let (kind, base, mut geom) = match p { + usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)), + usvg::Paint::Pattern(pt) => return self.pattern(pt, m), + usvg::Paint::LinearGradient(lg) => { + let geom = GradientGeom { + start: Point::new(lg.x1() as f64, lg.y1() as f64), + end: Point::new(lg.x2() as f64, lg.y2() as f64), + aspect: 1.0, + focal: None, + }; + let base: &usvg::BaseGradient = lg; + (GradientKind::Linear, base, geom) + } + usvg::Paint::RadialGradient(rg) => { + if rg.fr().get() > 1e-4 { + self.warn(format!("gradient '{}': focal radius ignored", rg.id())); + } + let c = Point::new(rg.cx() as f64, rg.cy() as f64); + let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0, focal: None }; + // A focal point outside the circle is pulled inside it. + geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64))); + let base: &usvg::BaseGradient = rg; + (GradientKind::Radial, base, geom) + } + }; + geom.transform(m * aff(base.transform()), kind); + let mut stops = self.stops(base); + let reflect = match base.spread_method() { + usvg::SpreadMethod::Pad => None, + usvg::SpreadMethod::Reflect => Some(true), + usvg::SpreadMethod::Repeat => Some(false), + }; + if let Some(reflect) = reflect { + match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) { + Some(spread::Expanded::Whole) => {} + Some(spread::Expanded::Capped) => { + self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS)) + } + None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())), + } + } + Paint::Gradient(Box::new(GradientPaint { + gradient: Gradient::new(kind, stops), + geom: Some(geom), + angle: geom.angle_deg(), + swatch: None, + freeform: None, + })) + } + + /// `` → a pattern swatch painted with the pattern's tile placement. The swatch art is + /// the pattern content clipped to the tile, as SVG draws it. + fn pattern(&mut self, pt: &Arc, m: Affine) -> Paint { + let r = pt.rect(); + let xf = m * aff(pt.transform()) * Affine::translate((r.x() as f64, r.y() as f64)); + let key = Arc::as_ptr(pt) as usize; + if let Some(name) = self.patterns.get(&key) { + return Paint::Pattern { pattern: name.clone(), xf }; + } + let tile = Rect::new(0.0, 0.0, r.width() as f64, r.height() as f64); + let content = self.children(pt.root(), aff(pt.root().transform())); + if content.is_empty() { + return Paint::None; + } + let clip = + self.named("", NodeKind::Path { path: shapes::rectangle(tile), rule: FillRule::NonZero, live: None, clipping: true, guide: false }); + let mut children = vec![Arc::new(clip)]; + children.extend(content); + let art = self.named("", NodeKind::Group { children, clip: true }); + let base = if pt.id().is_empty() { "Pattern" } else { pt.id() }; + let name = unique_name(base, |n| self.doc.pattern(n).is_some()); + let mut def = PatternDef::new(&name, vec![Arc::new(art)]); + def.tile = tile; + self.doc.patterns.push(def); + self.patterns.insert(key, name.clone()); + Paint::Pattern { pattern: name, xf } + } + + /// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`. + fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option) -> (Appearance, FillRule) { + let mut fill = None; + let mut r = FillRule::NonZero; + if let Some(f) = p.fill() { + let paint = self.paint(f.paint(), m, area); + let mut fl = FillLayer::new(paint); + fl.opacity = f.opacity().get(); + r = rule(f.rule()); + fill = Some(AppearanceItem::Fill(fl)); + } + let mut stroke = None; + if let Some(s) = p.stroke() { + // A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a + // stroke of that width in the document is exact. + let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() }; + // The stroke reaches past the path (miters further: a generous margin). + let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); + let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); + let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); + sl.opacity = s.opacity().get(); + sl.cap = match s.linecap() { + usvg::LineCap::Butt => LineCap::Butt, + usvg::LineCap::Round => LineCap::Round, + usvg::LineCap::Square => LineCap::Square, + }; + sl.join = match s.linejoin() { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter, + usvg::LineJoin::Round => LineJoin::Round, + usvg::LineJoin::Bevel => LineJoin::Bevel, + }; + sl.miter_limit = s.miterlimit().get() as f64; + if let Some(d) = s.dasharray() { + sl.dash = Some(Dash { + pattern: d.iter().map(|v| *v as f64 * scale).collect(), + offset: s.dashoffset() as f64 * scale, + align_corners: false, + }); + } + stroke = Some(AppearanceItem::Stroke(sl)); + } + let items = match p.paint_order() { + usvg::PaintOrder::FillAndStroke => [fill, stroke], + usvg::PaintOrder::StrokeAndFill => [stroke, fill], + }; + (Appearance { items: items.into_iter().flatten().collect(), ..Default::default() }, r) + } + + fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option { + if !p.is_visible() { + return None; + } + let path = PathData::from_bezpath(&bezpath(p.data(), acc)); + if path.is_empty() { + return None; + } + let (appearance, r) = self.appearance(p, acc, path.bounds()); + // Shared, so a compound path's subpaths don't each copy a gradient's stops. + let appearance: vectorcraft_doc::Shared = appearance.into(); + let mut n = if path.subpaths.len() > 1 { + // Multi-subpath SVG paths are compound paths (as in Illustrator). + let children = path + .subpaths + .into_iter() + .map(|sp| { + let id = self.doc.alloc_id(); + let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }); + c.appearance = appearance.clone(); + Arc::new(c) + }) + .collect(); + self.named(p.id(), NodeKind::Compound { children, rule: r }) + } else { + self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false }) + }; + n.appearance = appearance; + Some(n) + } + + fn image(&mut self, i: &usvg::Image, acc: Affine) -> Option { + if !i.is_visible() { + return None; + } + let (bytes, mime) = match i.kind() { + usvg::ImageKind::JPEG(d) => (d, "image/jpeg"), + usvg::ImageKind::PNG(d) => (d, "image/png"), + usvg::ImageKind::GIF(d) => (d, "image/gif"), + usvg::ImageKind::WEBP(d) => (d, "image/webp"), + usvg::ImageKind::SVG(tree) => return self.svg_image(tree, acc), + }; + let size = i.size(); + let (pw, ph) = image::ImageReader::new(std::io::Cursor::new(&bytes[..])) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); + let key = format!("img-{:016x}", fnv1a(bytes)); + // A linked file keeps a preview; a placeholder is all a missing one has (its preview). + let linked = self.files.linked(i.kind()); + let missing = linked.is_some_and(|l| l.missing); + let link = linked.map(|l| l.link.clone()); + self.doc.images.entry(key.clone()).or_insert_with(|| { + let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) }; + if link.is_some() && !missing { blob.with_proxy() } else { blob } + }); + let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); + Some(self.named( + i.id(), + NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default(), pixels: None })), + )) + } + + /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. + /// Its ids are its own: they mean nothing in the SVG around it. + fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option { + let outer = ( + std::mem::take(&mut self.links), + std::mem::take(&mut self.hidden), + std::mem::take(&mut self.uses), + std::mem::take(&mut self.labels), + std::mem::take(&mut self.non_scaling), + std::mem::take(&mut self.data), + std::mem::take(&mut self.events), + ); + let art = self.group_node(tree.root(), acc); + (self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data, self.events) = outer; + art + } +} diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs new file mode 100644 index 000000000..8dad45368 --- /dev/null +++ b/crates/svg/src/import/layers.rs @@ -0,0 +1,152 @@ +use super::convert::made_up; +use super::importer::{CSS_PX_PER_IN, PT_PER_IN}; +use super::*; + +/// What marks a nested `` as a sublayer rather than a group: our export writes it on the layers +/// it writes inside layers. (Inkscape's `inkscape:groupmode="layer"` counts too.) +pub(crate) const SUBLAYER: &str = "data-vc-layer"; + +/// The Inkscape namespace (its layers and their labels). +pub(super) const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape"; + +/// The layer name on root element `root` (``, or its `data-name`), as exports of +/// one-layer documents write it. An Inkscape root's id (`svg8`) is one Inkscape made up, not a name. +pub(super) fn root_layer_name(root: XNode) -> Option { + let id = root.attribute("id").filter(|_| root.attribute((INKSCAPE, "version")).is_none()); + root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) +} + +/// Is element `g` a `` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or +/// the label beside it) is its name. +pub(super) fn marked_layer(g: XNode) -> bool { + g.tag_name().name() == "g" + && g.attribute("id").is_some_and(|id| !id.is_empty()) + && (g.has_attribute(SUBLAYER) || g.attribute((INKSCAPE, "groupmode")) == Some("layer")) +} + +/// Turn the groups made of elements marked as layers (`marked`, see [`marked_layer`]) right inside +/// layer `l` into its sublayers, in its colour, and theirs in turn. A marked group anywhere else +/// (in a group, a mask, a symbol) stays a group: only layers hold layers. +pub(super) fn promote_sublayers(l: &mut Node, marked: &HashSet) { + let NodeKind::Layer { color, children, .. } = &mut l.kind else { return }; + let color = *color; + for c in children.iter_mut().filter(|c| marked.contains(&c.id)) { + let c = Arc::make_mut(c); + let NodeKind::Group { children, clip } = &mut c.kind else { continue }; + let mut sub = Node::layer(c.id, "", color); + sub.set_clips(*clip); + if let Some(ch) = sub.children_mut() { + *ch = std::mem::take(children); + } + c.kind = sub.kind; + promote_sublayers(c, marked); + } +} + +/// SVG's initial `font-size` (`medium`) in user units. +pub(crate) const DEFAULT_FONT_SIZE: f64 = 12.0; + +/// How lengths become points, from the root ``'s `width` / `height`. +/// +/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their +/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in +/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it +/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. With one +/// side absolute and the other not (`width="96pt" height="96"`), both are: the unitless side is +/// CSS pixels too, so the drawing isn't stretched (#1010). +pub(super) struct RootUnits { + /// Pixels per inch usvg (and the text pass) convert absolute lengths at. + pub(super) dpi: f64, + /// Points per user unit of the root, along both axes. + pub(super) k: f64, + /// Document units: those of the root `width` (pixels when it has none). + pub(super) unit: Unit, +} + +impl RootUnits { + pub(super) fn of(root: XNode) -> Self { + let unit = |name: &str| root.attribute(name).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit); + use svgtypes::LengthUnit as U; + let physical = |u: Option| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc)); + let (w, h) = (unit("width"), unit("height")); + let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) }; + let unit = match w { + Some(U::Mm) => Unit::Millimeters, + Some(U::Cm) => Unit::Centimeters, + Some(U::In) => Unit::Inches, + Some(U::Pt) => Unit::Points, + Some(U::Pc) => Unit::Picas, + _ => Unit::Pixels, + }; + Self { dpi, k, unit } + } +} + +/// `'id' (feA, feB)` for each filter: its id and the primitives it uses, for warnings. +pub(super) fn filter_names(filters: &[Arc]) -> String { + use usvg::filter::Kind as K; + let names: Vec = filters + .iter() + .map(|f| { + let mut kinds: Vec<&str> = Vec::new(); + for p in f.primitives() { + let k = match p.kind() { + K::Blend(_) => "feBlend", + K::ColorMatrix(_) => "feColorMatrix", + K::ComponentTransfer(_) => "feComponentTransfer", + K::Composite(_) => "feComposite", + K::ConvolveMatrix(_) => "feConvolveMatrix", + K::DiffuseLighting(_) => "feDiffuseLighting", + K::DisplacementMap(_) => "feDisplacementMap", + K::DropShadow(_) => "feDropShadow", + K::Flood(_) => "feFlood", + K::GaussianBlur(_) => "feGaussianBlur", + K::Image(_) => "feImage", + K::Merge(_) => "feMerge", + K::Morphology(_) => "feMorphology", + K::Offset(_) => "feOffset", + K::SpecularLighting(_) => "feSpecularLighting", + K::Tile(_) => "feTile", + K::Turbulence(_) => "feTurbulence", + }; + if !kinds.contains(&k) { + kinds.push(k); + } + } + let id = if f.id().is_empty() || made_up(f.id()) { String::new() } else { format!("'{}' ", f.id()) }; + format!("{id}({})", kinds.join(", ")) + }) + .collect(); + names.join(", ") +} + +/// Inkscape's pages (`` in its ``, in user units) as artboards +/// through `user` (user units → document points), named by their labels; `None` without any. +pub(super) fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option> { + let num = |n: roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let pages: Vec = xml + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "page" && n.tag_name().namespace() == Some(INKSCAPE)) + .filter_map(|p| { + let (w, h) = (num(p, "width")?, num(p, "height")?); + let (x, y) = (num(p, "x").unwrap_or(0.0), num(p, "y").unwrap_or(0.0)); + // A box too large to add up or to place (1e308 wide) is no page. + let r = user.transform_rect_bbox(Rect::new(x, y, x + w, y + h)); + (w > 0.0 && h > 0.0 && r.is_finite()).then_some((p, r)) + }) + .take(1000) + .enumerate() + .map(|(i, (p, r))| vectorcraft_doc::Artboard { + id: i as u32 + 1, + name: p.attribute((INKSCAPE, "label")).filter(|l| !l.trim().is_empty()).map_or_else(|| format!("Artboard {}", i + 1), str::to_string), + rect: r, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }) + .collect(); + (!pages.is_empty()).then_some(pages) +} diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs new file mode 100644 index 000000000..eb9519567 --- /dev/null +++ b/crates/svg/src/import/prepass.rs @@ -0,0 +1,388 @@ +use super::convert::{MADE_UP_ID, href}; +use super::layers::{INKSCAPE, marked_layer}; +use super::*; + +/// What usvg would lose, found in the XML before it runs ([`prepass`]). +#[derive(Default)] +pub(super) struct Found { + /// The URL of each ``'s group, by its id. + pub(super) links: HashMap, + /// Ids of the undisplayed objects shown for usvg: they come back hidden. + pub(super) hidden: HashSet, + /// The `` id each `` of a symbol shows, by the ``'s id. + pub(super) uses: HashMap, + /// The files `` elements link to. + pub(super) files: files::Files, + /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). + pub(super) non_scaling: HashSet, + /// The `data-*` attributes of elements, by id ([`user_data`]). + pub(super) data: HashMap>, + /// The event attributes of elements, by id ([`user_events`]). + pub(super) events: HashMap>, + /// The attributes of elements in other applications' namespaces, by id ([`foreign`]). + pub(super) foreign: HashMap>, + /// The JavaScript files `