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/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/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 186b87bb0..26d463091 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -352,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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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/a0916808f7d78bed8c7b90cdf937176015454a7f/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 926fea946..d329c26a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,15 @@ dependencies = [ "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" @@ -950,12 +959,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +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=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "serde", @@ -965,7 +1002,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -976,11 +1013,11 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree 0.20.0", + "miniz_oxide 0.9.1", + "roxmltree 0.21.1", "tempfile", "winprint", "winsafe", @@ -989,20 +1026,28 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +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=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -1027,7 +1072,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "objc2 0.6.5", @@ -1599,6 +1644,17 @@ 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" @@ -2117,7 +2173,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "brotli", "hayro-postscript", @@ -2126,7 +2182,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2144,7 +2200,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2162,12 +2218,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +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=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "flate2", "hayro-ccitt", @@ -2337,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" @@ -2382,6 +2455,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2391,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" @@ -2420,6 +2522,7 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", "serde", "stable_deref_trait", "writeable", @@ -2429,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" @@ -2704,7 +2831,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2834,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" @@ -3809,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" @@ -5223,6 +5363,18 @@ 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" @@ -5853,6 +6005,12 @@ 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" @@ -7379,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" @@ -7705,6 +7872,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", "zerovec", diff --git a/Cargo.toml b/Cargo.toml index 5c411d256..644bffe9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,15 +11,15 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] } -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +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 = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["egui"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", 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" } @@ -141,12 +141,12 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +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" } diff --git a/NOTICE b/NOTICE index c66f3efad..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 -a0916808f7d78bed8c7b90cdf937176015454a7f. 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/a0916808f7d78bed8c7b90cdf937176015454a7f/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. 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 c7886df3e..508ab0daf 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -289,399 +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) } - } - - /// 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(); - } -} +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 a92cda204..92c10de07 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, titlebar_override}; + struct App { app: VectorcraftApp, /// Reported by wgpu when the window's graphics device is lost (a driver reset). @@ -83,700 +108,6 @@ struct App { maximize_tries: u8, } -/// 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")] -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")))] -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")))))] -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))] -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). -/// A file read only in part, or with layouts converted from an older version, is copied beside -/// itself before anything saves over it. -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`). -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. -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. -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); -} - -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() || 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. -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(), - // 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}")) -} - -/// 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: 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; Linux and the BSDs decide in [`custom_titlebar_from`], where -/// VectorCraft also draws its own title bar by default. -#[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 `VECTORCRAFT_TITLEBAR` (`overridden`, as -/// [`craft_window::Choice::overridden`] reads it) and the user's System Title Bar preference. -/// -/// Linux and the BSDs: VectorCraft draws its own title bar on every desktop (GNOME, KDE, Sway, -/// Hyprland, COSMIC, X11 window managers) unless System Title Bar is on, as PhotoCraft does. The -/// variable decides for one run when it is set: `app` draws VectorCraft's bar and `system` or -/// `desktop` keeps the desktop's frame, whatever the preference says. The desktop the -/// `craft-window` probe finds does not change the result. -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn custom_titlebar_from(overridden: Option, opted_into_system: bool) -> bool { - overridden.unwrap_or(!opted_into_system) -} - -/// macOS and Windows: decided by [`CUSTOM_TITLEBAR`] and the preference; `VECTORCRAFT_TITLEBAR` -/// is not read. 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(_overridden: Option, opted_into_system: bool) -> bool { - CUSTOM_TITLEBAR && !opted_into_system -} - -/// What `VECTORCRAFT_TITLEBAR` asks for on Linux and the BSDs: `Some(true)` VectorCraft's own title -/// bar, `Some(false)` the desktop's frame, `None` when it is unset or holds another value. Always -/// `None` on macOS and Windows. The `craft-window` probe runs once per process. -fn titlebar_override() -> Option { - static OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); - if cfg!(any(target_os = "macos", target_os = "windows")) { - return None; - } - *OVERRIDE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")).overridden()) -} - -/// Whether this start draws its own title bar: [`custom_titlebar_from`] with `VECTORCRAFT_TITLEBAR` -/// and the saved System Title Bar preference. Call once at startup, before [`native_options`]. -fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - custom_titlebar_from(titlebar_override(), opted_into_system_title_bar(saved)) -} - -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…] @@ -1165,319 +496,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 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() { - // 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))); - - // macOS and Windows: `VECTORCRAFT_TITLEBAR` is not read, and the preference turns the - // native frame back on where VectorCraft would draw its own. - #[cfg(any(target_os = "macos", target_os = "windows"))] - { - assert_eq!(super::titlebar_override(), None); - for overridden in [None, Some(true), Some(false)] { - assert_eq!(super::custom_titlebar_from(overridden, false), super::CUSTOM_TITLEBAR, "{overridden:?}"); - assert!(!super::custom_titlebar_from(overridden, true), "{overridden:?}"); - } - } - - assert_eq!(super::native_options(false).viewport.decorations, Some(true)); - assert_eq!(super::native_options(true).viewport.decorations, Some(false)); - } - - /// Linux and the BSDs: VectorCraft's own title bar on every desktop unless System Title Bar is - /// on, and `VECTORCRAFT_TITLEBAR` decides over the preference for the run it is set in. - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - #[test] - fn every_linux_desktop_gets_the_app_title_bar_unless_system_title_bar_is_on() { - // What the `craft-window` probe reports: the desktop's frame on KDE Plasma, Sway, Hyprland, - // COSMIC and X11, the app's on GNOME's Wayland session; none of them sets the override. - for desktop in ["GNOME Wayland", "KDE Plasma", "Sway", "Hyprland", "COSMIC", "X11 window manager", "GNOME on X11"] { - assert!(super::custom_titlebar_from(None, false), "{desktop}: VectorCraft's own title bar"); - assert!(!super::custom_titlebar_from(None, true), "{desktop}: System Title Bar keeps the desktop's frame"); - } - // VECTORCRAFT_TITLEBAR=app draws VectorCraft's bar and =system (or =desktop) keeps the - // desktop's frame, whatever the preference says. - for opted_into_system in [false, true] { - assert!(super::custom_titlebar_from(Some(true), opted_into_system), "app, preference {opted_into_system}"); - assert!(!super::custom_titlebar_from(Some(false), opted_into_system), "system, preference {opted_into_system}"); - } - } - - /// 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/tests.rs b/apps/vectorcraft/src/tests.rs new file mode 100644 index 000000000..aead748a9 --- /dev/null +++ b/apps/vectorcraft/src/tests.rs @@ -0,0 +1,314 @@ +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() { + // 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))); + + // macOS and Windows: `VECTORCRAFT_TITLEBAR` is not read, and the preference turns the + // native frame back on where VectorCraft would draw its own. + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + assert_eq!(super::titlebar_override(), None); + for overridden in [None, Some(true), Some(false)] { + assert_eq!(super::custom_titlebar_from(overridden, false), super::CUSTOM_TITLEBAR, "{overridden:?}"); + assert!(!super::custom_titlebar_from(overridden, true), "{overridden:?}"); + } + } + + assert_eq!(super::native_options(false).viewport.decorations, Some(true)); + assert_eq!(super::native_options(true).viewport.decorations, Some(false)); +} + +/// Linux and the BSDs: VectorCraft's own title bar on every desktop unless System Title Bar is +/// on, and `VECTORCRAFT_TITLEBAR` decides over the preference for the run it is set in. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[test] +fn every_linux_desktop_gets_the_app_title_bar_unless_system_title_bar_is_on() { + // What the `craft-window` probe reports: the desktop's frame on KDE Plasma, Sway, Hyprland, + // COSMIC and X11, the app's on GNOME's Wayland session; none of them sets the override. + for desktop in ["GNOME Wayland", "KDE Plasma", "Sway", "Hyprland", "COSMIC", "X11 window manager", "GNOME on X11"] { + assert!(super::custom_titlebar_from(None, false), "{desktop}: VectorCraft's own title bar"); + assert!(!super::custom_titlebar_from(None, true), "{desktop}: System Title Bar keeps the desktop's frame"); + } + // VECTORCRAFT_TITLEBAR=app draws VectorCraft's bar and =system (or =desktop) keeps the + // desktop's frame, whatever the preference says. + for opted_into_system in [false, true] { + assert!(super::custom_titlebar_from(Some(true), opted_into_system), "app, preference {opted_into_system}"); + assert!(!super::custom_titlebar_from(Some(false), opted_into_system), "system, preference {opted_into_system}"); + } +} + +/// 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..f6bf259c3 --- /dev/null +++ b/apps/vectorcraft/src/titlebar.rs @@ -0,0 +1,102 @@ +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: 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; Linux and the BSDs decide in [`custom_titlebar_from`], where +/// VectorCraft also draws its own title bar by default. +#[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 `VECTORCRAFT_TITLEBAR` (`overridden`, as +/// [`craft_window::Choice::overridden`] reads it) and the user's System Title Bar preference. +/// +/// Linux and the BSDs: VectorCraft draws its own title bar on every desktop (GNOME, KDE, Sway, +/// Hyprland, COSMIC, X11 window managers) unless System Title Bar is on, as PhotoCraft does. The +/// variable decides for one run when it is set: `app` draws VectorCraft's bar and `system` or +/// `desktop` keeps the desktop's frame, whatever the preference says. The desktop the +/// `craft-window` probe finds does not change the result. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn custom_titlebar_from(overridden: Option, opted_into_system: bool) -> bool { + overridden.unwrap_or(!opted_into_system) +} + +/// macOS and Windows: decided by [`CUSTOM_TITLEBAR`] and the preference; `VECTORCRAFT_TITLEBAR` +/// is not read. 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(_overridden: Option, opted_into_system: bool) -> bool { + CUSTOM_TITLEBAR && !opted_into_system +} + +/// What `VECTORCRAFT_TITLEBAR` asks for on Linux and the BSDs: `Some(true)` VectorCraft's own title +/// bar, `Some(false)` the desktop's frame, `None` when it is unset or holds another value. Always +/// `None` on macOS and Windows. The `craft-window` probe runs once per process. +pub(crate) fn titlebar_override() -> Option { + static OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); + if cfg!(any(target_os = "macos", target_os = "windows")) { + return None; + } + *OVERRIDE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")).overridden()) +} + +/// Whether this start draws its own title bar: [`custom_titlebar_from`] with `VECTORCRAFT_TITLEBAR` +/// and the saved System Title Bar preference. Call once at startup, before [`native_options`]. +pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + custom_titlebar_from(titlebar_override(), opted_into_system_title_bar(saved)) +} + +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/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/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/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..9d17f689a --- /dev/null +++ b/crates/doc/src/document_ext.rs @@ -0,0 +1,285 @@ +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) + } +} + +/// 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/lib.rs b/crates/doc/src/lib.rs index 6349f9f4c..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,695 +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, -} - -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() - } -} - -#[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 { @@ -999,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 } @@ -1040,1772 +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 - } - /// 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 - } -} - -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(); - } - - /// 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) - } -} - -/// 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/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/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/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 067eae7b4..a3ab2ccc5 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -6,1623 +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 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; - -/// 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`. /// @@ -1713,828 +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 - } - /// 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); - } - } -} - #[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"}) - ); - } - - /// 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); - } -} +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 9f0eb549d..ad5927850 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -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 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs 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/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index d485366ea..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. @@ -266,96 +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`. -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 -} - -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()) })) -} - -/// 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()); - } - } -} - -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) })) -} - -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()?; @@ -367,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; @@ -391,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 03166a5b0..8d72ce335 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -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"; @@ -253,195 +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 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) -} - /// 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> { @@ -478,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). @@ -604,775 +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")); - } - // 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 != super::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 => 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/create.rs b/crates/engine/src/cmd/create.rs index 8c4e71f1f..d5588ff44 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -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) 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/draw2.rs b/crates/engine/src/cmd/draw2.rs index 0ef78a06a..5b884b3ee 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -20,1631 +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), 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 - ), - ] -} +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. 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(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)] - }) -} - -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(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))) -} - -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) -} - -// ---------- 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 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)) -} - -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) -} - -// ---------- 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(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 ---------- - -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 })) -} - -// ---------- 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::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 })) -} +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/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/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/findfiles/mod.rs b/crates/engine/src/cmd/findfiles.rs similarity index 100% rename from crates/engine/src/cmd/findfiles/mod.rs rename to crates/engine/src/cmd/findfiles.rs diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 741921ea1..b9b0c8bf2 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -40,1585 +40,17 @@ use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key}; use super::pathops::{node_path, outline_strokes, outline_strokes_under, separate, shape_node}; 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 - ), - ] -} - -/// 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/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/paint.rs b/crates/engine/src/cmd/paint.rs index f6e555892..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 ), @@ -742,7 +742,7 @@ fn transparency(s: &mut Session, p: &Value) -> 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. @@ -766,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/path.rs b/crates/engine/src/cmd/path.rs index 4de797583..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,115 +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")); } - let st = s.doc()?; - let picked_a = st.selection.anchors.get(&a).cloned().unwrap_or_default(); - let picked_b = st.selection.anchors.get(&b).cloned().unwrap_or_default(); + } + 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)); + } + } + match best { + Some((rx, ry, _)) => (rx, ry), + None => (ea == "first", eb == "last"), + } + } + }; + 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| { - 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)]; - // Direct-selected end points decide which ends meet, before the closest pair does. - let end_of = |picked: &std::collections::BTreeSet<(usize, usize)>, si: usize, len: usize| { - let last_ix = len.checked_sub(1)?; - let first = picked.contains(&(si, 0)); - let last = picked.contains(&(si, last_ix)); - match (first, last) { - (true, false) => Some(true), - (false, true) => Some(false), - _ => None, - } - }; - let by_selection = match (end_of(&picked_a, sa, x.anchors.len()), end_of(&picked_b, sb, y.anchors.len())) { - (Some(fa), Some(fb)) => Some((fa, !fb)), - _ => None, - }; - let (rx, ry) = ends - .or(by_selection) - .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; + 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)); } } - x.anchors.extend(y.anchors); - if let Some(smooth) = join_kind { - join_point(&mut x, junction, smooth); - } - pa.subpaths[sa] = x; - d.remove(b)?; - sel.set([a]); + } + } + } + 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 { @@ -643,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 @@ -654,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); @@ -712,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/place/mod.rs b/crates/engine/src/cmd/place.rs similarity index 100% rename from crates/engine/src/cmd/place/mod.rs rename to crates/engine/src/cmd/place.rs diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 680024832..3667169a4 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -625,490 +625,4 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn session_with_text(text: &str) -> (Session, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": text})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - (s, id) - } - - fn runs(s: &Session, id: u64) -> Vec { - match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!(), - } - } - - #[test] - fn char_style_new_apply_range_redefine_keeps_overrides() { - let (mut s, id) = session_with_text("Hello world"); - s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0, "underline": true}})).unwrap(); - // Style "world" only (a Type tool range). - s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11})).unwrap(); - let r = runs(&s, id); - assert_eq!(r.len(), 2); - assert_eq!((r[1].text.as_str(), r[1].style.size, r[1].style.underline), ("world", 24.0, true)); - assert_eq!(r[1].style.style_name.as_deref(), Some("Emphasis")); - assert_eq!(r[0].style.style_name, None); - // A local override on the styled run survives a redefinition; other attributes update. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 11, "size": 30.0})).unwrap(); - s.execute("charStyle.setAttrs", &json!({"name": "Emphasis", "attrs": {"size": 18.0, "underline": false, "tracking": 50.0}})).unwrap(); - let r = runs(&s, id); - assert_eq!((r[1].style.size, r[1].style.underline, r[1].style.tracking), (30.0, false, 50.0)); - // Clear Overrides: back to exactly the style's attributes. - s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11, "clearOverrides": true})).unwrap(); - assert_eq!(runs(&s, id)[1].style.size, 18.0); - let list = s.execute("charStyle.list", &json!({})).unwrap(); - assert_eq!(list["styles"][0]["name"], NORMAL_CHAR); - assert_eq!(list["styles"][1]["uses"], 1); - } - - /// Applying and redefining a style record a step named from a template with the style's name - /// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are - /// marked as names VectorCraft gives. A new style's step has a name of its own. - #[test] - fn style_steps_are_named_from_a_template() { - let (mut s, id) = session_with_text("Hello"); - let last = |s: &Session| { - let e = s.doc().unwrap().history.undo.last().unwrap(); - (e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in))) - }; - let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in)); - s.execute("charStyle.new", &json!({"name": "Body"})).unwrap(); - assert_eq!(last(&s), ("New Character Style".into(), None)); - s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); - assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); - s.execute("edit.undo", &json!({})).unwrap(); - let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned(); - assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body")); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); - s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap(); - assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true))); - s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap(); - assert_eq!(last(&s), ("New Paragraph Style".into(), None)); - s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap(); - assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false))); - // A step renamed since it was recorded is no longer named from the template. - s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into(); - assert_eq!(last(&s), ("Typing".into(), None)); - } - - #[test] - fn new_from_selection_rename_delete_and_undo() { - let (mut s, id) = session_with_text("Title"); - s.execute("text.setStyle", &json!({"size": 40})).unwrap(); - let name = s.execute("charStyle.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - assert_eq!(name, "Character Style 1"); - assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs["size"], 40.0); - s.execute("charStyle.apply", &json!({"name": name})).unwrap(); - s.execute("charStyle.rename", &json!({"name": name, "to": "Heading"})).unwrap(); - assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); - assert!(s.execute("charStyle.rename", &json!({"name": NORMAL_CHAR, "to": "x"})).is_err()); - s.execute("charStyle.duplicate", &json!({"name": "Heading"})).unwrap(); - s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); - assert_eq!(runs(&s, id)[0].style.style_name, None); - assert_eq!(runs(&s, id)[0].style.size, 40.0, "deleting keeps the look"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); - assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"nope": 1}})).is_err()); - // Attributes are stored canonically (a hand-written colour matches the text's afterwards). - s.execute( - "charStyle.new", - &json!({"name": "Pink", "attrs": {"fill": {"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.4}}}}), - ) - .unwrap(); - s.execute("charStyle.apply", &json!({"name": "Pink"})).unwrap(); - let fill = serde_json::to_value(&runs(&s, id)[0].style.fill).unwrap(); - assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); - } - - /// New Character Style and New Paragraph Style trim the name they are given, as a rename - /// trims `to`; the trimmed name is the one compared with the styles there are. - #[test] - fn new_styles_trim_their_names_as_a_rename_does() { - let (mut s, _) = session_with_text("Body text"); - for p in ["charStyle", "paraStyle"] { - assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}"); - let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); - let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect(); - assert_eq!(names[1..], ["Body"], "{p}"); - let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string(); - assert!(e.contains("a style named `Body` already exists"), "{p}: {e}"); - let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string(); - assert!(e.contains("give the style a name"), "{p}: {e}"); - assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}"); - } - // A style whose name has spaces around it, as a saved file can hold, is copied under the - // trimmed name, numbered past the copies there are. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() }); - s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap(); - assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); - } - - /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no - /// mojikumi) are still attributes a style can set, and a style captured with one applies to - /// text at the default, is duplicated and comes back out of a library. - #[test] - fn styles_take_attributes_left_out_at_their_defaults() { - let (mut s, id) = session_with_text("A"); - s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); - s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); - s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); - s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); - for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { - assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); - } - // Captured from text with explicit leading, applied to fresh Auto-leading text. - s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); - let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); - assert_eq!(runs(&s, fresh)[0].style.leading, None); - for clear in [false, true] { - s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); - assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); - } - let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); - assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); - s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); - let st = runs(&s, fresh)[0].style.clone(); - assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); - s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; - assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); - // A library keeps the captured attributes and makes the style again where it's used. - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); - let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); - s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); - assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); - } - - #[test] - fn auto_leading_redefine_keeps_overrides_and_history() { - for redefine in [true, false] { - let (mut s, source) = session_with_text("Source"); - let [linked, local] = - ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); - let ids = [source, linked, local]; - s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"leading": 30}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - s.execute("text.setStyle", &json!({"ids": [local], "leading": 42})).unwrap(); - s.execute("text.setStyle", &json!({"ids": [source], "leading": "auto"})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - if redefine { - s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - } else { - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": null}})).unwrap(); - } - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); - assert!(ids.iter().all(|&id| runs(&s, id)[0].style.style_name.as_deref() == Some("Body"))); - let after = s.doc().unwrap().doc.clone(); - assert_eq!(after.char_styles[0].attrs.get("leading"), Some(&Value::Null), "Auto is explicit, not an unset attribute"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - let back = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap(); - assert_eq!(back.char_styles, after.char_styles); - for id in ids { - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("not text") }; - assert_eq!(t.runs, runs(&s, id)); - } - // Auto users follow the next numeric definition; real numeric and Auto overrides stay. - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 26}})).unwrap(); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(26.0), Some(26.0), Some(42.0)]); - s.execute("text.setStyle", &json!({"ids": [linked], "leading": "auto"})).unwrap(); - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 28}})).unwrap(); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); - // Omitting leading replaces the definition without resetting the text's leading. - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"tracking": 20}})).unwrap(); - assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); - } - } - - /// The attribute names a style of type `T` can set, without the style name. - fn settable() -> std::collections::BTreeSet { - attr_names::().into_iter().filter(|&n| n != "style_name").map(String::from).collect() - } - - /// New and Redefine capture every paragraph attribute. A paragraph style redefined from a - /// paragraph at the defaults gives its paragraphs the defaults back where they had the old - /// definition's values, and `setAttrs` with a default value does too; local overrides, and - /// attributes `setAttrs` leaves out, stay. - #[test] - fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { - use vectorcraft_doc::{Composer, HyphenationOptions}; - let (mut s, source) = session_with_text("Source"); - let [linked, local] = - ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); - let ids = [source, linked, local]; - let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para_at(0).clone(), - _ => panic!("not text"), - }; - let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit); - let composers = |s: &Session| ids.map(|id| para(s, id).composer); - let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::>(); - s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap(); - s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap(); - s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); - assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default()); - assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body"))); - assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); - s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); - assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); - // Style options: a default value reaches the users too; an attribute left out stays as it is. - s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); - s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3])); - assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); - } - - /// The same for character styles: Auto kerning and the Roman baseline alignment, which the - /// run's JSON leaves out, are captured and reach the style's users. - #[test] - fn char_style_redefined_to_the_defaults_updates_its_text() { - use vectorcraft_doc::CharAlign; - let (mut s, source) = session_with_text("Source"); - let [linked, local] = - ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); - let ids = [source, linked, local]; - let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning); - let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align); - let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::>(); - s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); - s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); - assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); - s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); - assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3])); - assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); - } - - /// A style captured from type without Variable Font settings sets none explicitly (`{}`): - /// applying it clears type's settings, and redefining it from type whose settings were reset - /// resets the type that uses it. - #[test] - fn variable_font_settings_are_captured_explicitly() { - let _fonts = crate::adding_fonts(); - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let (mut s, source) = session_with_text("Source"); - let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap(); - let ids = [source, linked]; - s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({}))); - s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied()); - assert_eq!(wght(&s), [None, None], "applying the style clears the settings"); - for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] { - s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap(); - s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!(wght(&s), [want, want], "redefined from {set}"); - } - } - - /// A style that changes the font, applied or redefined, leaves none of the old font's - /// version and Variable Font settings, unless the style sets them. - #[test] - fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { - let _fonts = crate::adding_fonts(); - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let (mut s, id) = session_with_text("Source"); - let set_variable = |s: &mut Session| { - s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap(); - s.edit("version", |d, _| { - if let Some(t) = text_mut(d, NodeId(id)) { - t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into())); - } - Ok(()) - }) - .unwrap(); - }; - let font = |s: &Session| { - let st = runs(s, id)[0].style.clone(); - (st.font_family, st.font_version, st.font_axes.get("wght").copied()) - }; - set_variable(&mut s); - s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap(); - assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them"); - s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); - assert_eq!(font(&s), ("Inter".to_string(), None, None)); - // A style that sets the settings keeps its own. - s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap(); - assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0))); - // Redefined to another font: its users' settings and versions go too. - s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap(); - set_variable(&mut s); - assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans")); - s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap(); - assert_eq!(font(&s), ("Inter".to_string(), None, None)); - } - - #[test] - fn auto_leading_new_distinguishes_unset_attributes() { - let (mut s, id) = session_with_text("Title"); - s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Sparse", "attrs": {"tracking": 20}})).unwrap(); - assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); - s.execute("charStyle.apply", &json!({"name": "Sparse", "id": id})).unwrap(); - assert_eq!(runs(&s, id)[0].style.leading, Some(30.0)); - s.execute("charStyle.new", &json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Automatic", "id": id})).unwrap(); - assert_eq!(runs(&s, id)[0].style.leading, None); - assert_eq!(s.doc().unwrap().doc.char_styles[1].attrs.get("leading"), Some(&Value::Null)); - // New from Auto text captures that value too, rather than leaving leading unspecified. - s.execute("charStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); - s.execute("text.setStyle", &json!({"ids": [id], "leading": 36})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Captured", "id": id})).unwrap(); - assert_eq!(runs(&s, id)[0].style.leading, None); - assert_eq!(s.doc().unwrap().doc.char_styles[2].attrs.get("leading"), Some(&Value::Null)); - } - - #[test] - fn paragraph_styles_and_survive_save() { - let (mut s, id) = session_with_text("Body"); - s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6.0}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Centered"})).unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!(), - }; - assert_eq!((para(&s).justify, para(&s).space_after), (vectorcraft_doc::Justify::Center, 6.0)); - // The Normal style can be redefined too (stored once redefined). - s.execute("paraStyle.redefine", &json!({"name": NORMAL_PARA})).unwrap(); - assert_eq!(s.execute("paraStyle.list", &json!({})).unwrap()["styles"][0]["attrs"]["justify"], "Center"); - let d = s.doc().unwrap().doc.clone(); - let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); - assert_eq!(back.para_styles, d.para_styles); - } - - fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option, expected_size: f64) { - let (mut s, id) = session_with_text(text); - s.execute("text.setStyle", &json!({"id": id, "size": 18})).unwrap(); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": text.len(), "size": 34})).unwrap(); - if redefine { - s.execute("charStyle.new", &json!({"name": "Captured", "attrs": {"size": 24}})).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(); - let mut params = json!({"name": "Captured", "id": id, "start": start}); - if let Some(end) = end { - params["end"] = json!(end); - } - let command = if redefine { "charStyle.redefine" } else { "charStyle.new" }; - s.execute(command, ¶ms).unwrap(); - let st = s.doc().unwrap(); - let def = st.doc.char_styles.iter().find(|d| d.name == "Captured").unwrap(); - assert_eq!(def.attrs.get("size").and_then(Value::as_f64), Some(expected_size), "{command} {text:?} {start}..{end:?}"); - assert!(!def.attrs.contains_key("style_name")); - assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id)), "capture does not apply the definition"); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); - assert!(st.is_dirty()); - let after = st.doc.clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - assert_eq!(s.doc().unwrap().selection, selection); - assert!(!s.doc().unwrap().is_dirty()); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - assert_eq!(s.doc().unwrap().selection, selection); - } - - fn capture_boundary_ranges(redefine: bool) { - for (text, start, end, size) in [ - ("ABcdef", 2, 5, 34.0), - ("é漢字", 2, 5, 34.0), - ("ABcdef", 5, 2, 34.0), // command ranges are ordered before selected-character sampling - ("ABcdef", 1, 5, 18.0), // a mixed range samples its first selected character - ("ABcdef", 2, 2, 18.0), // collapsed range retains insertion affinity - ("ABcdef", 3, 3, 34.0), - ("ABcdef", 99, 100, 34.0), // beyond the text retains the final run - ] { - capture_boundary_command(redefine, text, start, Some(end), size); - } - } - - #[test] - fn char_style_new_range_capture_boundary() { - capture_boundary_ranges(false); - } - - #[test] - fn char_style_redefine_range_capture_boundary() { - capture_boundary_ranges(true); - } - - #[test] - fn char_style_start_only_capture_affinity_controls() { - for redefine in [false, true] { - for text in ["ABcdef", "é漢字"] { - for (start, size) in [(0, 18.0), (2, 18.0), (3, 34.0), (99, 34.0)] { - capture_boundary_command(redefine, text, start, None, size); - } - } - } - } - - #[test] - fn para_style_range_capture_samples_start_paragraph() { - for redefine in [false, true] { - for (start, end, justify) in [(0, 7, "Left"), (4, 7, "Right"), (4, 0, "Right")] { - let (mut s, id) = session_with_text("One\nTwo"); - s.execute("text.setStyle", &json!({"id": id, "justify": "left"})).unwrap(); - s.execute("text.setStyle", &json!({"id": id, "start": 4, "end": 7, "justify": "right"})).unwrap(); - if redefine { - s.execute("paraStyle.new", &json!({"name": "Captured", "attrs": {"justify": "Center"}})).unwrap(); - } - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let undo = s.doc().unwrap().history.undo.len(); - let command = if redefine { "paraStyle.redefine" } else { "paraStyle.new" }; - s.execute(command, &json!({"name": "Captured", "id": id, "start": start, "end": end})).unwrap(); - let st = s.doc().unwrap(); - let def = st.doc.para_styles.iter().find(|d| d.name == "Captured").unwrap(); - assert_eq!(def.attrs["justify"], json!(justify), "paragraph capture still samples start, not the end or a normalized range"); - assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id))); - assert_eq!(st.selection, selection); - assert_eq!(st.history.undo.len(), undo + 1); - let after = st.doc.clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - } - } - } -} +mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs new file mode 100644 index 000000000..e322e814a --- /dev/null +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -0,0 +1,485 @@ +use super::*; + +fn session_with_text(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": text})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) +} + +fn runs(s: &Session, id: u64) -> Vec { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!(), + } +} + +#[test] +fn char_style_new_apply_range_redefine_keeps_overrides() { + let (mut s, id) = session_with_text("Hello world"); + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0, "underline": true}})).unwrap(); + // Style "world" only (a Type tool range). + s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11})).unwrap(); + let r = runs(&s, id); + assert_eq!(r.len(), 2); + assert_eq!((r[1].text.as_str(), r[1].style.size, r[1].style.underline), ("world", 24.0, true)); + assert_eq!(r[1].style.style_name.as_deref(), Some("Emphasis")); + assert_eq!(r[0].style.style_name, None); + // A local override on the styled run survives a redefinition; other attributes update. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 11, "size": 30.0})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Emphasis", "attrs": {"size": 18.0, "underline": false, "tracking": 50.0}})).unwrap(); + let r = runs(&s, id); + assert_eq!((r[1].style.size, r[1].style.underline, r[1].style.tracking), (30.0, false, 50.0)); + // Clear Overrides: back to exactly the style's attributes. + s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11, "clearOverrides": true})).unwrap(); + assert_eq!(runs(&s, id)[1].style.size, 18.0); + let list = s.execute("charStyle.list", &json!({})).unwrap(); + assert_eq!(list["styles"][0]["name"], NORMAL_CHAR); + assert_eq!(list["styles"][1]["uses"], 1); +} + +/// Applying and redefining a style record a step named from a template with the style's name +/// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are +/// marked as names VectorCraft gives. A new style's step has a name of its own. +#[test] +fn style_steps_are_named_from_a_template() { + let (mut s, id) = session_with_text("Hello"); + let last = |s: &Session| { + let e = s.doc().unwrap().history.undo.last().unwrap(); + (e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in))) + }; + let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in)); + s.execute("charStyle.new", &json!({"name": "Body"})).unwrap(); + assert_eq!(last(&s), ("New Character Style".into(), None)); + s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); + assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); + s.execute("edit.undo", &json!({})).unwrap(); + let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned(); + assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body")); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); + s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap(); + assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true))); + s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap(); + assert_eq!(last(&s), ("New Paragraph Style".into(), None)); + s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap(); + assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false))); + // A step renamed since it was recorded is no longer named from the template. + s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into(); + assert_eq!(last(&s), ("Typing".into(), None)); +} + +#[test] +fn new_from_selection_rename_delete_and_undo() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"size": 40})).unwrap(); + let name = s.execute("charStyle.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(name, "Character Style 1"); + assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs["size"], 40.0); + s.execute("charStyle.apply", &json!({"name": name})).unwrap(); + s.execute("charStyle.rename", &json!({"name": name, "to": "Heading"})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert!(s.execute("charStyle.rename", &json!({"name": NORMAL_CHAR, "to": "x"})).is_err()); + s.execute("charStyle.duplicate", &json!({"name": "Heading"})).unwrap(); + s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name, None); + assert_eq!(runs(&s, id)[0].style.size, 40.0, "deleting keeps the look"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"nope": 1}})).is_err()); + // Attributes are stored canonically (a hand-written colour matches the text's afterwards). + s.execute( + "charStyle.new", + &json!({"name": "Pink", "attrs": {"fill": {"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.4}}}}), + ) + .unwrap(); + s.execute("charStyle.apply", &json!({"name": "Pink"})).unwrap(); + let fill = serde_json::to_value(&runs(&s, id)[0].style.fill).unwrap(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); +} + +/// New Character Style and New Paragraph Style trim the name they are given, as a rename +/// trims `to`; the trimmed name is the one compared with the styles there are. +#[test] +fn new_styles_trim_their_names_as_a_rename_does() { + let (mut s, _) = session_with_text("Body text"); + for p in ["charStyle", "paraStyle"] { + assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}"); + let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); + let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect(); + assert_eq!(names[1..], ["Body"], "{p}"); + let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string(); + assert!(e.contains("a style named `Body` already exists"), "{p}: {e}"); + let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string(); + assert!(e.contains("give the style a name"), "{p}: {e}"); + assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}"); + } + // A style whose name has spaces around it, as a saved file can hold, is copied under the + // trimmed name, numbered past the copies there are. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() }); + s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap(); + assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); +} + +/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no +/// mojikumi) are still attributes a style can set, and a style captured with one applies to +/// text at the default, is duplicated and comes back out of a library. +#[test] +fn styles_take_attributes_left_out_at_their_defaults() { + let (mut s, id) = session_with_text("A"); + s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); + s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); + for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { + assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); + } + // Captured from text with explicit leading, applied to fresh Auto-leading text. + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); + let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, None); + for clear in [false, true] { + s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); + } + let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); + s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); + let st = runs(&s, fresh)[0].style.clone(); + assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); + s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; + assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); + // A library keeps the captured attributes and makes the style again where it's used. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); + assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); +} + +#[test] +fn auto_leading_redefine_keeps_overrides_and_history() { + for redefine in [true, false] { + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"leading": 30}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [local], "leading": 42})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [source], "leading": "auto"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + if redefine { + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + } else { + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": null}})).unwrap(); + } + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); + assert!(ids.iter().all(|&id| runs(&s, id)[0].style.style_name.as_deref() == Some("Body"))); + let after = s.doc().unwrap().doc.clone(); + assert_eq!(after.char_styles[0].attrs.get("leading"), Some(&Value::Null), "Auto is explicit, not an unset attribute"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + let back = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap(); + assert_eq!(back.char_styles, after.char_styles); + for id in ids { + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("not text") }; + assert_eq!(t.runs, runs(&s, id)); + } + // Auto users follow the next numeric definition; real numeric and Auto overrides stay. + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 26}})).unwrap(); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(26.0), Some(26.0), Some(42.0)]); + s.execute("text.setStyle", &json!({"ids": [linked], "leading": "auto"})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 28}})).unwrap(); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); + // Omitting leading replaces the definition without resetting the text's leading. + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"tracking": 20}})).unwrap(); + assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); + } +} + +/// The attribute names a style of type `T` can set, without the style name. +fn settable() -> std::collections::BTreeSet { + attr_names::().into_iter().filter(|&n| n != "style_name").map(String::from).collect() +} + +/// New and Redefine capture every paragraph attribute. A paragraph style redefined from a +/// paragraph at the defaults gives its paragraphs the defaults back where they had the old +/// definition's values, and `setAttrs` with a default value does too; local overrides, and +/// attributes `setAttrs` leaves out, stay. +#[test] +fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { + use vectorcraft_doc::{Composer, HyphenationOptions}; + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para_at(0).clone(), + _ => panic!("not text"), + }; + let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit); + let composers = |s: &Session| ids.map(|id| para(s, id).composer); + let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::>(); + s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap(); + s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); + assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default()); + assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body"))); + assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); + assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + // Style options: a default value reaches the users too; an attribute left out stays as it is. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3])); + assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); +} + +/// The same for character styles: Auto kerning and the Roman baseline alignment, which the +/// run's JSON leaves out, are captured and reach the style's users. +#[test] +fn char_style_redefined_to_the_defaults_updates_its_text() { + use vectorcraft_doc::CharAlign; + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning); + let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align); + let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::>(); + s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); + assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); +} + +/// A style captured from type without Variable Font settings sets none explicitly (`{}`): +/// applying it clears type's settings, and redefining it from type whose settings were reset +/// resets the type that uses it. +#[test] +fn variable_font_settings_are_captured_explicitly() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let (mut s, source) = session_with_text("Source"); + let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap(); + let ids = [source, linked]; + s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({}))); + s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied()); + assert_eq!(wght(&s), [None, None], "applying the style clears the settings"); + for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] { + s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap(); + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!(wght(&s), [want, want], "redefined from {set}"); + } +} + +/// A style that changes the font, applied or redefined, leaves none of the old font's +/// version and Variable Font settings, unless the style sets them. +#[test] +fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let (mut s, id) = session_with_text("Source"); + let set_variable = |s: &mut Session| { + s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap(); + s.edit("version", |d, _| { + if let Some(t) = text_mut(d, NodeId(id)) { + t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into())); + } + Ok(()) + }) + .unwrap(); + }; + let font = |s: &Session| { + let st = runs(s, id)[0].style.clone(); + (st.font_family, st.font_version, st.font_axes.get("wght").copied()) + }; + set_variable(&mut s); + s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap(); + assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them"); + s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!(font(&s), ("Inter".to_string(), None, None)); + // A style that sets the settings keeps its own. + s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap(); + assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0))); + // Redefined to another font: its users' settings and versions go too. + s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap(); + set_variable(&mut s); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans")); + s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap(); + assert_eq!(font(&s), ("Inter".to_string(), None, None)); +} + +#[test] +fn auto_leading_new_distinguishes_unset_attributes() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Sparse", "attrs": {"tracking": 20}})).unwrap(); + assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); + s.execute("charStyle.apply", &json!({"name": "Sparse", "id": id})).unwrap(); + assert_eq!(runs(&s, id)[0].style.leading, Some(30.0)); + s.execute("charStyle.new", &json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Automatic", "id": id})).unwrap(); + assert_eq!(runs(&s, id)[0].style.leading, None); + assert_eq!(s.doc().unwrap().doc.char_styles[1].attrs.get("leading"), Some(&Value::Null)); + // New from Auto text captures that value too, rather than leaving leading unspecified. + s.execute("charStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id], "leading": 36})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Captured", "id": id})).unwrap(); + assert_eq!(runs(&s, id)[0].style.leading, None); + assert_eq!(s.doc().unwrap().doc.char_styles[2].attrs.get("leading"), Some(&Value::Null)); +} + +#[test] +fn paragraph_styles_and_survive_save() { + let (mut s, id) = session_with_text("Body"); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6.0}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Centered"})).unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!(), + }; + assert_eq!((para(&s).justify, para(&s).space_after), (vectorcraft_doc::Justify::Center, 6.0)); + // The Normal style can be redefined too (stored once redefined). + s.execute("paraStyle.redefine", &json!({"name": NORMAL_PARA})).unwrap(); + assert_eq!(s.execute("paraStyle.list", &json!({})).unwrap()["styles"][0]["attrs"]["justify"], "Center"); + let d = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert_eq!(back.para_styles, d.para_styles); +} + +fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option, expected_size: f64) { + let (mut s, id) = session_with_text(text); + s.execute("text.setStyle", &json!({"id": id, "size": 18})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": text.len(), "size": 34})).unwrap(); + if redefine { + s.execute("charStyle.new", &json!({"name": "Captured", "attrs": {"size": 24}})).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(); + let mut params = json!({"name": "Captured", "id": id, "start": start}); + if let Some(end) = end { + params["end"] = json!(end); + } + let command = if redefine { "charStyle.redefine" } else { "charStyle.new" }; + s.execute(command, ¶ms).unwrap(); + let st = s.doc().unwrap(); + let def = st.doc.char_styles.iter().find(|d| d.name == "Captured").unwrap(); + assert_eq!(def.attrs.get("size").and_then(Value::as_f64), Some(expected_size), "{command} {text:?} {start}..{end:?}"); + assert!(!def.attrs.contains_key("style_name")); + assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id)), "capture does not apply the definition"); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + assert!(st.is_dirty()); + let after = st.doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().selection, selection); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.doc().unwrap().selection, selection); +} + +fn capture_boundary_ranges(redefine: bool) { + for (text, start, end, size) in [ + ("ABcdef", 2, 5, 34.0), + ("é漢字", 2, 5, 34.0), + ("ABcdef", 5, 2, 34.0), // command ranges are ordered before selected-character sampling + ("ABcdef", 1, 5, 18.0), // a mixed range samples its first selected character + ("ABcdef", 2, 2, 18.0), // collapsed range retains insertion affinity + ("ABcdef", 3, 3, 34.0), + ("ABcdef", 99, 100, 34.0), // beyond the text retains the final run + ] { + capture_boundary_command(redefine, text, start, Some(end), size); + } +} + +#[test] +fn char_style_new_range_capture_boundary() { + capture_boundary_ranges(false); +} + +#[test] +fn char_style_redefine_range_capture_boundary() { + capture_boundary_ranges(true); +} + +#[test] +fn char_style_start_only_capture_affinity_controls() { + for redefine in [false, true] { + for text in ["ABcdef", "é漢字"] { + for (start, size) in [(0, 18.0), (2, 18.0), (3, 34.0), (99, 34.0)] { + capture_boundary_command(redefine, text, start, None, size); + } + } + } +} + +#[test] +fn para_style_range_capture_samples_start_paragraph() { + for redefine in [false, true] { + for (start, end, justify) in [(0, 7, "Left"), (4, 7, "Right"), (4, 0, "Right")] { + let (mut s, id) = session_with_text("One\nTwo"); + s.execute("text.setStyle", &json!({"id": id, "justify": "left"})).unwrap(); + s.execute("text.setStyle", &json!({"id": id, "start": 4, "end": 7, "justify": "right"})).unwrap(); + if redefine { + s.execute("paraStyle.new", &json!({"name": "Captured", "attrs": {"justify": "Center"}})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let command = if redefine { "paraStyle.redefine" } else { "paraStyle.new" }; + s.execute(command, &json!({"name": "Captured", "id": id, "start": start, "end": end})).unwrap(); + let st = s.doc().unwrap(); + let def = st.doc.para_styles.iter().find(|d| d.name == "Captured").unwrap(); + assert_eq!(def.attrs["justify"], json!(justify), "paragraph capture still samples start, not the end or a normalized range"); + assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id))); + assert_eq!(st.selection, selection); + assert_eq!(st.history.undo.len(), undo + 1); + let after = st.doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + } + } +} diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 420856962..4382ae0b2 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -708,1272 +708,4 @@ fn break_thread(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. "; - - fn setup() -> (Session, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - (s, a, b) - } - - fn text(s: &Session, id: u64) -> String { - match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("not text"), - } - } - - #[test] - fn thread_flows_reflows_on_edit_and_survives_delete() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - let (ta, tb) = (text(&s, a), text(&s, b)); - assert!(!ta.is_empty() && !tb.is_empty()); - assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4)); - // Editing the first frame re-flows into the second. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); - assert_ne!(text(&s, b), tb, "the overflow moved on"); - // Deleting the first frame moves all its text into the second. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("edit.clear", &json!({})).unwrap(); - assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4))); - assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1); - } - - #[test] - fn edits_copy_only_the_stories_of_threads_whose_frames_changed() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - let c = s.execute("text.create", &json!({"x": 10, "y": 200, "text": TEXT.repeat(2), "area": {"width": 150, "height": 40}})).unwrap()["id"] - .as_u64() - .unwrap(); - let d = s.execute("shape.rectangle", &json!({"x": 200, "y": 220, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [c, d]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - let texts = |s: &Session| [a, b, c, d].map(|id| text(s, id)); - let copied = || COPIED.with(std::cell::Cell::get); - let (shown, start) = (texts(&s), copied()); - // A rectangle drawn, moved and dragged beside the threads changes none of their frames. - let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); - s.begin_interaction("Move").unwrap(); - for dx in 1..=3 { - s.preview("object.move", &json!({"dx": dx, "dy": 0})).unwrap(); - } - s.commit_interaction().unwrap(); - assert_eq!(copied(), start, "edits beside the threads copied their stories"); - assert_eq!(texts(&s), shown); - // Typing in the first thread copies and re-flows that thread's story only. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); - assert_eq!(copied(), start + 1); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); - assert_eq!(texts(&s)[2..], shown[2..]); - let ids = |t: [u64; 2]| t.map(NodeId).to_vec(); - assert_eq!(s.doc().unwrap().doc.text_threads, [ids([a, b]), ids([c, d])]); - } - - /// Time edits of a story of 50,000 characters threaded through 8 frames, each frame wrapping - /// around an ellipse, with one style, a style change every 10 words and a style change at every - /// word: 21 characters typed into the first frame, then 200 moves of a rectangle below the - /// frames and 200 frames of a drag of it. - /// Run with `cargo test --release -p vectorcraft-engine --lib threaded_story_edit_timing -- --ignored --nocapture`. - #[test] - #[ignore = "manual timing probe; wall-clock performance depends on machine load"] - fn threaded_story_edit_timing() { - fn median(s: &mut Session, n: u32, f: &dyn Fn(&mut Session, f64)) -> f64 { - let mut ms: Vec = (1..=n) - .map(|i| { - let start = std::time::Instant::now(); - f(s, f64::from(i)); - start.elapsed().as_secs_f64() * 1000.0 - }) - .collect(); - ms.sort_by(f64::total_cmp); - ms[ms.len() / 2] - } - let story = format!("{}\n", TEXT.repeat(6).trim_end()).repeat(81)[..50_000].to_string(); - for words in [0, 10, 1] { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 4400, "height": 1200})).unwrap(); - let area = json!({"width": 500, "height": 900}); - let first = s.execute("text.create", &json!({"x": 20, "y": 20, "text": story, "size": 9, "area": area})).unwrap()["id"].as_u64().unwrap(); - if words > 0 { - s.edit("Restyle", |d, _| { - let Some(NodeKind::Text(t)) = d.node_mut(NodeId(first)).map(|n| &mut n.kind) else { panic!("not text") }; - let style = t.runs[0].style.clone(); - let pieces: Vec<&str> = story.split_inclusive([' ', '\n']).collect(); - t.runs = pieces - .chunks(words) - .enumerate() - .map(|(i, c)| { - let mut run = TextRun::new(c.concat(), style.clone()); - run.style.underline = i % 2 == 1; - run - }) - .collect(); - refresh_bounds(t); - Ok(()) - }) - .unwrap(); - } - let mut ids = vec![first]; - for k in 1..8 { - let frame = json!({"x": 20 + k * 540, "y": 20, "width": 500, "height": 900}); - ids.push(s.execute("shape.rectangle", &frame).unwrap()["id"].as_u64().unwrap()); - } - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - for k in 0..8 { - let e = s.execute("shape.ellipse", &json!({"x": 195 + k * 540, "y": 395, "width": 150, "height": 150})).unwrap()["id"].clone(); - s.execute("object.textWrap.make", &json!({"ids": [e]})).unwrap(); - } - let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 1100, "width": 30, "height": 30})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - let typing = median(&mut s, 21, &|s, _| { - s.execute("text.editRange", &json!({"id": first, "start": 0, "end": 0, "insert": "x"})).unwrap(); - }); - let edit = median(&mut s, 200, &|s, _| { - s.execute("object.move", &json!({"dx": 0.5, "dy": 0})).unwrap(); - }); - s.begin_interaction("Move").unwrap(); - let frame = median(&mut s, 200, &|s, i| { - s.preview("object.move", &json!({"dx": i * 0.25, "dy": 0})).unwrap(); - }); - s.commit_interaction().unwrap(); - let d = &s.doc().unwrap().doc; - let runs: usize = ids.iter().filter_map(|id| text_of(d, NodeId(*id))).map(|t| t.runs.len()).sum(); - println!("{runs} runs in 8 frames: typing {typing:.1} ms, a move below them {edit:.3} ms, a drag frame {frame:.3} ms (medians)"); - } - } - - fn new_id(v: Value) -> u64 { - v["id"].as_u64().unwrap() - } - - fn threads(s: &Session) -> Vec> { - s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect() - } - - /// Bounds of area type `id`'s frame on the page. - fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; - let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; - t.xf.transform_rect_bbox(frame.bounds().unwrap()) - } - - /// #2572: the text loaded from an out port threads into a closed path, into a new frame of the - /// first one's shape and size at a point, and into a new rectangle, each in one undo step. - #[test] - fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() { - let (mut s, a, b) = setup(); - assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b); - assert_eq!(threads(&s), [[a, b]]); - assert!(!text(&s, b).is_empty(), "the overflow flowed into the path"); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); - assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back"); - - let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap()); - assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click"); - let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap()); - assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0)); - assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from"); - assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4)); - assert!(!text(&s, d).is_empty()); - // A frame threaded from the middle of a thread goes in after it. - let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap()); - assert_eq!(threads(&s), [[a, e, c, d]]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none()); - assert_eq!(threads(&s), [[a, c, d]]); - } - - /// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes - /// before the frame it was loaded from, so the story starts there and flows on into it; in the - /// middle of a thread the new frame goes in between, each in one undo step. - #[test] - fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() { - let (mut s, a, b) = setup(); - let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap()); - assert_eq!(threads(&s), [[c, a]]); - assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click"); - assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c)); - assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4)); - assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]); - // In between the frame and the one before it. - let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap()); - assert_eq!(threads(&s), [[c, d, a]]); - assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [[c, a]]); - // A closed path clicked with the in port's text comes first, with the start of the story. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b); - assert_eq!(threads(&s), [[b, a]]); - assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty()); - assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4)); - // `before: false` is the out port's. - let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap()); - assert_eq!(threads(&s), [[b, a, e]]); - } - - /// Type on a path along a line beside the area type of [`setup`]. - fn type_on_a_line(s: &mut Session) -> u64 { - let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap()); - new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap()) - } - - /// Type on a path threads as area type does, in any mix: a story flows from area type along a - /// path and on into area type, and a new frame threaded from type on a path at a point is type - /// on a path of the same shape. - #[test] - fn type_on_a_path_threads_with_area_type() { - let (mut s, a, b) = setup(); - let on_path = type_on_a_line(&mut s); - s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); - s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); - assert_eq!(threads(&s), [[a, on_path, b]]); - let shares = [a, on_path, b].map(|id| text(&s, id)); - assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}"); - assert_eq!(shares.concat(), TEXT.repeat(4)); - - let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap()); - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") }; - assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from"); - assert_eq!(threads(&s), [[a, on_path, c, b]]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [[a, on_path, b]]); - } - - /// Type on a path released from its thread gives its text back to the frame before it. - #[test] - fn type_on_a_path_is_released_from_its_thread() { - let (mut s, a, _) = setup(); - let on_path = type_on_a_line(&mut s); - s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); - assert!(!text(&s, on_path).is_empty()); - s.execute("select.set", &json!({"ids": [on_path]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert!(text(&s, on_path).is_empty()); - assert_eq!(text(&s, a), TEXT.repeat(4)); - } - - /// Area type threaded into from an out port takes the overflow at once, before its own text. - #[test] - fn add_into_area_type_flows_the_story_into_it() { - let (mut s, a, _) = setup(); - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - assert_eq!(threads(&s), [[a, c]]); - assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4))); - assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c)); - } - - #[test] - fn add_refuses_bad_params_and_leaves_the_document_alone() { - let (mut s, a, b) = setup(); - let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap()); - let before = s.doc().unwrap().doc.clone(); - for p in [ - json!({"to": b}), - json!({"from": b, "at": [0, 0]}), - json!({"from": 9999, "at": [0, 0]}), - json!({"from": -1, "at": [0, 0]}), - json!({"from": a}), - json!({"from": a, "to": b, "at": [0, 0]}), - json!({"from": a, "to": a}), - json!({"from": a, "to": line}), - json!({"from": a, "to": 9999}), - json!({"from": a, "at": [0]}), - json!({"from": a, "at": ["1", 2]}), - json!({"from": a, "rect": [0, 0, -10, 10]}), - json!({"from": a, "rect": [0, 0, 10, 0]}), - json!({"from": a, "rect": [0, 0, 10]}), - json!({"from": a, "rect": "big"}), - json!({"from": a, "at": [0, 0], "before": "yes"}), - json!({"from": a, "at": [0, 0], "before": 1}), - ] { - assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); - assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); - } - s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap()); - assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded"); - } - - /// A frame released from a thread that keeps two or more frames gives its text back to them: - /// the story flows through them once. - #[test] - fn release_from_a_longer_thread_keeps_the_story_once() { - let (mut s, a, b) = setup(); - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); - assert_eq!(threads(&s), [[a, c, b]]); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert_eq!(threads(&s), [[a, b]]); - assert!(text(&s, c).is_empty()); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); - } - - /// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`], - /// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`. - fn thread_of(n: usize) -> (Session, Vec) { - let (mut s, a, b) = setup(); - let frames = if n == 3 { - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); - vec![a, c, b] - } else { - s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); - vec![a, b] - }; - assert_eq!(threads(&s), std::slice::from_ref(&frames)); - assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); - (s, frames) - } - - /// Convert To Point Type on a frame of threaded text takes it out of its thread with the text - /// it holds, and the other frames keep the text they hold: the story holds each character once. - /// A thread left with one frame is no thread. Undo threads the frame again. - #[test] - fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() { - // Soft wraps of the point type become line breaks, so its characters are compared without - // white space. - let squeeze = |t: &str| t.split_whitespace().collect::(); - for n in [2, 3] { - for k in 0..n { - let (mut s, frames) = thread_of(n); - let shown: Vec = frames.iter().map(|id| text(&s, *id)).collect(); - let id = frames[k]; - s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") }; - assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type"); - assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text"); - for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) { - assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text"); - } - let rest: Vec = frames.iter().copied().filter(|f| *f != id).collect(); - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}"); - let all: String = frames.iter().map(|f| text(&s, *f)).collect(); - assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), std::slice::from_ref(&frames)); - assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::>(), shown, "{n} frames, frame {k}: undo"); - } - } - } - - #[test] - fn release_and_remove() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - let tb = text(&s, b); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert_eq!(text(&s, b), tb, "remove keeps what each frame shows"); - assert!(s.doc().unwrap().doc.text_threads.is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert_eq!(text(&s, b), ""); - assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame"); - } - - /// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each - /// showing part of it. - fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| { - new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap()) - }; - let a = frame(&mut s, 10.0, 10.0, 60.0, story); - let b = frame(&mut s, 10.0, 100.0, 40.0, ""); - let c = frame(&mut s, 200.0, 10.0, last, ""); - s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); - s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap(); - assert_eq!(threads(&s), [[a, b, c]]); - assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); - (s, [a, b, c]) - } - - fn continues(s: &Session, id: u64) -> bool { - text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues) - } - - /// A double-click on a threaded port breaks the thread there: the story flows into the frames - /// before the break and the frames after it are left empty, still threaded with each other - /// when two or more. One undo step restores the thread and its text. - #[test] - fn break_splits_the_thread_and_the_story_flows_into_the_first_part() { - let story = TEXT.repeat(12); - let (mut s, [a, b, c]) = three_frames(&story, 40.0); - let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap()); - s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); - let all = [a, b, c, d]; - let shown: Vec = all.iter().map(|id| text(&s, *id)).collect(); - assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story"); - let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::(); - // (frame, port, threads after, the frames that hold the story, the frames left empty) - let cases = [ - (a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]), - (b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]), - (b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]), - (d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]), - (c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]), - ]; - for (id, port, after, full, empty) in cases { - let case = format!("{port} port of {id}"); - s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap(); - assert_eq!(threads(&s), after, "{case}"); - assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part"); - assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty"); - assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph"); - assert!(!continues(&s, a), "{case}: the first frame starts the story"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [all.to_vec()], "{case}: undo"); - assert_eq!(all.iter().map(|id| text(&s, *id)).collect::>(), shown, "{case}: undo"); - } - } - - #[test] - fn break_refuses_a_port_with_no_thread_and_bad_params() { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); - let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap()); - let ids = [a, b, c, lone]; - let shown: Vec = ids.iter().map(|id| text(&s, *id)).collect(); - let before = threads(&s); - for params in [ - json!({"id": a, "port": "in"}), - json!({"id": c, "port": "out"}), - json!({"id": lone, "port": "out"}), - json!({"id": lone, "port": "in"}), - json!({"id": a, "port": "up"}), - json!({"id": a}), - json!({"port": "out"}), - json!({"id": 9999, "port": "out"}), - ] { - assert!(s.execute("text.thread.break", ¶ms).is_err(), "{params}"); - } - assert_eq!(threads(&s), before); - assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::>(), shown); - } - - /// Whether the document holds area type or type on a path `id`. - fn is_frame(s: &Session, id: u64) -> bool { - text_of(&s.doc().unwrap().doc, NodeId(id)).is_some() - } - - /// Create Outlines and Object › Expand on frames of threaded text take them out of their - /// thread with the text they show, which their outlines draw, and the other frames keep the - /// text they hold: each character is in the document once. A thread left with one frame is no - /// thread. Undo threads the frames again. - #[test] - fn outlined_frames_leave_their_thread_with_their_text() { - for command in ["type.createOutlines", "object.expand"] { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute(command, &json!({})).unwrap(); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined"); - } - let st = s.doc().unwrap(); - let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. }))); - assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected"); - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{command} {out:?}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo"); - } - } - } - - /// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the - /// text it shows; Object › Expand of such a frame expands its appearance first. - #[test] - fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() { - for command in ["effect.expandAppearance", "object.expand"] { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); - s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap(); - let shown = [a, b, c].map(|id| text(&s, id)); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute(command, &json!({})).unwrap(); - assert!(!is_frame(&s, b), "{command}: the frame is outlined"); - assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text"); - assert_eq!(threads(&s), [[a, c]], "{command}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [[a, b, c]], "{command}: undo"); - assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo"); - } - } - - /// Object › Flatten Transparency outlines frames of threaded text with Convert All Text to - /// Outlines, and turns transparent frames into flat regions or an image: each such frame leaves - /// its thread with the text it shows, and the other frames keep the text they hold. Undo threads - /// the frames again. When the last frame is flattened, the frame before it takes the text it has - /// no room for. - #[test] - fn flattened_frames_leave_their_thread_with_their_text() { - let cases = [(json!({"textToOutlines": true}), false), (json!({}), true), (json!({"balance": 0}), true)]; - for (options, transparent) in cases { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let case = format!("{options} {out:?}"); - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - if transparent { - s.execute("transparency.set", &json!({"ids": ids, "opacity": 50})).unwrap(); - } - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let mut p = options.clone(); - p["ids"] = json!(ids); - s.execute("object.flattenTransparency", &p).unwrap(); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{case}: frame {id} is flattened"); - } - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - } - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("object.flattenTransparency", &json!({"ids": [c], "textToOutlines": true})).unwrap(); - assert_eq!(text(&s, a), shown[0]); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]]); - } - - /// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new - /// id, in a group under the frame's id: the type takes the frame's place in its thread, and - /// each frame keeps its text, the last frame the text it has no room for too. Type in a group - /// with Crop Marks keeps its id and stays in its thread. - #[test] - fn type_that_expand_appearance_keeps_takes_its_frames_place() { - for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] { - for k in [1, 2] { - let case = format!("{effect} grouped {grouped} frame {k}"); - let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); - let id = frames[k]; - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id }; - s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap(); - let shown = frames.map(|id| text(&s, id)); - s.execute("select.set", &json!({"ids": [target]})).unwrap(); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let mut types = vec![]; - if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) { - n.walk(&mut |m| { - if matches!(m.kind, NodeKind::Text(_)) { - types.push(m.id.0); - } - }); - } - let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") }; - assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group"); - let mut now = frames; - now[k] = kept; - assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place"); - assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - } - } - - /// The text that does not fit a thread's last frame, which its outlines do not draw, stays in - /// the story: when the last frame is outlined, the frame before it takes that text after its - /// own. - #[test] - fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - assert_eq!(text(&s, a), shown[0]); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!([a, b, c].map(|id| text(&s, id)), shown); - } - - /// Object › Rasterize on frames of threaded text takes them out of their thread with the text - /// they show, which the image draws, and the other frames keep the text they hold. Undo threads - /// the frames again. When the last frame is rasterized, the frame before it takes the text it - /// has no room for. - #[test] - fn rasterized_frames_leave_their_thread_with_their_text() { - for clip in [false, true] { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let case = format!("clippingMask {clip} {out:?}"); - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap(); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized"); - } - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - } - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("object.rasterize", &json!({"ppi": 36})).unwrap(); - assert_eq!(text(&s, a), shown[0]); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]]); - } - - /// Frame `id`'s first line: whether its first paragraph continues from the frame before, where - /// its text starts from the frame's left edge, and how many list markers it starts with. - fn first_line(s: &Session, id: u64) -> (bool, f64, usize) { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; - let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let l = &lay.lines[0]; - let x = lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x); - let markers = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); - (t.first_para_continues, (x - frame.bounds().unwrap().x0 - 1e-9).round(), markers) - } - - /// A paragraph's first-line indent and bullet belong to its first line, in the frame it starts - /// in: the frame it continues into starts at its left indent, without the bullet. Re-flowing, - /// saving and opening keep it. In a frame that is no longer threaded and in a copy of a frame, - /// the first paragraph has its first-line indent and bullet. - #[test] - fn a_paragraph_continued_into_the_next_frame_starts_it_without_its_first_line_indent_or_bullet() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap(); - assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 18.0, 1), (true, 18.0, 0))); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "none", "leftIndent": 0, "firstLineIndent": 24})).unwrap(); - assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0))); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); - // Typing re-flows the story and keeps it. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); - assert_eq!(first_line(&s, b), (true, 0.0, 0)); - // Saved and opened: a native file re-flows its threads, a restored document keeps its text. - for name in ["thread.vectorcraft", "thread.eps"] { - let bytes = if name.ends_with(".eps") { - let r = s.execute("document.exportEps", &json!({})).unwrap(); - vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() - } else { - vectorcraft_format::save(&s.doc().unwrap().doc, false) - }; - let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); - assert_eq!(threads(&s), [[a, b]], "{name}"); - assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)), "{name}"); - } - // A copy of the second frame is in no thread, and starts with the indent. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!((first_line(&s, b), first_line(&s, copy)), ((true, 0.0, 0), (false, 24.0, 0))); - // …and so is a pasted one, and each copy's cached bounds and baselines are its own layout's. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let pasted = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); - let dragged = s.execute("layer.move", &json!({"ids": [b], "target": a, "place": "above", "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!((first_line(&s, pasted), first_line(&s, dragged)), ((false, 24.0, 0), (false, 24.0, 0))); - // Every text object in the document has the cached bounds and baselines of its own layout. - let own_caches = |s: &Session, what: &str| { - let mut texts = vec![]; - s.doc().unwrap().doc.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - texts.push((n.id, t)); - } - }); - for (id, t) in texts { - let mut fresh = (**t).clone(); - refresh_bounds(&mut fresh); - assert!(t.cached_bounds.is_some() && t.cached_bounds == fresh.cached_bounds, "{what}: {id:?}"); - assert_eq!(t.cached_baselines, fresh.cached_baselines, "{what}: {id:?}"); - } - }; - own_caches(&s, "copies"); - // Release to Layers (Build), Release Opacity Mask, Edit Opacity Mask and Draw Inside copy the - // frame too. - let layer = s.doc().unwrap().doc.layer_containing(NodeId(b)).unwrap().0; - s.execute("layer.releaseToLayers", &json!({"id": layer, "build": true})).unwrap(); - own_caches(&s, "Release to Layers (Build)"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("transparency.makeOpacityMask", &json!({"ids": [a, b]})).unwrap(); - s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); - own_caches(&s, "Release Opacity Mask"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("transparency.editOpacityMask", &json!({"id": a})).unwrap(); - own_caches(&s, "Edit Opacity Mask"); - s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); - s.execute("shape.rectangle", &json!({"x": 210, "y": 20, "width": 20, "height": 20})).unwrap(); - own_caches(&s, "Draw Inside"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); - assert_eq!((threads(&s), first_line(&s, b)), (vec![vec![a, b]], (true, 0.0, 0))); - // Remove Threading: each frame keeps its text, and its first paragraph starts in it. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert_eq!(first_line(&s, b), (false, 24.0, 0)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(first_line(&s, b), (true, 0.0, 0)); - // Released, the frame is empty and starts the next text typed into it as a paragraph. - let continues = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.first_para_continues, - _ => panic!("not text"), - }; - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(text(&s, b).is_empty() && !continues(&s, b)); - s.execute("edit.undo", &json!({})).unwrap(); - // With the first frame released, the second one holds the whole story and starts it. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty() && text(&s, a).is_empty()); - assert_eq!(first_line(&s, b), (false, 24.0, 0)); - s.execute("edit.undo", &json!({})).unwrap(); - // Converted to point type, it is in no thread, and its first line starts at the indent where - // the frame's left edge was. - let left = frame_box(&s, b).x0; - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("type.convertToPointType", &json!({})).unwrap(); - assert!(!continues(&s, b)); - { - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind else { panic!("not text") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let g = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); - let x = (t.xf * g.origin).x - left; - assert!((x - 24.0).abs() < 1e-6, "{x}"); - } - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(first_line(&s, b), (true, 0.0, 0)); - // Text that ends the first frame with a paragraph break: the second frame starts a - // paragraph, with its indent. - let all = text(&s, a).len(); - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": all, "insert": "Short line.\n".repeat(12)})).unwrap(); - assert!(text(&s, a).ends_with('\n'), "{:?}", text(&s, a)); - assert_eq!(first_line(&s, b), (false, 24.0, 0)); - } - - /// When Create Outlines takes frames out of a thread, a frame that now starts its thread or is - /// in no thread starts its paragraph with the first-line indent; a frame after one that ends - /// inside the paragraph still continues it. The frames that stay hold each character they held - /// once, and keep their text when no mark changes. Undo marks the frames again. - #[test] - fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() { - for (out, want) in [ - (&[0][..], [None, Some(false), Some(true)]), - (&[0, 2], [None, Some(false), None]), - (&[1], [Some(false), None, Some(true)]), - (&[2], [Some(false), Some(true), None]), - ] { - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - s.execute("text.setFormat", &json!({"ids": frames, "leftIndent": 0, "firstLineIndent": 24})).unwrap(); - let before = frames.map(|id| first_line(&s, id)); - assert_eq!(before, [(false, 24.0, 0), (true, 0.0, 0), (true, 0.0, 0)], "{out:?}"); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4), "{out:?}: the last frame has room for the rest"); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - let stay: Vec = (0..3).filter(|k| want[*k].is_some()).collect(); - for k in &stay { - let on = want[*k] == Some(true); - let indent = if on { 0.0 } else { 24.0 }; - assert_eq!(first_line(&s, frames[*k]), (on, indent, 0), "{out:?}: frame {k}"); - } - let held: String = stay.iter().map(|k| shown[*k].as_str()).collect(); - assert_eq!(stay.iter().map(|k| text(&s, frames[*k])).collect::(), held, "{out:?}: each character once"); - if stay.iter().all(|k| want[*k] == Some(before[*k].0)) { - for k in &stay { - assert_eq!(text(&s, frames[*k]), shown[*k], "{out:?}: frame {k} keeps its text"); - } - } - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(frames.map(|id| first_line(&s, id)), before, "{out:?}: undo"); - } - } - - /// The text that a thread's last frame has no room for is measured as the frame lays it out, - /// without the first-line indent of the paragraph it continues: when the frame is outlined, the - /// frame before it takes only that text. - #[test] - fn outlining_a_continued_last_frame_keeps_only_the_text_it_has_no_room_for() { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - s.execute("text.setFormat", &json!({"ids": [a, b, c], "leftIndent": 0, "firstLineIndent": 72})).unwrap(); - assert_eq!(first_line(&s, c), (true, 0.0, 0)); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1])); - } - - /// The text that a thread's last frame has no room for is measured with the frame's numbered - /// paragraphs counted on from the frames before it: when the frame is outlined, the frame before - /// it takes only that text. - #[test] - fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); - let story = format!("{items}{}", TEXT.repeat(4)); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); - let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - // A hanging indent that "I." fits and "VIII." does not: the text after "VIII." starts at - // the next tab stop. - s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "leftIndent": 14, "firstLineIndent": -14})).unwrap(); - let shown = [a, c].map(|id| text(&s, id)); - assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; - assert_eq!(t.list_count, 7); - let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_count: 0, ..t }); - assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); - let hidden = shown[1][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); - } - - /// A story of one-line paragraphs that each start with a quotation mark, threaded from area - /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. - fn quoted_thread() -> (Session, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let story = "“Quoted.”\n".repeat(20); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - assert!(text(&s, b).starts_with('“')); - (s, a, b) - } - - /// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame - /// (its lines' opening quotation marks hang). - fn optical(s: &Session, id: u64) -> (bool, bool) { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; - let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; - (t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0) - } - - /// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment - /// in any frame sets it in every frame of the thread, in one undo step, and the lines in every - /// frame hang. Typing keeps it, and frames taken out of the thread keep it. - #[test] - fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() { - let (mut s, a, b) = quoted_thread(); - assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap(); - assert_eq!(r, json!({"on": true, "ids": [a, b]})); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too"); - // Turned off from the second frame, it is off in both, and one undo turns it back on in both. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); - assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - // Typing in the first frame re-flows the story and keeps the setting. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - // Any edit that sets one frame sets the thread. - s.edit("Set", |d, _| { - let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; - t.optical_margin = false; - refresh_bounds(t); - Ok(()) - }) - .unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); - s.execute("edit.undo", &json!({})).unwrap(); - // Released or unthreaded, each frame keeps it. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - } - - /// Threading type into a thread gives the joined thread the setting of its first frame. - #[test] - fn a_thread_takes_its_first_frames_optical_margin_alignment() { - let (mut s, a, b) = quoted_thread(); - s.execute("text.thread.remove", &json!({})).unwrap(); - s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); - // Threaded again in stacking order: the second frame takes the first one's setting. - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); - // From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting. - s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap(); - assert_eq!(threads(&s), [[b, a]]); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false)); - // A new frame threaded from a frame takes the thread's setting. - s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); - let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap()); - assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); - } - - /// A file saved with the frames of one thread set differently opens with the first frame's - /// setting in every frame: a native file, and an EPS file restored from the document it carries. - #[test] - fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { - let (mut s, a, b) = quoted_thread(); - for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { - for (id, on) in [(a, first), (b, !first)] { - let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; - t.optical_margin = on; - } - let bytes = if name.ends_with(".eps") { - let r = s.execute("document.exportEps", &json!({})).unwrap(); - vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() - } else { - vectorcraft_format::save(&s.doc().unwrap().doc, false) - }; - let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); - assert_eq!(threads(&s), [[a, b]], "{name}"); - assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}"); - } - } - - /// One count runs through a thread: the second frame's numbered paragraphs go on from the - /// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a - /// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks - /// and the frame a Release Selection leaves alone count from 1. - #[test] - fn numbered_list_paragraphs_count_on_through_a_thread() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - let numbers = |s: &Session, id: u64| { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() - }; - let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::>(); - let first = numbers(&s, a); - assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); - assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1)); - // Typing a paragraph into the first frame numbers it and moves the rest on. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap(); - assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat()); - s.execute("edit.undo", &json!({})).unwrap(); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - let after: Vec = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect(); - assert_eq!(after, all(first.len() + 1), "reopened"); - // A pasted copy of the second frame is in no thread. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - s.execute("edit.paste", &json!({})).unwrap(); - let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); - assert_ne!(copy, NodeId(b)); - assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1.")); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate"); - let layer = s.doc().unwrap().doc.layers[0].id.0; - let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel"); - assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself"); - // Convert To Point Type takes the second frame out of the thread with its text. - s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); - // Remove Threading: each frame is a story of its own. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1.")); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); - // Release Selection of the first frame: the second one holds the whole story, from 1. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); - } - - /// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art - /// looks as it did; Break Link puts the frames in no thread, and each counts from 1. - #[test] - fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - let count = |t: &TextObject| t.list_count; - let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap(); - assert!(carried > 0); - let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap()); - let counts = |n: &Node| { - let mut counts = vec![]; - n.walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind { - counts.push(t.list_count); - } - }); - counts.sort_unstable(); - counts - }; - let doc = &s.doc().unwrap().doc; - assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]); - // Break Link puts the frames on the artboard in no thread: each counts from 1. - s.execute("select.set", &json!({"ids": [instance]})).unwrap(); - s.execute("symbol.breakLink", &json!({})).unwrap(); - let doc = &s.doc().unwrap().doc; - assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::>(), [0, 0]); - } - - /// The text of each frame in symbol `name`'s art, sorted. - fn symbol_texts(s: &Session, name: &str) -> Vec { - let doc = &s.doc().unwrap().doc; - let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") }; - let mut texts = vec![]; - sym.art.walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind { - texts.push(t.plain_text()); - } - }); - texts.sort(); - texts - } - - /// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance - /// whose art holds the text they show, and the frames leave their thread: the other frames keep - /// the text they hold, so each character is drawn once. Undo threads the frames again. When the - /// last frame goes into the symbol, the frame before it takes the text it has no room for, and - /// the symbol's art holds only the text the frame shows. - #[test] - fn frames_made_into_a_symbol_leave_their_thread_with_their_text() { - let symbol = |s: &mut Session, command: &str, ids: &[u64]| { - let name = if command == "symbol.update" { - let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); - s.execute("select.set", &json!({"ids": [mark]})).unwrap(); - s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); - "Mark" - } else { - "Story" - }; - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute(command, &json!({"name": name})).unwrap(); - name - }; - for command in ["symbol.new", "symbol.update"] { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let case = format!("{command} {out:?}"); - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - let name = symbol(&mut s, command, &ids); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol"); - } - let mut held: Vec = out.iter().map(|k| shown[*k].clone()).collect(); - held.sort(); - assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show"); - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - let name = symbol(&mut s, command, &[c]); - assert_eq!(text(&s, a), shown[0], "{command}"); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]], "{command}"); - let held = symbol_texts(&s, name); - let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect(); - assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows"); - // With every frame of the thread in the symbol, the last frame keeps all its text. - let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); - let mut shown = frames.map(|id| text(&s, id)).to_vec(); - shown.sort(); - let name = symbol(&mut s, command, &frames); - assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread"); - assert!(threads(&s).is_empty(), "{command}: the whole thread"); - } - } - - /// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the - /// mask art too. - #[test] - fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0)); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap()); - let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [obj, m]})).unwrap(); - s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - s.execute("transparency.editOpacityMask", &json!({})).unwrap(); - s.execute("edit.paste", &json!({})).unwrap(); - let doc = &s.doc().unwrap().doc; - let mut counts = vec![]; - doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind { - counts.push(t.list_count); - } - }); - assert_eq!(counts, [0], "the pasted frame in the mask art"); - } - - /// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout - /// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit - /// number pushed it on to the next tab stop and the paragraph took more lines. - #[test] - fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110)); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap(); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap(); - let laid = |s: &Session, id: u64| { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - (lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap()) - }; - let (number, lines, _, _) = laid(&s, b); - let (copy_number, copy_lines, bounds, cached) = laid(&s, copy); - assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}"); - assert_eq!(copy_number, "01."); - assert!(copy_lines < lines, "{copy_lines} {lines}"); - assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}"); - } - - /// Release Selection that leaves one frame of a thread gives that frame the whole story and - /// the layout bounds of its new text. - #[test] - fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(text(&s, b), TEXT.repeat(4)); - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") }; - let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; - let cached = t.cached_bounds.unwrap(); - assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs new file mode 100644 index 000000000..07531d4ed --- /dev/null +++ b/crates/engine/src/cmd/threads/tests.rs @@ -0,0 +1,1267 @@ +use super::*; + +const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. "; + +fn setup() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + (s, a, b) +} + +fn text(s: &Session, id: u64) -> String { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +#[test] +fn thread_flows_reflows_on_edit_and_survives_delete() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let (ta, tb) = (text(&s, a), text(&s, b)); + assert!(!ta.is_empty() && !tb.is_empty()); + assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4)); + // Editing the first frame re-flows into the second. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); + assert_ne!(text(&s, b), tb, "the overflow moved on"); + // Deleting the first frame moves all its text into the second. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4))); + assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1); +} + +#[test] +fn edits_copy_only_the_stories_of_threads_whose_frames_changed() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let c = s.execute("text.create", &json!({"x": 10, "y": 200, "text": TEXT.repeat(2), "area": {"width": 150, "height": 40}})).unwrap()["id"] + .as_u64() + .unwrap(); + let d = s.execute("shape.rectangle", &json!({"x": 200, "y": 220, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [c, d]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let texts = |s: &Session| [a, b, c, d].map(|id| text(s, id)); + let copied = || COPIED.with(std::cell::Cell::get); + let (shown, start) = (texts(&s), copied()); + // A rectangle drawn, moved and dragged beside the threads changes none of their frames. + let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.begin_interaction("Move").unwrap(); + for dx in 1..=3 { + s.preview("object.move", &json!({"dx": dx, "dy": 0})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(copied(), start, "edits beside the threads copied their stories"); + assert_eq!(texts(&s), shown); + // Typing in the first thread copies and re-flows that thread's story only. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(copied(), start + 1); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); + assert_eq!(texts(&s)[2..], shown[2..]); + let ids = |t: [u64; 2]| t.map(NodeId).to_vec(); + assert_eq!(s.doc().unwrap().doc.text_threads, [ids([a, b]), ids([c, d])]); +} + +/// Time edits of a story of 50,000 characters threaded through 8 frames, each frame wrapping +/// around an ellipse, with one style, a style change every 10 words and a style change at every +/// word: 21 characters typed into the first frame, then 200 moves of a rectangle below the +/// frames and 200 frames of a drag of it. +/// Run with `cargo test --release -p vectorcraft-engine --lib threaded_story_edit_timing -- --ignored --nocapture`. +#[test] +#[ignore = "manual timing probe; wall-clock performance depends on machine load"] +fn threaded_story_edit_timing() { + fn median(s: &mut Session, n: u32, f: &dyn Fn(&mut Session, f64)) -> f64 { + let mut ms: Vec = (1..=n) + .map(|i| { + let start = std::time::Instant::now(); + f(s, f64::from(i)); + start.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + ms.sort_by(f64::total_cmp); + ms[ms.len() / 2] + } + let story = format!("{}\n", TEXT.repeat(6).trim_end()).repeat(81)[..50_000].to_string(); + for words in [0, 10, 1] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 4400, "height": 1200})).unwrap(); + let area = json!({"width": 500, "height": 900}); + let first = s.execute("text.create", &json!({"x": 20, "y": 20, "text": story, "size": 9, "area": area})).unwrap()["id"].as_u64().unwrap(); + if words > 0 { + s.edit("Restyle", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(first)).map(|n| &mut n.kind) else { panic!("not text") }; + let style = t.runs[0].style.clone(); + let pieces: Vec<&str> = story.split_inclusive([' ', '\n']).collect(); + t.runs = pieces + .chunks(words) + .enumerate() + .map(|(i, c)| { + let mut run = TextRun::new(c.concat(), style.clone()); + run.style.underline = i % 2 == 1; + run + }) + .collect(); + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + } + let mut ids = vec![first]; + for k in 1..8 { + let frame = json!({"x": 20 + k * 540, "y": 20, "width": 500, "height": 900}); + ids.push(s.execute("shape.rectangle", &frame).unwrap()["id"].as_u64().unwrap()); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + for k in 0..8 { + let e = s.execute("shape.ellipse", &json!({"x": 195 + k * 540, "y": 395, "width": 150, "height": 150})).unwrap()["id"].clone(); + s.execute("object.textWrap.make", &json!({"ids": [e]})).unwrap(); + } + let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 1100, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let typing = median(&mut s, 21, &|s, _| { + s.execute("text.editRange", &json!({"id": first, "start": 0, "end": 0, "insert": "x"})).unwrap(); + }); + let edit = median(&mut s, 200, &|s, _| { + s.execute("object.move", &json!({"dx": 0.5, "dy": 0})).unwrap(); + }); + s.begin_interaction("Move").unwrap(); + let frame = median(&mut s, 200, &|s, i| { + s.preview("object.move", &json!({"dx": i * 0.25, "dy": 0})).unwrap(); + }); + s.commit_interaction().unwrap(); + let d = &s.doc().unwrap().doc; + let runs: usize = ids.iter().filter_map(|id| text_of(d, NodeId(*id))).map(|t| t.runs.len()).sum(); + println!("{runs} runs in 8 frames: typing {typing:.1} ms, a move below them {edit:.3} ms, a drag frame {frame:.3} ms (medians)"); + } +} + +fn new_id(v: Value) -> u64 { + v["id"].as_u64().unwrap() +} + +fn threads(s: &Session) -> Vec> { + s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect() +} + +/// Bounds of area type `id`'s frame on the page. +fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + t.xf.transform_rect_bbox(frame.bounds().unwrap()) +} + +/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the +/// first one's shape and size at a point, and into a new rectangle, each in one undo step. +#[test] +fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() { + let (mut s, a, b) = setup(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b); + assert_eq!(threads(&s), [[a, b]]); + assert!(!text(&s, b).is_empty(), "the overflow flowed into the path"); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back"); + + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap()); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click"); + let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap()); + assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0)); + assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from"); + assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4)); + assert!(!text(&s, d).is_empty()); + // A frame threaded from the middle of a thread goes in after it. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap()); + assert_eq!(threads(&s), [[a, e, c, d]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none()); + assert_eq!(threads(&s), [[a, c, d]]); +} + +/// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes +/// before the frame it was loaded from, so the story starts there and flows on into it; in the +/// middle of a thread the new frame goes in between, each in one undo step. +#[test] +fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, a]]); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click"); + assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c)); + assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]); + // In between the frame and the one before it. + let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, d, a]]); + assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[c, a]]); + // A closed path clicked with the in port's text comes first, with the start of the story. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b); + assert_eq!(threads(&s), [[b, a]]); + assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty()); + assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4)); + // `before: false` is the out port's. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap()); + assert_eq!(threads(&s), [[b, a, e]]); +} + +/// Type on a path along a line beside the area type of [`setup`]. +fn type_on_a_line(s: &mut Session) -> u64 { + let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap()); + new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap()) +} + +/// Type on a path threads as area type does, in any mix: a story flows from area type along a +/// path and on into area type, and a new frame threaded from type on a path at a point is type +/// on a path of the same shape. +#[test] +fn type_on_a_path_threads_with_area_type() { + let (mut s, a, b) = setup(); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); + assert_eq!(threads(&s), [[a, on_path, b]]); + let shares = [a, on_path, b].map(|id| text(&s, id)); + assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}"); + assert_eq!(shares.concat(), TEXT.repeat(4)); + + let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap()); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from"); + assert_eq!(threads(&s), [[a, on_path, c, b]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, on_path, b]]); +} + +/// Type on a path released from its thread gives its text back to the frame before it. +#[test] +fn type_on_a_path_is_released_from_its_thread() { + let (mut s, a, _) = setup(); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + assert!(!text(&s, on_path).is_empty()); + s.execute("select.set", &json!({"ids": [on_path]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert!(text(&s, on_path).is_empty()); + assert_eq!(text(&s, a), TEXT.repeat(4)); +} + +/// Area type threaded into from an out port takes the overflow at once, before its own text. +#[test] +fn add_into_area_type_flows_the_story_into_it() { + let (mut s, a, _) = setup(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + assert_eq!(threads(&s), [[a, c]]); + assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4))); + assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c)); +} + +#[test] +fn add_refuses_bad_params_and_leaves_the_document_alone() { + let (mut s, a, b) = setup(); + let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap()); + let before = s.doc().unwrap().doc.clone(); + for p in [ + json!({"to": b}), + json!({"from": b, "at": [0, 0]}), + json!({"from": 9999, "at": [0, 0]}), + json!({"from": -1, "at": [0, 0]}), + json!({"from": a}), + json!({"from": a, "to": b, "at": [0, 0]}), + json!({"from": a, "to": a}), + json!({"from": a, "to": line}), + json!({"from": a, "to": 9999}), + json!({"from": a, "at": [0]}), + json!({"from": a, "at": ["1", 2]}), + json!({"from": a, "rect": [0, 0, -10, 10]}), + json!({"from": a, "rect": [0, 0, 10, 0]}), + json!({"from": a, "rect": [0, 0, 10]}), + json!({"from": a, "rect": "big"}), + json!({"from": a, "at": [0, 0], "before": "yes"}), + json!({"from": a, "at": [0, 0], "before": 1}), + ] { + assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); + } + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap()); + assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded"); +} + +/// A frame released from a thread that keeps two or more frames gives its text back to them: +/// the story flows through them once. +#[test] +fn release_from_a_longer_thread_keeps_the_story_once() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + assert_eq!(threads(&s), [[a, c, b]]); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, b]]); + assert!(text(&s, c).is_empty()); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); +} + +/// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`], +/// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`. +fn thread_of(n: usize) -> (Session, Vec) { + let (mut s, a, b) = setup(); + let frames = if n == 3 { + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + vec![a, c, b] + } else { + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + vec![a, b] + }; + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, frames) +} + +/// Convert To Point Type on a frame of threaded text takes it out of its thread with the text +/// it holds, and the other frames keep the text they hold: the story holds each character once. +/// A thread left with one frame is no thread. Undo threads the frame again. +#[test] +fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() { + // Soft wraps of the point type become line breaks, so its characters are compared without + // white space. + let squeeze = |t: &str| t.split_whitespace().collect::(); + for n in [2, 3] { + for k in 0..n { + let (mut s, frames) = thread_of(n); + let shown: Vec = frames.iter().map(|id| text(&s, *id)).collect(); + let id = frames[k]; + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type"); + assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text"); + for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) { + assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text"); + } + let rest: Vec = frames.iter().copied().filter(|f| *f != id).collect(); + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}"); + let all: String = frames.iter().map(|f| text(&s, *f)).collect(); + assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::>(), shown, "{n} frames, frame {k}: undo"); + } + } +} + +#[test] +fn release_and_remove() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let tb = text(&s, b); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(text(&s, b), tb, "remove keeps what each frame shows"); + assert!(s.doc().unwrap().doc.text_threads.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(text(&s, b), ""); + assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame"); +} + +/// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each +/// showing part of it. +fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| { + new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap()) + }; + let a = frame(&mut s, 10.0, 10.0, 60.0, story); + let b = frame(&mut s, 10.0, 100.0, 40.0, ""); + let c = frame(&mut s, 200.0, 10.0, last, ""); + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap(); + assert_eq!(threads(&s), [[a, b, c]]); + assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, [a, b, c]) +} + +fn continues(s: &Session, id: u64) -> bool { + text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues) +} + +/// A double-click on a threaded port breaks the thread there: the story flows into the frames +/// before the break and the frames after it are left empty, still threaded with each other +/// when two or more. One undo step restores the thread and its text. +#[test] +fn break_splits_the_thread_and_the_story_flows_into_the_first_part() { + let story = TEXT.repeat(12); + let (mut s, [a, b, c]) = three_frames(&story, 40.0); + let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap()); + s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); + let all = [a, b, c, d]; + let shown: Vec = all.iter().map(|id| text(&s, *id)).collect(); + assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story"); + let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::(); + // (frame, port, threads after, the frames that hold the story, the frames left empty) + let cases = [ + (a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]), + (b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]), + (b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]), + (d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]), + (c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]), + ]; + for (id, port, after, full, empty) in cases { + let case = format!("{port} port of {id}"); + s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap(); + assert_eq!(threads(&s), after, "{case}"); + assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part"); + assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty"); + assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph"); + assert!(!continues(&s, a), "{case}: the first frame starts the story"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [all.to_vec()], "{case}: undo"); + assert_eq!(all.iter().map(|id| text(&s, *id)).collect::>(), shown, "{case}: undo"); + } +} + +#[test] +fn break_refuses_a_port_with_no_thread_and_bad_params() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); + let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap()); + let ids = [a, b, c, lone]; + let shown: Vec = ids.iter().map(|id| text(&s, *id)).collect(); + let before = threads(&s); + for params in [ + json!({"id": a, "port": "in"}), + json!({"id": c, "port": "out"}), + json!({"id": lone, "port": "out"}), + json!({"id": lone, "port": "in"}), + json!({"id": a, "port": "up"}), + json!({"id": a}), + json!({"port": "out"}), + json!({"id": 9999, "port": "out"}), + ] { + assert!(s.execute("text.thread.break", ¶ms).is_err(), "{params}"); + } + assert_eq!(threads(&s), before); + assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::>(), shown); +} + +/// Whether the document holds area type or type on a path `id`. +fn is_frame(s: &Session, id: u64) -> bool { + text_of(&s.doc().unwrap().doc, NodeId(id)).is_some() +} + +/// Create Outlines and Object › Expand on frames of threaded text take them out of their +/// thread with the text they show, which their outlines draw, and the other frames keep the +/// text they hold: each character is in the document once. A thread left with one frame is no +/// thread. Undo threads the frames again. +#[test] +fn outlined_frames_leave_their_thread_with_their_text() { + for command in ["type.createOutlines", "object.expand"] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute(command, &json!({})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined"); + } + let st = s.doc().unwrap(); + let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. }))); + assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected"); + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{command} {out:?}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo"); + } + } +} + +/// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the +/// text it shows; Object › Expand of such a frame expands its appearance first. +#[test] +fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() { + for command in ["effect.expandAppearance", "object.expand"] { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap(); + let shown = [a, b, c].map(|id| text(&s, id)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert!(!is_frame(&s, b), "{command}: the frame is outlined"); + assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text"); + assert_eq!(threads(&s), [[a, c]], "{command}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, b, c]], "{command}: undo"); + assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo"); + } +} + +/// Object › Flatten Transparency outlines frames of threaded text with Convert All Text to +/// Outlines, and turns transparent frames into flat regions or an image: each such frame leaves +/// its thread with the text it shows, and the other frames keep the text they hold. Undo threads +/// the frames again. When the last frame is flattened, the frame before it takes the text it has +/// no room for. +#[test] +fn flattened_frames_leave_their_thread_with_their_text() { + let cases = [(json!({"textToOutlines": true}), false), (json!({}), true), (json!({"balance": 0}), true)]; + for (options, transparent) in cases { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("{options} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + if transparent { + s.execute("transparency.set", &json!({"ids": ids, "opacity": 50})).unwrap(); + } + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let mut p = options.clone(); + p["ids"] = json!(ids); + s.execute("object.flattenTransparency", &p).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is flattened"); + } + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("object.flattenTransparency", &json!({"ids": [c], "textToOutlines": true})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); +} + +/// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new +/// id, in a group under the frame's id: the type takes the frame's place in its thread, and +/// each frame keeps its text, the last frame the text it has no room for too. Type in a group +/// with Crop Marks keeps its id and stays in its thread. +#[test] +fn type_that_expand_appearance_keeps_takes_its_frames_place() { + for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] { + for k in [1, 2] { + let case = format!("{effect} grouped {grouped} frame {k}"); + let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); + let id = frames[k]; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id }; + s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap(); + let shown = frames.map(|id| text(&s, id)); + s.execute("select.set", &json!({"ids": [target]})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let mut types = vec![]; + if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) { + n.walk(&mut |m| { + if matches!(m.kind, NodeKind::Text(_)) { + types.push(m.id.0); + } + }); + } + let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") }; + assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group"); + let mut now = frames; + now[k] = kept; + assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place"); + assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } +} + +/// The text that does not fit a thread's last frame, which its outlines do not draw, stays in +/// the story: when the last frame is outlined, the frame before it takes that text after its +/// own. +#[test] +fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([a, b, c].map(|id| text(&s, id)), shown); +} + +/// Object › Rasterize on frames of threaded text takes them out of their thread with the text +/// they show, which the image draws, and the other frames keep the text they hold. Undo threads +/// the frames again. When the last frame is rasterized, the frame before it takes the text it +/// has no room for. +#[test] +fn rasterized_frames_leave_their_thread_with_their_text() { + for clip in [false, true] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("clippingMask {clip} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized"); + } + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 36})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); +} + +/// Frame `id`'s first line: whether its first paragraph continues from the frame before, where +/// its text starts from the frame's left edge, and how many list markers it starts with. +fn first_line(s: &Session, id: u64) -> (bool, f64, usize) { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &lay.lines[0]; + let x = lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x); + let markers = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); + (t.first_para_continues, (x - frame.bounds().unwrap().x0 - 1e-9).round(), markers) +} + +/// A paragraph's first-line indent and bullet belong to its first line, in the frame it starts +/// in: the frame it continues into starts at its left indent, without the bullet. Re-flowing, +/// saving and opening keep it. In a frame that is no longer threaded and in a copy of a frame, +/// the first paragraph has its first-line indent and bullet. +#[test] +fn a_paragraph_continued_into_the_next_frame_starts_it_without_its_first_line_indent_or_bullet() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap(); + assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 18.0, 1), (true, 18.0, 0))); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "none", "leftIndent": 0, "firstLineIndent": 24})).unwrap(); + assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0))); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + // Typing re-flows the story and keeps it. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(first_line(&s, b), (true, 0.0, 0)); + // Saved and opened: a native file re-flows its threads, a restored document keeps its text. + for name in ["thread.vectorcraft", "thread.eps"] { + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)), "{name}"); + } + // A copy of the second frame is in no thread, and starts with the indent. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!((first_line(&s, b), first_line(&s, copy)), ((true, 0.0, 0), (false, 24.0, 0))); + // …and so is a pasted one, and each copy's cached bounds and baselines are its own layout's. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let pasted = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let dragged = s.execute("layer.move", &json!({"ids": [b], "target": a, "place": "above", "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!((first_line(&s, pasted), first_line(&s, dragged)), ((false, 24.0, 0), (false, 24.0, 0))); + // Every text object in the document has the cached bounds and baselines of its own layout. + let own_caches = |s: &Session, what: &str| { + let mut texts = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((n.id, t)); + } + }); + for (id, t) in texts { + let mut fresh = (**t).clone(); + refresh_bounds(&mut fresh); + assert!(t.cached_bounds.is_some() && t.cached_bounds == fresh.cached_bounds, "{what}: {id:?}"); + assert_eq!(t.cached_baselines, fresh.cached_baselines, "{what}: {id:?}"); + } + }; + own_caches(&s, "copies"); + // Release to Layers (Build), Release Opacity Mask, Edit Opacity Mask and Draw Inside copy the + // frame too. + let layer = s.doc().unwrap().doc.layer_containing(NodeId(b)).unwrap().0; + s.execute("layer.releaseToLayers", &json!({"id": layer, "build": true})).unwrap(); + own_caches(&s, "Release to Layers (Build)"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"ids": [a, b]})).unwrap(); + s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); + own_caches(&s, "Release Opacity Mask"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({"id": a})).unwrap(); + own_caches(&s, "Edit Opacity Mask"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 210, "y": 20, "width": 20, "height": 20})).unwrap(); + own_caches(&s, "Draw Inside"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); + assert_eq!((threads(&s), first_line(&s, b)), (vec![vec![a, b]], (true, 0.0, 0))); + // Remove Threading: each frame keeps its text, and its first paragraph starts in it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(first_line(&s, b), (false, 24.0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(first_line(&s, b), (true, 0.0, 0)); + // Released, the frame is empty and starts the next text typed into it as a paragraph. + let continues = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.first_para_continues, + _ => panic!("not text"), + }; + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(text(&s, b).is_empty() && !continues(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + // With the first frame released, the second one holds the whole story and starts it. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty() && text(&s, a).is_empty()); + assert_eq!(first_line(&s, b), (false, 24.0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + // Converted to point type, it is in no thread, and its first line starts at the indent where + // the frame's left edge was. + let left = frame_box(&s, b).x0; + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert!(!continues(&s, b)); + { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind else { panic!("not text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let g = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); + let x = (t.xf * g.origin).x - left; + assert!((x - 24.0).abs() < 1e-6, "{x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(first_line(&s, b), (true, 0.0, 0)); + // Text that ends the first frame with a paragraph break: the second frame starts a + // paragraph, with its indent. + let all = text(&s, a).len(); + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": all, "insert": "Short line.\n".repeat(12)})).unwrap(); + assert!(text(&s, a).ends_with('\n'), "{:?}", text(&s, a)); + assert_eq!(first_line(&s, b), (false, 24.0, 0)); +} + +/// When Create Outlines takes frames out of a thread, a frame that now starts its thread or is +/// in no thread starts its paragraph with the first-line indent; a frame after one that ends +/// inside the paragraph still continues it. The frames that stay hold each character they held +/// once, and keep their text when no mark changes. Undo marks the frames again. +#[test] +fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() { + for (out, want) in [ + (&[0][..], [None, Some(false), Some(true)]), + (&[0, 2], [None, Some(false), None]), + (&[1], [Some(false), None, Some(true)]), + (&[2], [Some(false), Some(true), None]), + ] { + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + s.execute("text.setFormat", &json!({"ids": frames, "leftIndent": 0, "firstLineIndent": 24})).unwrap(); + let before = frames.map(|id| first_line(&s, id)); + assert_eq!(before, [(false, 24.0, 0), (true, 0.0, 0), (true, 0.0, 0)], "{out:?}"); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4), "{out:?}: the last frame has room for the rest"); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + let stay: Vec = (0..3).filter(|k| want[*k].is_some()).collect(); + for k in &stay { + let on = want[*k] == Some(true); + let indent = if on { 0.0 } else { 24.0 }; + assert_eq!(first_line(&s, frames[*k]), (on, indent, 0), "{out:?}: frame {k}"); + } + let held: String = stay.iter().map(|k| shown[*k].as_str()).collect(); + assert_eq!(stay.iter().map(|k| text(&s, frames[*k])).collect::(), held, "{out:?}: each character once"); + if stay.iter().all(|k| want[*k] == Some(before[*k].0)) { + for k in &stay { + assert_eq!(text(&s, frames[*k]), shown[*k], "{out:?}: frame {k} keeps its text"); + } + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames.map(|id| first_line(&s, id)), before, "{out:?}: undo"); + } +} + +/// The text that a thread's last frame has no room for is measured as the frame lays it out, +/// without the first-line indent of the paragraph it continues: when the frame is outlined, the +/// frame before it takes only that text. +#[test] +fn outlining_a_continued_last_frame_keeps_only_the_text_it_has_no_room_for() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + s.execute("text.setFormat", &json!({"ids": [a, b, c], "leftIndent": 0, "firstLineIndent": 72})).unwrap(); + assert_eq!(first_line(&s, c), (true, 0.0, 0)); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1])); +} + +/// The text that a thread's last frame has no room for is measured with the frame's numbered +/// paragraphs counted on from the frames before it: when the frame is outlined, the frame before +/// it takes only that text. +#[test] +fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + // A hanging indent that "I." fits and "VIII." does not: the text after "VIII." starts at + // the next tab stop. + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "leftIndent": 14, "firstLineIndent": -14})).unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + assert_eq!(t.list_count, 7); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_count: 0, ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); +} + +/// A story of one-line paragraphs that each start with a quotation mark, threaded from area +/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. +fn quoted_thread() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let story = "“Quoted.”\n".repeat(20); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert!(text(&s, b).starts_with('“')); + (s, a, b) +} + +/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame +/// (its lines' opening quotation marks hang). +fn optical(s: &Session, id: u64) -> (bool, bool) { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + (t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0) +} + +/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment +/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every +/// frame hang. Typing keeps it, and frames taken out of the thread keep it. +#[test] +fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() { + let (mut s, a, b) = quoted_thread(); + assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap(); + assert_eq!(r, json!({"on": true, "ids": [a, b]})); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too"); + // Turned off from the second frame, it is off in both, and one undo turns it back on in both. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); + assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); + // Typing in the first frame re-flows the story and keeps the setting. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); + // Any edit that sets one frame sets the thread. + s.edit("Set", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; + t.optical_margin = false; + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); + s.execute("edit.undo", &json!({})).unwrap(); + // Released or unthreaded, each frame keeps it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); +} + +/// Threading type into a thread gives the joined thread the setting of its first frame. +#[test] +fn a_thread_takes_its_first_frames_optical_margin_alignment() { + let (mut s, a, b) = quoted_thread(); + s.execute("text.thread.remove", &json!({})).unwrap(); + s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); + // Threaded again in stacking order: the second frame takes the first one's setting. + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); + // From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting. + s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap(); + assert_eq!(threads(&s), [[b, a]]); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false)); + // A new frame threaded from a frame takes the thread's setting. + s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap()); + assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); +} + +/// A file saved with the frames of one thread set differently opens with the first frame's +/// setting in every frame: a native file, and an EPS file restored from the document it carries. +#[test] +fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { + let (mut s, a, b) = quoted_thread(); + for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { + for (id, on) in [(a, first), (b, !first)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.optical_margin = on; + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}"); + } +} + +/// One count runs through a thread: the second frame's numbered paragraphs go on from the +/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a +/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks +/// and the frame a Release Selection leaves alone count from 1. +#[test] +fn numbered_list_paragraphs_count_on_through_a_thread() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + let numbers = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::>(); + let first = numbers(&s, a); + assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); + assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1)); + // Typing a paragraph into the first frame numbers it and moves the rest on. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap(); + assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat()); + s.execute("edit.undo", &json!({})).unwrap(); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + let after: Vec = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect(); + assert_eq!(after, all(first.len() + 1), "reopened"); + // A pasted copy of the second frame is in no thread. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); + assert_ne!(copy, NodeId(b)); + assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1.")); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate"); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel"); + assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself"); + // Convert To Point Type takes the second frame out of the thread with its text. + s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); + // Remove Threading: each frame is a story of its own. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1.")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); + // Release Selection of the first frame: the second one holds the whole story, from 1. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); +} + +/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art +/// looks as it did; Break Link puts the frames in no thread, and each counts from 1. +#[test] +fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + let count = |t: &TextObject| t.list_count; + let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap(); + assert!(carried > 0); + let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap()); + let counts = |n: &Node| { + let mut counts = vec![]; + n.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + counts.push(t.list_count); + } + }); + counts.sort_unstable(); + counts + }; + let doc = &s.doc().unwrap().doc; + assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]); + // Break Link puts the frames on the artboard in no thread: each counts from 1. + s.execute("select.set", &json!({"ids": [instance]})).unwrap(); + s.execute("symbol.breakLink", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::>(), [0, 0]); +} + +/// The text of each frame in symbol `name`'s art, sorted. +fn symbol_texts(s: &Session, name: &str) -> Vec { + let doc = &s.doc().unwrap().doc; + let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") }; + let mut texts = vec![]; + sym.art.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + texts.push(t.plain_text()); + } + }); + texts.sort(); + texts +} + +/// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance +/// whose art holds the text they show, and the frames leave their thread: the other frames keep +/// the text they hold, so each character is drawn once. Undo threads the frames again. When the +/// last frame goes into the symbol, the frame before it takes the text it has no room for, and +/// the symbol's art holds only the text the frame shows. +#[test] +fn frames_made_into_a_symbol_leave_their_thread_with_their_text() { + let symbol = |s: &mut Session, command: &str, ids: &[u64]| { + let name = if command == "symbol.update" { + let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [mark]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + "Mark" + } else { + "Story" + }; + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute(command, &json!({"name": name})).unwrap(); + name + }; + for command in ["symbol.new", "symbol.update"] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("{command} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + let name = symbol(&mut s, command, &ids); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol"); + } + let mut held: Vec = out.iter().map(|k| shown[*k].clone()).collect(); + held.sort(); + assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show"); + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + let name = symbol(&mut s, command, &[c]); + assert_eq!(text(&s, a), shown[0], "{command}"); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]], "{command}"); + let held = symbol_texts(&s, name); + let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect(); + assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows"); + // With every frame of the thread in the symbol, the last frame keeps all its text. + let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); + let mut shown = frames.map(|id| text(&s, id)).to_vec(); + shown.sort(); + let name = symbol(&mut s, command, &frames); + assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread"); + assert!(threads(&s).is_empty(), "{command}: the whole thread"); + } +} + +/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the +/// mask art too. +#[test] +fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap()); + let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [obj, m]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let mut counts = vec![]; + doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + counts.push(t.list_count); + } + }); + assert_eq!(counts, [0], "the pasted frame in the mask art"); +} + +/// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout +/// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit +/// number pushed it on to the next tab stop and the paragraph took more lines. +#[test] +fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap(); + let laid = |s: &Session, id: u64| { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + (lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap()) + }; + let (number, lines, _, _) = laid(&s, b); + let (copy_number, copy_lines, bounds, cached) = laid(&s, copy); + assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}"); + assert_eq!(copy_number, "01."); + assert!(copy_lines < lines, "{copy_lines} {lines}"); + assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}"); +} + +/// Release Selection that leaves one frame of a thread gives that frame the whole story and +/// the layout bounds of its new text. +#[test] +fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(text(&s, b), TEXT.repeat(4)); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") }; + let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + let cached = t.cached_bounds.unwrap(); + assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 886381cd5..e0cb73a1c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1114,1419 +1114,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod area_tests { - use super::*; - - /// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box - /// `document.inspect` reports for it, so a script can place it without a helper object. - #[test] - fn text_create_returns_the_inspect_bounds() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); - let inspected = |s: &mut Session, id: &Value| { - let doc = s.execute("document.inspect", &json!({})).unwrap(); - fn find(v: &Value, id: &Value) -> Option { - if &v["id"] == id { - return Some(v["bounds"].clone()); - } - v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id)) - } - find(&doc, id).unwrap() - }; - let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap(); - assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}"); - assert_eq!(made["bounds"], inspected(&mut s, &made["id"])); - let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap(); - let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap(); - assert_eq!(area["bounds"], inspected(&mut s, &area["id"])); - } - - /// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on - /// it, or ending there), and `text.setStyle` moves it the same way afterwards. - #[test] - fn text_create_justify_anchors_point_type_on_its_point() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); - let bounds = |s: &Session, id: &Value| { - let id = NodeId(id.as_u64().unwrap()); - let st = s.doc().unwrap(); - st.doc.bounds_of(&[id], false).unwrap() - }; - let make = |s: &mut Session, extra: Value| { - let mut p = json!({"x": 300, "y": 100, "text": "Centred title", "size": 24}); - for (k, v) in extra.as_object().unwrap() { - p[k] = v.clone(); - } - s.execute("text.create", &p).unwrap()["id"].clone() - }; - let id = make(&mut s, json!({})); - let left = bounds(&s, &id); - assert!(left.width() > 10.0, "{left:?}"); - assert!((left.x0 - 300.0).abs() < 1.0, "left (the default) starts at x: {left:?}"); - let id = make(&mut s, json!({"justify": "center"})); - let centre = bounds(&s, &id); - assert!((centre.center().x - 300.0).abs() < 0.5, "centred on x: {centre:?}"); - assert!((centre.width() - left.width()).abs() < 0.5); - let right = make(&mut s, json!({"justify": "right"})); - let r = bounds(&s, &right); - assert!((r.x1 - 300.0).abs() < 1.0, "ends at x: {r:?}"); - let got = |s: &Session, id: &Value| match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { - NodeKind::Text(t) => t.para.justify, - _ => panic!("not text"), - }; - assert_eq!(got(&s, &right), Justify::Right); - // Changed afterwards, it moves to the same place as made centred. - s.execute("text.setStyle", &json!({"id": right, "justify": "center"})).unwrap(); - let moved = bounds(&s, &right); - assert!((moved.center().x - 300.0).abs() < 0.5, "{moved:?}"); - assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "justify": "middle"})).is_err()); - // Type in or on a path takes it too. - let path = s.execute("path.create", &json!({"d": "M50 300 L550 300"})).unwrap()["id"].clone(); - let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "On a path", "justify": "center"})).unwrap(); - assert_eq!(got(&s, &on["id"]), Justify::Center); - } - - #[test] - fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { - use vectorcraft_doc::Kinsoku; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!("text"), - }; - // New type: Hard, today's set. - assert_eq!(para(&s).kinsoku, Kinsoku::Hard); - assert!(serde_json::to_value(para(&s)).unwrap().get("kinsoku").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"kinsoku": "strict"})).is_err()); - s.execute("text.setFormat", &json!({"kinsoku": "soft"})).unwrap(); - assert_eq!(para(&s).kinsoku, Kinsoku::Soft); - assert_eq!(serde_json::to_value(para(&s)).unwrap()["kinsoku"], "soft"); - s.execute("text.setFormat", &json!({"kinsoku": "none"})).unwrap(); - assert_eq!(para(&s).kinsoku, Kinsoku::None); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(para(&s).kinsoku, Kinsoku::Hard); - // Documents from before it read as Hard. - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); - assert_eq!(old.kinsoku, Kinsoku::Hard); - } - - #[test] - fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { - use vectorcraft_doc::Burasagari; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "雅楽、笙。", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!("text"), - }; - // New type: Standard. - assert_eq!(para(&s).burasagari, Burasagari::Standard); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err()); - s.execute("text.setFormat", &json!({"burasagari": "forced"})).unwrap(); - assert_eq!(para(&s).burasagari, Burasagari::Forced); - assert_eq!(serde_json::to_value(para(&s)).unwrap()["burasagari"], "forced"); - s.execute("text.setFormat", &json!({"burasagari": "none"})).unwrap(); - assert_eq!(para(&s).burasagari, Burasagari::None); - // Saved only when on; documents from before it read as None. - assert!(serde_json::to_value(para(&s)).unwrap().get("burasagari").is_none()); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(para(&s).burasagari, Burasagari::Standard); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); - assert_eq!(old.burasagari, Burasagari::None); - } - - /// Type › Optical Margin Alignment: `type.opticalMarginAlignment` sets or toggles the flag on - /// the selected type in one undo step, the opening quotation mark then hangs left of the - /// frame, and the flag is saved only when on and survives a native save. - #[test] - fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted” words", "area": {"width": 300, "height": 100}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - // The left edge of the glyphs in text space, where the frame's left edge is at 0. - let left = |s: &Session| text(s).cached_bounds.unwrap().x0; - // New type: off, and not saved. - assert!(!text(&s).optical_margin); - assert!(serde_json::to_value(text(&s)).unwrap().get("opticalMarginAlignment").is_none()); - let inside = left(&s); - assert!(inside >= 0.0, "{inside}"); - // Nothing but a rectangle selected: an error. - let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [rect]})).unwrap(); - assert!(s.execute("type.opticalMarginAlignment", &json!({})).is_err()); - // Toggled on: the quotation mark hangs outside the frame. - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], true); - assert!(text(&s).optical_margin); - assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); - assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true); - // `on` sets it; without `on` it toggles. - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true); - assert!(text(&s).optical_margin); - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); - assert!(!text(&s).optical_margin); - assert_eq!(left(&s), inside); - // One undo step each. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(text(&s).optical_margin); - // Saved and reopened as it was; a document from before it reads as off. - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert!(t.optical_margin); - let mut old = serde_json::to_value(text(&s)).unwrap(); - old.as_object_mut().unwrap().remove("opticalMarginAlignment"); - assert!(!serde_json::from_value::(old).unwrap().optical_margin); - } - - /// Character panel menu › System Layout: `type.systemLayout` sets the document and every text - /// object in one undo step. New type inherits it. Off by default and in a document from before - /// the flag, and saved only when on. On, each positive advance is a whole number of points. - #[test] - fn system_layout_sets_the_document_and_every_text_object() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); - let other = s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); - let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let setup = |s: &Session| s.doc().unwrap().doc.setup.clone(); - let advances = |s: &Session| { - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>() - }; - assert!(!setup(&s).system_layout); - assert!(!text(&s, id).system_layout && !text(&s, other).system_layout); - assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none()); - assert!(serde_json::to_value(text(&s, id)).unwrap().get("systemLayout").is_none()); - let fractional = advances(&s); - assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); - assert_eq!(s.execute("type.systemLayout", &json!({})).unwrap()["on"], true); - assert!(setup(&s).system_layout); - assert!(text(&s, id).system_layout && text(&s, other).system_layout); - let whole = advances(&s); - assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); - assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); - assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); - assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); - // New type inherits the document's setting. - let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); - assert!(text(&s, made).system_layout); - // A boolean given as text is an error, and the setting stays on. - assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err()); - assert!(setup(&s).system_layout && text(&s, id).system_layout); - assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false); - assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(setup(&s).system_layout && text(&s, id).system_layout); - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert!(back.setup.system_layout); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert!(t.system_layout); - let mut old = serde_json::to_value(setup(&s)).unwrap(); - old.as_object_mut().unwrap().remove("systemLayout"); - assert!(!serde_json::from_value::(old).unwrap().system_layout); - } - - /// Character panel menu › Fractional Widths: `type.fractionalWidths` sets or toggles the flag - /// on the selected type in one undo step. New type and a document from before the flag are on, - /// and the flag is saved only when off. Off, each positive advance is a whole number of points. - #[test] - fn fractional_widths_is_set_per_object_and_saved_only_when_off() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let advances = - |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).glyphs.iter().map(|g| g.advance).collect::>(); - // New type: on, and not saved. - assert!(text(&s).fractional_widths); - assert!(serde_json::to_value(text(&s)).unwrap().get("fractionalWidths").is_none()); - let fractional = advances(&s); - assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); - // Nothing but a rectangle selected: an error. - let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [rect]})).unwrap(); - assert!(s.execute("type.fractionalWidths", &json!({})).is_err()); - // Toggled off: whole-point advances, and the key is written. - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], false); - assert!(!text(&s).fractional_widths); - let whole = advances(&s); - assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); - assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); - assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false); - // A boolean given as text is an error, and the flag stays off. - assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err()); - assert!(!text(&s).fractional_widths); - assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true); - assert!(text(&s).fractional_widths); - assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); - assert!(!text(&s).fractional_widths); - // One undo step. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(text(&s).fractional_widths); - // Saved and reopened as it was; a document from before it reads as on. - s.execute("type.fractionalWidths", &json!({"on": false})).unwrap(); - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert!(!t.fractional_widths); - let mut old = serde_json::to_value(text(&s)).unwrap(); - old.as_object_mut().unwrap().remove("fractionalWidths"); - assert!(serde_json::from_value::(old).unwrap().fractional_widths); - } - - /// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation - /// options, rounded and clamped, in one undo step; they are saved only when not the defaults, - /// and paragraph styles carry them. - #[test] - fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() { - use vectorcraft_doc::HyphenationOptions; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), - _ => panic!("text"), - }; - assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default()); - assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute( - "text.setFormat", - &json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}), - ) - .unwrap(); - let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false }; - let (first, second) = paras(&s); - assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in"); - assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2); - let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); - assert_eq!(back.hyphenation, want); - s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap(); - assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default()); - // A paragraph style carries them; an older file's paragraph reads the defaults. - s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap(); - assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() }); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); - assert_eq!(old.hyphenation, HyphenationOptions::default()); - } - - /// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}` - /// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the - /// frame, it is saved only when on, and paragraph styles carry it. - #[test] - fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let left = |s: &Session| text(s).cached_bounds.unwrap().x0; - assert!(!text(&s).para_at(0).roman_hanging); - assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none()); - let inside = left(&s); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - // Only the first paragraph (the caret's range). - s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap(); - assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging); - assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true); - let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); - assert!(back.roman_hanging); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!text(&s).para_at(0).roman_hanging); - assert!((left(&s) - inside).abs() < 1e-9); - // A paragraph style carries it. - s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap(); - assert!(text(&s).para_at(1).roman_hanging); - } - - /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one - /// undo step, the paragraph is saved with it only when it is not None and opens with it, an - /// unknown value changes nothing, and paragraph styles carry it. - #[test] - fn insert_kashidas_is_set_per_paragraph_and_saved_only_when_set() { - use vectorcraft_doc::text::Kashida; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "كتب محمد\nعلي", "area": {"width": 300, "height": 200}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - assert_eq!(text(&s).para_at(0).kashida, Kashida::None); - assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kashida").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - // Only the first paragraph (the caret's range). - s.execute("text.setFormat", &json!({"kashida": "medium", "start": 0, "end": 0})).unwrap(); - assert_eq!((text(&s).para_at(0).kashida, text(&s).para_at(1).kashida), (Kashida::Medium, Kashida::None)); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kashida"], "medium"); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!((back.para_at(0).kashida, back.para_at(1).kashida), (Kashida::Medium, Kashida::None)); - assert!(s.execute("text.setFormat", &json!({"kashida": "wide"})).is_err()); - assert_eq!(text(&s).para_at(0).kashida, Kashida::Medium, "an unknown value changes nothing"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s).para_at(0).kashida, Kashida::None); - // A paragraph style carries it. - s.execute("paraStyle.new", &json!({"name": "Stretched", "attrs": {"kashida": "stylistic"}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Stretched", "id": id})).unwrap(); - assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); - } - - /// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a - /// bulleted list and refuses unknown values. A paragraph that joins a list with no left or - /// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents - /// keeps them. The list is saved only when set and opens with the file, Return continues it, - /// "none" turns it off, and paragraph styles set it and turn it off. - #[test] - fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { - use vectorcraft_doc::text::{Bullet, ParaList}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = - s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let list = |s: &Session, i: usize| text(s).para_at(i).list; - let indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); - let dot = Some(ParaList::bulleted(Bullet::Dot)); - assert_eq!(list(&s, 0), None); - assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); - // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. - s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); - assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); - // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. - s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); - s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); - let square = Some(ParaList::bulleted(Bullet::Square)); - assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); - for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { - assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); - } - assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); - // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. - let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; - assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); - // Return at the end of a list item starts another one. - s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); - assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); - s.execute("edit.undo", &json!({})).unwrap(); - // "none" turns the list off and leaves the indents. - s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); - assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); - s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); - assert_eq!(list(&s, 1), None); - // Indents the call sets win over the hanging indent. - s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); - assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); - // A style that holds a list sets it; a style captured from a paragraph without one turns it off. - s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); - let dash = Some(ParaList::bulleted(Bullet::Dash)); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); - let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); - s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); - } - - /// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the - /// style; a list switched between bullets and numbers keeps its bullet and its indents; an - /// unknown value is an error; the list is saved, reopened and carried by paragraph styles. - #[test] - fn numbered_lists_are_set_per_paragraph_saved_and_styled() { - use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = - s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let list = |s: &Session, i: usize| text(s).para_at(i).list; - let numbers = |s: &Session| { - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() - }; - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"list": "number"})).unwrap(); - let arabic = Some(ParaList::numbered(NumberStyle::Arabic)); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic)); - assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); - assert_eq!(numbers(&s), ["1.", "2.", "3."]); - // `number` picks the style, `list: "number"` keeps it, and the count runs on through both. - s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap(); - s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap(); - let roman = Some(ParaList::numbered(NumberStyle::UpperRoman)); - assert_eq!(list(&s, 0), roman); - assert_eq!(numbers(&s), ["I.", "2.", "3."]); - // A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings - // back the bullet it had; the indents stay as they are. - s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); - assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); - s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); - let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); - assert_eq!(list(&s, 0), letters); - assert_eq!(numbers(&s), ["a.", "2.", "3."]); - assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); - for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] { - assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); - } - assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing"); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"})); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic)); - // Return at the end of a numbered item starts the next number. - s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap(); - assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]); - s.execute("edit.undo", &json!({})).unwrap(); - // "none" turns the list off; the next numbered paragraph starts again at 1. - s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap(); - assert_eq!(list(&s, 1), None); - assert_eq!(numbers(&s), ["a.", "1."]); - // A style that holds a numbered list sets it, a style captured from a numbered paragraph - // carries its list, and one captured from a paragraph without a list turns it off. - s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman)); - assert_eq!(numbers(&s), ["I.", "II.", "III."]); - let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); - s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); - s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap(); - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!(t.para_at(0).list, roman); - s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); - } - - /// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the - /// transform it is drawn with (in thousandths), from the top line down and left to right. - fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let milli = |v: f64| (v * 1000.0).round() as i64; - let mut v: Vec<(u32, i64, i64, [i64; 6])> = lay - .glyphs - .iter() - .filter(|g| !g.outline.elements().is_empty()) - .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y), g.xf.as_coeffs().map(milli))) - .collect(); - v.sort_by_key(|&(_, x, y, _)| (y, x)); - v - } - - /// Type › Bullets and Numbering › Convert to Text: `text.convertListToText` writes each list - /// paragraph's marker before its text as characters in the marker's style, followed by a tab, - /// turns the list off, and gives the paragraph a plain left tab stop at the left indent where - /// the marker's tab went to it, so every glyph is drawn where it was: bullets in area type (a - /// wrapped item, an empty item, a right-aligned stop at the indent and a stop before it, both - /// with leaders), numbers narrower and wider than the hanging indent (one reaching a stop with - /// a leader past it), and point and vertical type. A paragraph with a tab of its own keeps its - /// stops past the indent. The characters can be styled and deleted, and one undo brings the - /// lists back. - #[test] - fn convert_to_text_writes_the_markers_where_they_were_drawn() { - use vectorcraft_doc::text::{Bullet, ParaList}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len(); - let tabs = - |s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::>(); - let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill"; - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let (four, five) = (long.len() + 6, long.len() + 11); - s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "bullet": "square", "start": four, "end": four})).unwrap(); - // A right-aligned stop with a leader at the fourth item's left indent, and a stop with a - // leader before the fifth item's: the bullets' tabs go to the indent and to that stop and - // draw no leader. - s.execute("text.tabs.set", &json!({"ids": [id], "start": four, "end": four, "stops": [{"position": 18, "align": "right", "leader": "."}]})) - .unwrap(); - s.execute("text.tabs.set", &json!({"ids": [id], "start": five, "end": five, "stops": [{"position": 10, "leader": "."}]})).unwrap(); - // The first character larger, raised and underlined: the bullet takes its size alone. The - // fourth item's first character is larger than the break before it. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "size": 20, "baselineShift": 4, "underline": true})).unwrap(); - s.execute("text.setRangeStyle", &json!({"id": id, "start": four, "end": four + 1, "size": 20})).unwrap(); - let listed = text(&s, id); - let before = drawn_glyphs(&s, id); - assert_eq!(markers(&s, id), 4, "the empty item has no bullet"); - let reply = s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - let inserted = |at: usize| json!({"id": id, "at": at, "length": 4}); - assert_eq!(reply, json!({"ids": [id], "converted": 5, "inserted": [inserted(0), inserted(4), inserted(four), inserted(five)]})); - assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); - let t = text(&s, id); - assert_eq!(t.plain_text(), format!("\u{2022}\tOne\n\u{2022}\t{long}\n\n\u{25AA}\tFour\n\u{2022}\tFive")); - assert!((0..5).all(|k| t.para_at(k).list.is_none()), "no paragraph is in a list"); - assert_eq!(markers(&s, id), 0); - let left = vectorcraft_doc::TabAlign::Left; - let plain_stop = |position| vec![(position, left, String::new())]; - assert_eq!( - (tabs(&s, id, 0), tabs(&s, id, 1), tabs(&s, id, 2), tabs(&s, id, 3), tabs(&s, id, 4)), - (plain_stop(18.0), plain_stop(18.0), vec![], plain_stop(18.0), plain_stop(10.0)), - "a plain left stop at the left indent (in place of the right-aligned one), none in the empty item, and the stop the \ - fifth item's tab went to without its leader" - ); - let bullet = &t.runs[0]; - assert_eq!( - (bullet.text.as_str(), bullet.style.size, bullet.style.baseline_shift, bullet.style.underline), - ("\u{2022}\t", 20.0, 0.0, false), - "the bullet has the first character's size without its baseline shift and underline" - ); - // The characters are text: they take a style and can be deleted. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 30})).unwrap(); - assert_eq!(text(&s, id).runs[0].style.size, 30.0); - s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 4, "insert": ""})).unwrap(); - assert!(text(&s, id).plain_text().starts_with("One\n\u{2022}\t")); - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - let back = text(&s, id); - assert_eq!(back.para_at(3).list, Some(ParaList::bulleted(Bullet::Square))); - assert_eq!((back.runs, back.para, back.paras), (listed.runs, listed.para, listed.paras), "one undo step"); - assert_eq!(drawn_glyphs(&s, id), before); - // Numbers 1 to 9 at 20 pt are narrower than the hanging indent, 10 is wider and takes the - // text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab - // draws neither before nor after. - let items = (1..=11).map(|i| format!("Item {i}")).collect::>().join("\n"); - let id = - s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - let eleven = 9 * 7 + 8; - s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap(); - let before = drawn_glyphs(&s, id); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "numbers"); - let expected = (1..=11).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); - assert_eq!(text(&s, id).plain_text(), expected); - assert_eq!((tabs(&s, id, 0), tabs(&s, id, 9), tabs(&s, id, 10)), (plain_stop(18.0), vec![], plain_stop(72.0))); - // A paragraph with a tab of its own keeps its stops past the left indent, which that tab - // can reach. - let id = s.execute("text.create", &json!({"x": 10, "y": 650, "size": 30, "text": "Item\tvalue"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 72, "leader": "."}, {"position": 200}]})).unwrap(); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(text(&s, id).plain_text(), "1.\tItem\tvalue"); - assert_eq!(tabs(&s, id, 0), [(72.0, left, ".".to_string()), (200.0, left, String::new())]); - // A 30 pt number's tab to a right-aligned stop aligns the whole paragraph there, trailing - // space included, and a converted tab aligns the rest of its line: a plain left stop where - // the number's tab ended keeps the text in place. A right-aligned stop before the left stop - // the number's tab reached, which the wrapped paragraph's width kept the number's tab from, - // would catch the converted tab, which measures only the first line: it goes. The lines are - // compared with the Single-line Composer, as a paragraph with a tab is broken line by line. - let right = vectorcraft_doc::TabAlign::Right; - for (text_in, width, stops, kept) in [ - ("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]), - ("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]), - ] { - let id = s - .execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap(); - s.execute("text.tabs.set", &json!({"ids": [id], "stops": stops})).unwrap(); - let before = drawn_glyphs(&s, id); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}"); - let after = tabs(&s, id, 0); - assert_eq!( - (after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)), - (Some((left, "")), Some(kept)), - "{text_in:?}" - ); - } - // Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is - // wider than the hanging indent, a 12 pt bullet is not. - for (size, list, text_after, stop) in - [(30, "number", "1.\tOne\n2.\tTwo", vec![]), (12, "bullet", "\u{2022}\tOne\n\u{2022}\tTwo", plain_stop(18.0))] - { - let id = s - .execute("text.create", &json!({"x": 10, "y": 500, "size": size, "text": "One\nTwo", "area": {"width": 200, "height": size + 10}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": list})).unwrap(); - assert_eq!(markers(&s, id), 1, "the second item is overset"); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt"); - } - // Point type and vertical type. - for vertical in [false, true] { - let id = - s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - let before = drawn_glyphs(&s, id); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}"); - assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo"); - } - // Type with no list paragraph: nothing to convert and no undo step. - let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("text.convertListToText", &json!({"ids": [plain]})).unwrap(), json!({"ids": [], "converted": 0, "inserted": []})); - assert_eq!(s.doc().unwrap().history.undo.len(), steps); - } - - /// A right-to-left list paragraph converted to text starts with the number, its period and a - /// tab, which are read first: the line draws its glyphs in the order it drew them with the - /// marker, the number at the right with its period at its left. Letters and roman numerals - /// would make the paragraph run left to right, so the paragraph gets the direction it ran in; - /// digits leave the direction unset. - #[test] - fn a_right_to_left_list_paragraph_converts_to_text_read_first() { - use vectorcraft_doc::ParaDirection; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - for (number, text, direction) in [ - ("arabic", "1.\tשלום\n2.\tעולם", None), - ("upperLetters", "A.\tשלום\nB.\tעולם", Some(ParaDirection::RightToLeft)), - ("upperRoman", "I.\tשלום\nII.\tעולם", Some(ParaDirection::RightToLeft)), - ] { - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "שלום\nעולם", "area": {"width": 200, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "number": number})).unwrap(); - // `drawn_glyphs` lists the first line first, from left to right. - let order = |s: &Session| { - let glyphs = drawn_glyphs(s, id); - let top = glyphs.first().map_or(0, |g| g.2); - glyphs.into_iter().filter(|g| g.2 == top).map(|g| g.0).collect::>() - }; - let before = order(&s); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!(t.plain_text(), text); - assert!(t.para_at(0).list.is_none() && t.para_at(1).list.is_none()); - let directions = (t.para_at(0).direction, t.para_at(1).direction); - assert_eq!(order(&s), before, "{number}: the first line's glyphs from left to right"); - assert_eq!(directions, (direction, direction), "{number}"); - } - } - - /// Convert to Text in threaded type writes the numbers the story counted through its frames, - /// and both frames draw every glyph where it was. A paragraph that continues into the next - /// frame gets its number once, where it starts, also when only the later frame is named, and - /// empty frames after a converted paragraph leave its list with it. - #[test] - fn threaded_numbers_convert_to_text_as_the_story_counted_them() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("text"), - }; - let numbers = |s: &Session, id: u64| { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() - }; - let thread = |s: &mut Session, text: &str| { - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - (a, b) - }; - // Whole paragraphs: the first frame ends with a paragraph break. - let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); - let first = numbers(&s, a).len(); - assert!((2..10).contains(&first) && plain(&s, a).ends_with('\n'), "both frames hold items: {first}"); - let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); - let reply = s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); - assert_eq!(reply["converted"], 10, "each paragraph once"); - assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); - let story = (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); - assert_eq!(plain(&s, a) + &plain(&s, b), story); - assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}", n = first + 1))); - // The empty part after the first frame's last break and the second frame's first item are - // one paragraph: a caret at the end of the first frame converts that item. - let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); - let end = plain(&s, a).len(); - let reply = s.execute("text.convertListToText", &json!({"ids": [a], "start": end, "end": end})).unwrap(); - assert_eq!(reply["converted"], 1); - assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}\nItem", n = first + 1)), "{}", plain(&s, b)); - // A paragraph split between the frames, with only the second frame named. - let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); - let (a, b) = thread(&mut s, &format!("One\nTwo\n{long}\nFour")); - assert!(!plain(&s, a).ends_with('\n'), "the third paragraph goes on in the second frame"); - // A paragraph with a tab is broken line by line, so the lines are compared with the - // Single-line Composer, which breaks the listed paragraph the same way. - s.execute("text.setFormat", &json!({"ids": [a, b], "composer": "singleLine"})).unwrap(); - let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); - s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); - assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames, with the paragraph split between them"); - assert_eq!(plain(&s, a) + &plain(&s, b), format!("One\nTwo\n3.\t{long}\n4.\tFour")); - assert_eq!(numbers(&s, a), ["1.", "2."], "the paragraphs of the first frame alone stay in the list"); - let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too"); - // One item that fits in the first of three frames: the two empty frames after it leave the - // list too, so the re-flowed thread draws no number before the converted one. - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); - let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b, c], "list": "number"})).unwrap(); - assert_eq!((plain(&s, b), plain(&s, c), numbers(&s, a)), (String::new(), String::new(), vec!["1.".to_string()])); - assert_eq!(s.execute("text.convertListToText", &json!({"ids": [a]})).unwrap()["converted"], 1); - assert_eq!(plain(&s, a), "1.\tItem"); - assert_eq!(numbers(&s, a), Vec::::new(), "no number drawn before the converted one"); - for id in [a, b, c] { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert!(t.para_at(0).list.is_none(), "frame {id} leaves the list"); - } - // An overset 30 pt number, wider than the hanging indent, in a last frame that starts - // inside a paragraph: its tab goes to the next stop, so it gets no stop at the left indent. - let a = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 30, "text": format!("{long}\nTwo"), "area": {"width": 150, "height": 80}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 80})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - assert!(second.first_para_continues && second.plain_text().ends_with("\nTwo"), "{:?}", second.plain_text()); - assert_eq!(numbers(&s, b).len(), 0, "the second item is overset"); - s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); - let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - assert!(second.plain_text().ends_with("\n2.\tTwo"), "{:?}", second.plain_text()); - assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs); - } - - /// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel) - /// moves the tool's anchor and caret past the characters it inserts at or before them, so that - /// neither lands inside a bullet. In threaded type, when the text before the caret moves to the - /// frame before, the caret goes with its character into that frame, which becomes the - /// selection. - #[test] - fn convert_to_text_moves_the_type_tools_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("text"), - }; - let selection = |s: &Session| { - let o = s.tool_options(); - (o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64()) - }; - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.select_tool("type", Default::default()).unwrap(); - // From after the O of One to after the T of Two. - s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5})); - s.execute("text.convertListToText", &json!({})).unwrap(); - let text = plain(&s, id); - assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo"); - assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T"); - assert!(text.is_char_boundary(5) && text.is_char_boundary(13)); - - let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); - let a = s - .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "area": {"width": 150, "height": 60}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - s.select_tool("selection", Default::default()).unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b)); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.select_tool("type", Default::default()).unwrap(); - // The caret after the d of dog, at the start of the second frame. - s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1})); - s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); - let first = plain(&s, a); - assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}"); - let after_d = (first.len() - "og. ".len()) as u64; - assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame"); - assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); - } - - /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. - #[test] - fn create_outlines_makes_a_path_of_the_bullet() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let outlined = |s: &mut Session, list: bool| { - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); - if list { - s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); - } - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); - s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() - }; - let plain = outlined(&mut s, false); - assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); - } - - /// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and - /// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their - /// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They - /// are saved only when not the defaults, and paragraph styles carry them. - #[test] - fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() { - use vectorcraft_doc::{JustificationOptions, SingleWordJustify}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), - _ => panic!("text"), - }; - assert_eq!(paras(&s).0.justification, JustificationOptions::default()); - assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute( - "text.setFormat", - &json!({ - "wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105], - "autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5 - }), - ) - .unwrap(); - let want = JustificationOptions { - word_spacing: [60.0, 90.0, 1000.0], - letter_spacing: [0.0, 0.0, 10.0], - glyph_scaling: [95.0, 100.0, 105.0], - auto_leading: 150.0, - single_word: SingleWordJustify::AlignCenter, - }; - let (first, second) = paras(&s); - assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in"); - assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter"); - let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); - assert_eq!(back.justification, want); - // Out of order: nothing changes, and the error names the setting. - let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string(); - assert!(e.contains("letterSpacing"), "{e}"); - assert_eq!(paras(&s).1.justification, want); - assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err()); - assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(paras(&s).1.justification, JustificationOptions::default()); - // A paragraph style carries them; an older file's paragraph reads the defaults. - s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap(); - assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() }); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); - assert_eq!(old.justification, JustificationOptions::default()); - } - - /// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts - /// with em box top-to-top leading and em box centre alignment, and new styles made from nothing - /// carry them; params override; the values are journaled; imported text keeps the Roman - /// baseline. - #[test] - fn new_type_takes_the_japanese_defaults_while_the_interface_is_japanese() { - use vectorcraft_doc::{CharAlign, LeadingModel}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let made = |s: &mut Session, cmd: &str, p: Value| -> (LeadingModel, CharAlign) { - let id = s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap(); - match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (t.para.leading_model, t.runs[0].style.char_align), - _ => panic!("text"), - } - }; - let roman = (LeadingModel::RomanBaseline, CharAlign::RomanBaseline); - let japanese = (LeadingModel::EmBoxTop, CharAlign::EmBoxCenter); - let point = json!({"x": 10, "y": 50, "text": "雅楽"}); - assert!(!s.japanese_interface()); - assert_eq!(made(&mut s, "text.create", point.clone()), roman, "headless, `auto`: the Roman defaults"); - s.ui_language = Some("ja".into()); - assert_eq!(made(&mut s, "text.create", point.clone()), japanese); - assert_eq!(made(&mut s, "text.create", json!({"x": 10, "y": 90, "text": "笙", "area": {"width": 100, "height": 50}})), japanese); - // Journaled: a replay in another language does the same. - let (_, logged) = s.journal.last().unwrap().clone(); - assert_eq!((logged["leadingModel"].as_str(), logged["charAlign"].as_str()), (Some("emBoxTop"), Some("emBoxCenter"))); - // Params override. - assert_eq!( - made(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "a", "leadingModel": "romanBaseline", "charAlign": "romanBaseline"})), - roman - ); - assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "leadingModel": "middle"})).is_err()); - // Area type in a path. - let path = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 100, "height": 80})).unwrap()["id"].clone(); - assert_eq!(made(&mut s, "text.createInPath", json!({"path": path, "mode": "area", "text": "篳篥"})), japanese); - // A preference set to Japanese counts without a UI; another language doesn't. - s.ui_language = None; - s.prefs.interface_language = "ja".into(); - assert_eq!(made(&mut s, "text.create", point.clone()), japanese); - s.ui_language = Some("en".into()); - assert_eq!(made(&mut s, "text.create", point.clone()), roman); - // New styles made from nothing. - s.execute("select.set", &json!({"ids": []})).unwrap(); - s.ui_language = Some("ja".into()); - let style = |s: &mut Session, kind: &str| { - let name = s.execute(&format!("{kind}.new"), &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - let list = s.execute(&format!("{kind}.list"), &json!({})).unwrap(); - list["styles"].as_array().unwrap().iter().find(|st| st["name"] == name.as_str()).unwrap()["attrs"].clone() - }; - assert_eq!(style(&mut s, "paraStyle"), json!({"leading_model": "emBoxTop"})); - assert_eq!(style(&mut s, "charStyle"), json!({"charAlign": "emBoxCenter"})); - s.ui_language = Some("en".into()); - assert_eq!(style(&mut s, "paraStyle"), json!({})); - // Imported text keeps the Roman baseline. - s.ui_language = Some("ja".into()); - let svg = r#"雅楽"#; - s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); - let mut found = vec![]; - s.doc().unwrap().doc.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - found.push((t.para.leading_model, t.runs[0].style.char_align)); - } - }); - assert_eq!(found, [roman]); - } - - #[test] - fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() { - use vectorcraft_doc::LeadingModel; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - // Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts. - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - assert_eq!(text(&s).para.leading_model, LeadingModel::RomanBaseline); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"leadingModel": "middle"})).is_err()); - let before = text(&s).cached_bounds; - s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); - let t = text(&s); - assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); - // The fallback used without craft-fonts has no glyph metrics to move the first line. - if !vectorcraft_text::CRAFT_FONTS.is_empty() { - assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); - } - assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none()); - } - - #[test] - fn character_alignment_is_a_character_attribute_saved_only_when_set() { - use vectorcraft_doc::CharAlign; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charAlign").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"charAlign": "middle"})).is_err()); - s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_align == CharAlign::EmBoxCenter)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], "emBoxCenter"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); - // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). - assert!(s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "top"})).is_err()); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap(); - let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect(); - assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]); - // The ideographic character face's top and bottom. - for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] { - s.execute("text.setFormat", &json!({"charAlign": key})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_align == want)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key); - } - } - - /// Character Direction (Character panel menu, Middle Eastern options): a character attribute - /// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when - /// set and kept by a native save. - #[test] - fn character_direction_is_a_character_attribute_saved_only_when_set() { - use vectorcraft_doc::ParaDirection; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); - assert_eq!(runs(&s)[0].style.char_direction, None); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { - s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); - let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); - assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); - } - let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); - assert!(err.contains("`charDirection` must be"), "{err}"); - // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). - s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); - assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); - s.execute("edit.redo", &json!({})).unwrap(); - // Saved and reopened as it was. - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); - assert_eq!(reopened, set(&s)); - // A character style captured from the characters carries it, Default included. - assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); - assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); - } - - /// Proportional Metrics (#966): a character attribute, saved only when on. - #[test] - fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert!(!runs(&s)[0].style.proportional_metrics); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!runs(&s)[0].style.proportional_metrics); - // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). - s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); - let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); - assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); - // Styles written without it (documents from before it) read as off. - let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); - assert!(old.get("proportionalMetrics").is_none()); - assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); - } - - /// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only - /// when on. Documents written before it read as off. - #[test] - fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "2026年"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert!(!runs(&s)[0].style.tate_chu_yoko); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("tateChuYoko").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"tateChuYoko": true})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.tate_chu_yoko)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["tateChuYoko"], true); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!runs(&s)[0].style.tate_chu_yoko); - // The year only (2026 is 4 bytes); 年 stays unmarked. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "tateChuYoko": true})).unwrap(); - let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect(); - assert_eq!(on, [("2026".to_string(), true), ("年".to_string(), false)]); - let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); - assert!(old.get("tateChuYoko").is_none()); - assert!(!serde_json::from_value::(old).unwrap().tate_chu_yoko); - s.execute("text.setFormat", &json!({"tateChuYoko": false})).unwrap(); - assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); - } - - /// No Break is a character attribute, saved only when on. - #[test] - fn no_break_is_a_character_attribute_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert!(!runs(&s)[0].style.no_break); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("noBreak").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"noBreak": true})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.no_break)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["noBreak"], true); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!runs(&s)[0].style.no_break); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "noBreak": true})).unwrap(); - let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.no_break)).collect(); - assert_eq!(on, [("A".to_string(), true), ("b".to_string(), false)]); - let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); - assert!(old.get("noBreak").is_none()); - assert!(!serde_json::from_value::(old).unwrap().no_break); - s.execute("text.setFormat", &json!({"noBreak": false})).unwrap(); - assert!(runs(&s).iter().all(|r| !r.style.no_break)); - } - - /// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only - /// when set; the text keeps the digits that were typed. - #[test] - fn digits_are_a_character_attribute_saved_only_when_set() { - use vectorcraft_doc::Digits; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "صفحة 12"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert_eq!(runs(&s)[0].style.digits, Digits::Default); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("digits").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - for (key, want) in [("hindi", Digits::Hindi), ("farsi", Digits::Farsi), ("arabic", Digits::Arabic), ("default", Digits::Default)] { - s.execute("text.setFormat", &json!({"digits": key})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.digits == want)); - let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); - assert_eq!(saved.get("digits").and_then(|v| v.as_str()), (want != Digits::Default).then_some(key)); - // The text is as typed: only the glyphs change. - assert_eq!(runs(&s).iter().map(|r| r.text.as_str()).collect::(), "صفحة 12"); - } - s.execute("text.setFormat", &json!({"digits": "hindi"})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(runs(&s)[0].style.digits, Digits::Default); - let err = s.execute("text.setFormat", &json!({"digits": "roman"})).unwrap_err().to_string(); - assert!(err.contains("`digits` must be"), "{err}"); - // Characters selected with the Type tool: only the range takes it (صفحة is 8 bytes). - s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 11, "digits": "farsi"})).unwrap(); - let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.digits)).collect(); - assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]); - } - - /// Variable Font settings (Character panel › Variable Font): a character attribute, saved only - /// when set, each value clamped to its axis's range, and cleared by another font or style. - #[test] - fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { - let _fonts = crate::adding_fonts(); - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned(); - let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width(); - assert_eq!(saved(&s), None); - let regular = width(&s); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wght": 650.0}))); - assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s)); - // Past the axis's range: its maximum; an axis the font hasn't: left out; others kept. - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0}))); - // Null goes back to the style's own setting, and so does a value equal to it. - s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); - s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap(); - assert_eq!(saved(&s), None); - assert!((width(&s) - regular).abs() < 1e-6); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); - // Characters selected with the Type tool: only the range takes it. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap(); - let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect(); - let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::>(); - assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]); - // A saved file keeps them. - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert_eq!(t.runs, runs(&s)); - // Another style or font clears them: their settings were the old face's. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap(); - assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true))); - s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); - s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - // A static font has no axes to set. - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); - assert_eq!(saved(&s), None); - // A missing font keeps them as given, for when it is installed. - s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap(); - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wght": 2000.0}))); - for (bad, says) in - [(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")] - { - let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string(); - assert!(err.contains(says), "{err}"); - } - // The axes and the setting of a style, as the sliders show them. - let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap(); - assert_eq!( - list["axes"], - json!([ - {"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false}, - {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, - ]) - ); - assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); - } - - #[test] - fn new_type_is_composed_with_line_end_half_width_punctuation() { - use vectorcraft_doc::Mojikumi; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!("text"), - }; - assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err()); - s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap(); - assert_eq!(para(&s).mojikumi, Mojikumi::None); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); - // Saved only when set; documents from before it read as None. - let json = serde_json::to_value(para(&s)).unwrap(); - assert_eq!(json["mojikumi"], "lineEndHalf"); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); - assert_eq!(old.mojikumi, Mojikumi::None); - assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none()); - } - - #[test] - fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap(); - let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); - let wide = bounds(&s); - assert!(wide.width() > wide.height()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("type.orientation.vertical", &json!({})).unwrap(); - let tall = bounds(&s); - assert!(tall.height() > tall.width(), "{tall:?}"); - assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(bounds(&s), wide); - } - - #[test] - fn area_options_columns_change_layout() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let text = "word ".repeat(80); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let q = s.execute("text.areaOptions", &json!({})).unwrap(); - assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1))); - let lay = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t), - _ => panic!(), - }; - assert_eq!(lay(&s).frames.len(), 1); - let r = s.execute("text.areaOptions", &json!({"columns": 3, "gutter": 12, "inset": 4, "firstBaseline": "capHeight"})).unwrap(); - assert_eq!(r["firstBaseline"], "capHeight"); - let l = lay(&s); - assert_eq!(l.frames.len(), 3, "three column cells"); - // Glyphs land in more than one column. - let xs: Vec = l.glyphs.iter().map(|g| g.origin.x).collect(); - assert!(xs.iter().any(|x| *x > 110.0 + 10.0) && xs.iter().any(|x| *x < 100.0)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(lay(&s).frames.len(), 1); - assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); - } -} +mod area_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs new file mode 100644 index 000000000..a844d0083 --- /dev/null +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -0,0 +1,1396 @@ +use super::*; + +/// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box +/// `document.inspect` reports for it, so a script can place it without a helper object. +#[test] +fn text_create_returns_the_inspect_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let inspected = |s: &mut Session, id: &Value| { + let doc = s.execute("document.inspect", &json!({})).unwrap(); + fn find(v: &Value, id: &Value) -> Option { + if &v["id"] == id { + return Some(v["bounds"].clone()); + } + v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id)) + } + find(&doc, id).unwrap() + }; + let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap(); + assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}"); + assert_eq!(made["bounds"], inspected(&mut s, &made["id"])); + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap(); + let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap(); + assert_eq!(area["bounds"], inspected(&mut s, &area["id"])); +} + +/// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on +/// it, or ending there), and `text.setStyle` moves it the same way afterwards. +#[test] +fn text_create_justify_anchors_point_type_on_its_point() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let bounds = |s: &Session, id: &Value| { + let id = NodeId(id.as_u64().unwrap()); + let st = s.doc().unwrap(); + st.doc.bounds_of(&[id], false).unwrap() + }; + let make = |s: &mut Session, extra: Value| { + let mut p = json!({"x": 300, "y": 100, "text": "Centred title", "size": 24}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + s.execute("text.create", &p).unwrap()["id"].clone() + }; + let id = make(&mut s, json!({})); + let left = bounds(&s, &id); + assert!(left.width() > 10.0, "{left:?}"); + assert!((left.x0 - 300.0).abs() < 1.0, "left (the default) starts at x: {left:?}"); + let id = make(&mut s, json!({"justify": "center"})); + let centre = bounds(&s, &id); + assert!((centre.center().x - 300.0).abs() < 0.5, "centred on x: {centre:?}"); + assert!((centre.width() - left.width()).abs() < 0.5); + let right = make(&mut s, json!({"justify": "right"})); + let r = bounds(&s, &right); + assert!((r.x1 - 300.0).abs() < 1.0, "ends at x: {r:?}"); + let got = |s: &Session, id: &Value| match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { + NodeKind::Text(t) => t.para.justify, + _ => panic!("not text"), + }; + assert_eq!(got(&s, &right), Justify::Right); + // Changed afterwards, it moves to the same place as made centred. + s.execute("text.setStyle", &json!({"id": right, "justify": "center"})).unwrap(); + let moved = bounds(&s, &right); + assert!((moved.center().x - 300.0).abs() < 0.5, "{moved:?}"); + assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "justify": "middle"})).is_err()); + // Type in or on a path takes it too. + let path = s.execute("path.create", &json!({"d": "M50 300 L550 300"})).unwrap()["id"].clone(); + let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "On a path", "justify": "center"})).unwrap(); + assert_eq!(got(&s, &on["id"]), Justify::Center); +} + +#[test] +fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { + use vectorcraft_doc::Kinsoku; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"].as_u64().unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + // New type: Hard, today's set. + assert_eq!(para(&s).kinsoku, Kinsoku::Hard); + assert!(serde_json::to_value(para(&s)).unwrap().get("kinsoku").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"kinsoku": "strict"})).is_err()); + s.execute("text.setFormat", &json!({"kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::Soft); + assert_eq!(serde_json::to_value(para(&s)).unwrap()["kinsoku"], "soft"); + s.execute("text.setFormat", &json!({"kinsoku": "none"})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::None); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::Hard); + // Documents from before it read as Hard. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.kinsoku, Kinsoku::Hard); +} + +#[test] +fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { + use vectorcraft_doc::Burasagari; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "雅楽、笙。", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + // New type: Standard. + assert_eq!(para(&s).burasagari, Burasagari::Standard); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err()); + s.execute("text.setFormat", &json!({"burasagari": "forced"})).unwrap(); + assert_eq!(para(&s).burasagari, Burasagari::Forced); + assert_eq!(serde_json::to_value(para(&s)).unwrap()["burasagari"], "forced"); + s.execute("text.setFormat", &json!({"burasagari": "none"})).unwrap(); + assert_eq!(para(&s).burasagari, Burasagari::None); + // Saved only when on; documents from before it read as None. + assert!(serde_json::to_value(para(&s)).unwrap().get("burasagari").is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).burasagari, Burasagari::Standard); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.burasagari, Burasagari::None); +} + +/// Type › Optical Margin Alignment: `type.opticalMarginAlignment` sets or toggles the flag on +/// the selected type in one undo step, the opening quotation mark then hangs left of the +/// frame, and the flag is saved only when on and survives a native save. +#[test] +fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted” words", "area": {"width": 300, "height": 100}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + // The left edge of the glyphs in text space, where the frame's left edge is at 0. + let left = |s: &Session| text(s).cached_bounds.unwrap().x0; + // New type: off, and not saved. + assert!(!text(&s).optical_margin); + assert!(serde_json::to_value(text(&s)).unwrap().get("opticalMarginAlignment").is_none()); + let inside = left(&s); + assert!(inside >= 0.0, "{inside}"); + // Nothing but a rectangle selected: an error. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + assert!(s.execute("type.opticalMarginAlignment", &json!({})).is_err()); + // Toggled on: the quotation mark hangs outside the frame. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], true); + assert!(text(&s).optical_margin); + assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); + assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true); + // `on` sets it; without `on` it toggles. + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true); + assert!(text(&s).optical_margin); + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); + assert!(!text(&s).optical_margin); + assert_eq!(left(&s), inside); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s).optical_margin); + // Saved and reopened as it was; a document from before it reads as off. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.optical_margin); + let mut old = serde_json::to_value(text(&s)).unwrap(); + old.as_object_mut().unwrap().remove("opticalMarginAlignment"); + assert!(!serde_json::from_value::(old).unwrap().optical_margin); +} + +/// Character panel menu › System Layout: `type.systemLayout` sets the document and every text +/// object in one undo step. New type inherits it. Off by default and in a document from before +/// the flag, and saved only when on. On, each positive advance is a whole number of points. +#[test] +fn system_layout_sets_the_document_and_every_text_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let setup = |s: &Session| s.doc().unwrap().doc.setup.clone(); + let advances = + |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>(); + assert!(!setup(&s).system_layout); + assert!(!text(&s, id).system_layout && !text(&s, other).system_layout); + assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none()); + assert!(serde_json::to_value(text(&s, id)).unwrap().get("systemLayout").is_none()); + let fractional = advances(&s); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + assert_eq!(s.execute("type.systemLayout", &json!({})).unwrap()["on"], true); + assert!(setup(&s).system_layout); + assert!(text(&s, id).system_layout && text(&s, other).system_layout); + let whole = advances(&s); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); + assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); + // New type inherits the document's setting. + let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); + assert!(text(&s, made).system_layout); + // A boolean given as text is an error, and the setting stays on. + assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err()); + assert!(setup(&s).system_layout && text(&s, id).system_layout); + assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false); + assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(setup(&s).system_layout && text(&s, id).system_layout); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(back.setup.system_layout); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.system_layout); + let mut old = serde_json::to_value(setup(&s)).unwrap(); + old.as_object_mut().unwrap().remove("systemLayout"); + assert!(!serde_json::from_value::(old).unwrap().system_layout); +} + +/// Character panel menu › Fractional Widths: `type.fractionalWidths` sets or toggles the flag +/// on the selected type in one undo step. New type and a document from before the flag are on, +/// and the flag is saved only when off. Off, each positive advance is a whole number of points. +#[test] +fn fractional_widths_is_set_per_object_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let advances = + |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).glyphs.iter().map(|g| g.advance).collect::>(); + // New type: on, and not saved. + assert!(text(&s).fractional_widths); + assert!(serde_json::to_value(text(&s)).unwrap().get("fractionalWidths").is_none()); + let fractional = advances(&s); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + // Nothing but a rectangle selected: an error. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + assert!(s.execute("type.fractionalWidths", &json!({})).is_err()); + // Toggled off: whole-point advances, and the key is written. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], false); + assert!(!text(&s).fractional_widths); + let whole = advances(&s); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false); + // A boolean given as text is an error, and the flag stays off. + assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err()); + assert!(!text(&s).fractional_widths); + assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true); + assert!(text(&s).fractional_widths); + assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); + assert!(!text(&s).fractional_widths); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s).fractional_widths); + // Saved and reopened as it was; a document from before it reads as on. + s.execute("type.fractionalWidths", &json!({"on": false})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(!t.fractional_widths); + let mut old = serde_json::to_value(text(&s)).unwrap(); + old.as_object_mut().unwrap().remove("fractionalWidths"); + assert!(serde_json::from_value::(old).unwrap().fractional_widths); +} + +/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation +/// options, rounded and clamped, in one undo step; they are saved only when not the defaults, +/// and paragraph styles carry them. +#[test] +fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() { + use vectorcraft_doc::HyphenationOptions; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("text"), + }; + assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default()); + assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.setFormat", + &json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}), + ) + .unwrap(); + let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false }; + let (first, second) = paras(&s); + assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in"); + assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); + assert_eq!(back.hyphenation, want); + s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap(); + assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default()); + // A paragraph style carries them; an older file's paragraph reads the defaults. + s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap(); + assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() }); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); + assert_eq!(old.hyphenation, HyphenationOptions::default()); +} + +/// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}` +/// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the +/// frame, it is saved only when on, and paragraph styles carry it. +#[test] +fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let left = |s: &Session| text(s).cached_bounds.unwrap().x0; + assert!(!text(&s).para_at(0).roman_hanging); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none()); + let inside = left(&s); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap(); + assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging); + assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); + assert!(back.roman_hanging); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(0).roman_hanging); + assert!((left(&s) - inside).abs() < 1e-9); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap(); + assert!(text(&s).para_at(1).roman_hanging); +} + +/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one +/// undo step, the paragraph is saved with it only when it is not None and opens with it, an +/// unknown value changes nothing, and paragraph styles carry it. +#[test] +fn insert_kashidas_is_set_per_paragraph_and_saved_only_when_set() { + use vectorcraft_doc::text::Kashida; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "كتب محمد\nعلي", "area": {"width": 300, "height": 200}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kashida").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"kashida": "medium", "start": 0, "end": 0})).unwrap(); + assert_eq!((text(&s).para_at(0).kashida, text(&s).para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kashida"], "medium"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kashida, back.para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert!(s.execute("text.setFormat", &json!({"kashida": "wide"})).is_err()); + assert_eq!(text(&s).para_at(0).kashida, Kashida::Medium, "an unknown value changes nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Stretched", "attrs": {"kashida": "stylistic"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Stretched", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); +} + +/// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a +/// bulleted list and refuses unknown values. A paragraph that joins a list with no left or +/// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents +/// keeps them. The list is saved only when set and opens with the file, Return continues it, +/// "none" turns it off, and paragraph styles set it and turn it off. +#[test] +fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!(list(&s, 0), None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); + // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); + assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); + // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); + let square = Some(ParaList::bulleted(Bullet::Square)); + assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); + for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); + // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. + let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); + // Return at the end of a list item starts another one. + s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); + assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off and leaves the indents. + s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); + assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + // Indents the call sets win over the hanging indent. + s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); + assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); + // A style that holds a list sets it; a style captured from a paragraph without one turns it off. + s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); + let dash = Some(ParaList::bulleted(Bullet::Dash)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); + let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); +} + +/// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the +/// style; a list switched between bullets and numbers keeps its bullet and its indents; an +/// unknown value is an error; the list is saved, reopened and carried by paragraph styles. +#[test] +fn numbered_lists_are_set_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let numbers = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number"})).unwrap(); + let arabic = Some(ParaList::numbered(NumberStyle::Arabic)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic)); + assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); + assert_eq!(numbers(&s), ["1.", "2.", "3."]); + // `number` picks the style, `list: "number"` keeps it, and the count runs on through both. + s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap(); + let roman = Some(ParaList::numbered(NumberStyle::UpperRoman)); + assert_eq!(list(&s, 0), roman); + assert_eq!(numbers(&s), ["I.", "2.", "3."]); + // A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings + // back the bullet it had; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); + s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); + let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); + assert_eq!(list(&s, 0), letters); + assert_eq!(numbers(&s), ["a.", "2.", "3."]); + assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); + for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic)); + // Return at the end of a numbered item starts the next number. + s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap(); + assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off; the next numbered paragraph starts again at 1. + s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + assert_eq!(numbers(&s), ["a.", "1."]); + // A style that holds a numbered list sets it, a style captured from a numbered paragraph + // carries its list, and one captured from a paragraph without a list turns it off. + s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman)); + assert_eq!(numbers(&s), ["I.", "II.", "III."]); + let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.para_at(0).list, roman); + s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); +} + +/// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the +/// transform it is drawn with (in thousandths), from the top line down and left to right. +fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let milli = |v: f64| (v * 1000.0).round() as i64; + let mut v: Vec<(u32, i64, i64, [i64; 6])> = lay + .glyphs + .iter() + .filter(|g| !g.outline.elements().is_empty()) + .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y), g.xf.as_coeffs().map(milli))) + .collect(); + v.sort_by_key(|&(_, x, y, _)| (y, x)); + v +} + +/// Type › Bullets and Numbering › Convert to Text: `text.convertListToText` writes each list +/// paragraph's marker before its text as characters in the marker's style, followed by a tab, +/// turns the list off, and gives the paragraph a plain left tab stop at the left indent where +/// the marker's tab went to it, so every glyph is drawn where it was: bullets in area type (a +/// wrapped item, an empty item, a right-aligned stop at the indent and a stop before it, both +/// with leaders), numbers narrower and wider than the hanging indent (one reaching a stop with +/// a leader past it), and point and vertical type. A paragraph with a tab of its own keeps its +/// stops past the indent. The characters can be styled and deleted, and one undo brings the +/// lists back. +#[test] +fn convert_to_text_writes_the_markers_where_they_were_drawn() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len(); + let tabs = + |s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::>(); + let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill"; + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let (four, five) = (long.len() + 6, long.len() + 11); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "bullet": "square", "start": four, "end": four})).unwrap(); + // A right-aligned stop with a leader at the fourth item's left indent, and a stop with a + // leader before the fifth item's: the bullets' tabs go to the indent and to that stop and + // draw no leader. + s.execute("text.tabs.set", &json!({"ids": [id], "start": four, "end": four, "stops": [{"position": 18, "align": "right", "leader": "."}]})) + .unwrap(); + s.execute("text.tabs.set", &json!({"ids": [id], "start": five, "end": five, "stops": [{"position": 10, "leader": "."}]})).unwrap(); + // The first character larger, raised and underlined: the bullet takes its size alone. The + // fourth item's first character is larger than the break before it. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "size": 20, "baselineShift": 4, "underline": true})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": four, "end": four + 1, "size": 20})).unwrap(); + let listed = text(&s, id); + let before = drawn_glyphs(&s, id); + assert_eq!(markers(&s, id), 4, "the empty item has no bullet"); + let reply = s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + let inserted = |at: usize| json!({"id": id, "at": at, "length": 4}); + assert_eq!(reply, json!({"ids": [id], "converted": 5, "inserted": [inserted(0), inserted(4), inserted(four), inserted(five)]})); + assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); + let t = text(&s, id); + assert_eq!(t.plain_text(), format!("\u{2022}\tOne\n\u{2022}\t{long}\n\n\u{25AA}\tFour\n\u{2022}\tFive")); + assert!((0..5).all(|k| t.para_at(k).list.is_none()), "no paragraph is in a list"); + assert_eq!(markers(&s, id), 0); + let left = vectorcraft_doc::TabAlign::Left; + let plain_stop = |position| vec![(position, left, String::new())]; + assert_eq!( + (tabs(&s, id, 0), tabs(&s, id, 1), tabs(&s, id, 2), tabs(&s, id, 3), tabs(&s, id, 4)), + (plain_stop(18.0), plain_stop(18.0), vec![], plain_stop(18.0), plain_stop(10.0)), + "a plain left stop at the left indent (in place of the right-aligned one), none in the empty item, and the stop the \ + fifth item's tab went to without its leader" + ); + let bullet = &t.runs[0]; + assert_eq!( + (bullet.text.as_str(), bullet.style.size, bullet.style.baseline_shift, bullet.style.underline), + ("\u{2022}\t", 20.0, 0.0, false), + "the bullet has the first character's size without its baseline shift and underline" + ); + // The characters are text: they take a style and can be deleted. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs[0].style.size, 30.0); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 4, "insert": ""})).unwrap(); + assert!(text(&s, id).plain_text().starts_with("One\n\u{2022}\t")); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let back = text(&s, id); + assert_eq!(back.para_at(3).list, Some(ParaList::bulleted(Bullet::Square))); + assert_eq!((back.runs, back.para, back.paras), (listed.runs, listed.para, listed.paras), "one undo step"); + assert_eq!(drawn_glyphs(&s, id), before); + // Numbers 1 to 9 at 20 pt are narrower than the hanging indent, 10 is wider and takes the + // text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab + // draws neither before nor after. + let items = (1..=11).map(|i| format!("Item {i}")).collect::>().join("\n"); + let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + let eleven = 9 * 7 + 8; + s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "numbers"); + let expected = (1..=11).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); + assert_eq!(text(&s, id).plain_text(), expected); + assert_eq!((tabs(&s, id, 0), tabs(&s, id, 9), tabs(&s, id, 10)), (plain_stop(18.0), vec![], plain_stop(72.0))); + // A paragraph with a tab of its own keeps its stops past the left indent, which that tab + // can reach. + let id = s.execute("text.create", &json!({"x": 10, "y": 650, "size": 30, "text": "Item\tvalue"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 72, "leader": "."}, {"position": 200}]})).unwrap(); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "1.\tItem\tvalue"); + assert_eq!(tabs(&s, id, 0), [(72.0, left, ".".to_string()), (200.0, left, String::new())]); + // A 30 pt number's tab to a right-aligned stop aligns the whole paragraph there, trailing + // space included, and a converted tab aligns the rest of its line: a plain left stop where + // the number's tab ended keeps the text in place. A right-aligned stop before the left stop + // the number's tab reached, which the wrapped paragraph's width kept the number's tab from, + // would catch the converted tab, which measures only the first line: it goes. The lines are + // compared with the Single-line Composer, as a paragraph with a tab is broken line by line. + let right = vectorcraft_doc::TabAlign::Right; + for (text_in, width, stops, kept) in [ + ("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]), + ("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]), + ] { + let id = s.execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}})).unwrap() + ["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap(); + s.execute("text.tabs.set", &json!({"ids": [id], "stops": stops})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}"); + let after = tabs(&s, id, 0); + assert_eq!((after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)), (Some((left, "")), Some(kept)), "{text_in:?}"); + } + // Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is + // wider than the hanging indent, a 12 pt bullet is not. + for (size, list, text_after, stop) in + [(30, "number", "1.\tOne\n2.\tTwo", vec![]), (12, "bullet", "\u{2022}\tOne\n\u{2022}\tTwo", plain_stop(18.0))] + { + let id = s + .execute("text.create", &json!({"x": 10, "y": 500, "size": size, "text": "One\nTwo", "area": {"width": 200, "height": size + 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": list})).unwrap(); + assert_eq!(markers(&s, id), 1, "the second item is overset"); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt"); + } + // Point type and vertical type. + for vertical in [false, true] { + let id = s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}"); + assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo"); + } + // Type with no list paragraph: nothing to convert and no undo step. + let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("text.convertListToText", &json!({"ids": [plain]})).unwrap(), json!({"ids": [], "converted": 0, "inserted": []})); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +/// A right-to-left list paragraph converted to text starts with the number, its period and a +/// tab, which are read first: the line draws its glyphs in the order it drew them with the +/// marker, the number at the right with its period at its left. Letters and roman numerals +/// would make the paragraph run left to right, so the paragraph gets the direction it ran in; +/// digits leave the direction unset. +#[test] +fn a_right_to_left_list_paragraph_converts_to_text_read_first() { + use vectorcraft_doc::ParaDirection; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + for (number, text, direction) in [ + ("arabic", "1.\tשלום\n2.\tעולם", None), + ("upperLetters", "A.\tשלום\nB.\tעולם", Some(ParaDirection::RightToLeft)), + ("upperRoman", "I.\tשלום\nII.\tעולם", Some(ParaDirection::RightToLeft)), + ] { + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "שלום\nעולם", "area": {"width": 200, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "number": number})).unwrap(); + // `drawn_glyphs` lists the first line first, from left to right. + let order = |s: &Session| { + let glyphs = drawn_glyphs(s, id); + let top = glyphs.first().map_or(0, |g| g.2); + glyphs.into_iter().filter(|g| g.2 == top).map(|g| g.0).collect::>() + }; + let before = order(&s); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.plain_text(), text); + assert!(t.para_at(0).list.is_none() && t.para_at(1).list.is_none()); + let directions = (t.para_at(0).direction, t.para_at(1).direction); + assert_eq!(order(&s), before, "{number}: the first line's glyphs from left to right"); + assert_eq!(directions, (direction, direction), "{number}"); + } +} + +/// Convert to Text in threaded type writes the numbers the story counted through its frames, +/// and both frames draw every glyph where it was. A paragraph that continues into the next +/// frame gets its number once, where it starts, also when only the later frame is named, and +/// empty frames after a converted paragraph leave its list with it. +#[test] +fn threaded_numbers_convert_to_text_as_the_story_counted_them() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let numbers = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let thread = |s: &mut Session, text: &str| { + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + (a, b) + }; + // Whole paragraphs: the first frame ends with a paragraph break. + let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); + let first = numbers(&s, a).len(); + assert!((2..10).contains(&first) && plain(&s, a).ends_with('\n'), "both frames hold items: {first}"); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + let reply = s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(reply["converted"], 10, "each paragraph once"); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); + let story = (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); + assert_eq!(plain(&s, a) + &plain(&s, b), story); + assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}", n = first + 1))); + // The empty part after the first frame's last break and the second frame's first item are + // one paragraph: a caret at the end of the first frame converts that item. + let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); + let end = plain(&s, a).len(); + let reply = s.execute("text.convertListToText", &json!({"ids": [a], "start": end, "end": end})).unwrap(); + assert_eq!(reply["converted"], 1); + assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}\nItem", n = first + 1)), "{}", plain(&s, b)); + // A paragraph split between the frames, with only the second frame named. + let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); + let (a, b) = thread(&mut s, &format!("One\nTwo\n{long}\nFour")); + assert!(!plain(&s, a).ends_with('\n'), "the third paragraph goes on in the second frame"); + // A paragraph with a tab is broken line by line, so the lines are compared with the + // Single-line Composer, which breaks the listed paragraph the same way. + s.execute("text.setFormat", &json!({"ids": [a, b], "composer": "singleLine"})).unwrap(); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames, with the paragraph split between them"); + assert_eq!(plain(&s, a) + &plain(&s, b), format!("One\nTwo\n3.\t{long}\n4.\tFour")); + assert_eq!(numbers(&s, a), ["1.", "2."], "the paragraphs of the first frame alone stay in the list"); + let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too"); + // One item that fits in the first of three frames: the two empty frames after it leave the + // list too, so the re-flowed thread draws no number before the converted one. + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b, c], "list": "number"})).unwrap(); + assert_eq!((plain(&s, b), plain(&s, c), numbers(&s, a)), (String::new(), String::new(), vec!["1.".to_string()])); + assert_eq!(s.execute("text.convertListToText", &json!({"ids": [a]})).unwrap()["converted"], 1); + assert_eq!(plain(&s, a), "1.\tItem"); + assert_eq!(numbers(&s, a), Vec::::new(), "no number drawn before the converted one"); + for id in [a, b, c] { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert!(t.para_at(0).list.is_none(), "frame {id} leaves the list"); + } + // An overset 30 pt number, wider than the hanging indent, in a last frame that starts + // inside a paragraph: its tab goes to the next stop, so it gets no stop at the left indent. + let a = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 30, "text": format!("{long}\nTwo"), "area": {"width": 150, "height": 80}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert!(second.first_para_continues && second.plain_text().ends_with("\nTwo"), "{:?}", second.plain_text()); + assert_eq!(numbers(&s, b).len(), 0, "the second item is overset"); + s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); + let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert!(second.plain_text().ends_with("\n2.\tTwo"), "{:?}", second.plain_text()); + assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs); +} + +/// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel) +/// moves the tool's anchor and caret past the characters it inserts at or before them, so that +/// neither lands inside a bullet. In threaded type, when the text before the caret moves to the +/// frame before, the caret goes with its character into that frame, which becomes the +/// selection. +#[test] +fn convert_to_text_moves_the_type_tools_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let selection = |s: &Session| { + let o = s.tool_options(); + (o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64()) + }; + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.select_tool("type", Default::default()).unwrap(); + // From after the O of One to after the T of Two. + s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5})); + s.execute("text.convertListToText", &json!({})).unwrap(); + let text = plain(&s, id); + assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo"); + assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T"); + assert!(text.is_char_boundary(5) && text.is_char_boundary(13)); + + let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); + let a = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "area": {"width": 150, "height": 60}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.select_tool("selection", Default::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.select_tool("type", Default::default()).unwrap(); + // The caret after the d of dog, at the start of the second frame. + s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1})); + s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); + let first = plain(&s, a); + assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}"); + let after_d = (first.len() - "og. ".len()) as u64; + assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame"); + assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); +} + +/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. +#[test] +fn create_outlines_makes_a_path_of_the_bullet() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let outlined = |s: &mut Session, list: bool| { + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); + if list { + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + } + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() + }; + let plain = outlined(&mut s, false); + assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); +} + +/// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and +/// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their +/// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They +/// are saved only when not the defaults, and paragraph styles carry them. +#[test] +fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() { + use vectorcraft_doc::{JustificationOptions, SingleWordJustify}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("text"), + }; + assert_eq!(paras(&s).0.justification, JustificationOptions::default()); + assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.setFormat", + &json!({ + "wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105], + "autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5 + }), + ) + .unwrap(); + let want = JustificationOptions { + word_spacing: [60.0, 90.0, 1000.0], + letter_spacing: [0.0, 0.0, 10.0], + glyph_scaling: [95.0, 100.0, 105.0], + auto_leading: 150.0, + single_word: SingleWordJustify::AlignCenter, + }; + let (first, second) = paras(&s); + assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in"); + assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter"); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); + assert_eq!(back.justification, want); + // Out of order: nothing changes, and the error names the setting. + let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string(); + assert!(e.contains("letterSpacing"), "{e}"); + assert_eq!(paras(&s).1.justification, want); + assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err()); + assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paras(&s).1.justification, JustificationOptions::default()); + // A paragraph style carries them; an older file's paragraph reads the defaults. + s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap(); + assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() }); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); + assert_eq!(old.justification, JustificationOptions::default()); +} + +/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts +/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing +/// carry them; params override; the values are journaled; imported text keeps the Roman +/// baseline. +#[test] +fn new_type_takes_the_japanese_defaults_while_the_interface_is_japanese() { + use vectorcraft_doc::{CharAlign, LeadingModel}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let made = |s: &mut Session, cmd: &str, p: Value| -> (LeadingModel, CharAlign) { + let id = s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap(); + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para.leading_model, t.runs[0].style.char_align), + _ => panic!("text"), + } + }; + let roman = (LeadingModel::RomanBaseline, CharAlign::RomanBaseline); + let japanese = (LeadingModel::EmBoxTop, CharAlign::EmBoxCenter); + let point = json!({"x": 10, "y": 50, "text": "雅楽"}); + assert!(!s.japanese_interface()); + assert_eq!(made(&mut s, "text.create", point.clone()), roman, "headless, `auto`: the Roman defaults"); + s.ui_language = Some("ja".into()); + assert_eq!(made(&mut s, "text.create", point.clone()), japanese); + assert_eq!(made(&mut s, "text.create", json!({"x": 10, "y": 90, "text": "笙", "area": {"width": 100, "height": 50}})), japanese); + // Journaled: a replay in another language does the same. + let (_, logged) = s.journal.last().unwrap().clone(); + assert_eq!((logged["leadingModel"].as_str(), logged["charAlign"].as_str()), (Some("emBoxTop"), Some("emBoxCenter"))); + // Params override. + assert_eq!( + made(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "a", "leadingModel": "romanBaseline", "charAlign": "romanBaseline"})), + roman + ); + assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "leadingModel": "middle"})).is_err()); + // Area type in a path. + let path = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 100, "height": 80})).unwrap()["id"].clone(); + assert_eq!(made(&mut s, "text.createInPath", json!({"path": path, "mode": "area", "text": "篳篥"})), japanese); + // A preference set to Japanese counts without a UI; another language doesn't. + s.ui_language = None; + s.prefs.interface_language = "ja".into(); + assert_eq!(made(&mut s, "text.create", point.clone()), japanese); + s.ui_language = Some("en".into()); + assert_eq!(made(&mut s, "text.create", point.clone()), roman); + // New styles made from nothing. + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.ui_language = Some("ja".into()); + let style = |s: &mut Session, kind: &str| { + let name = s.execute(&format!("{kind}.new"), &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + let list = s.execute(&format!("{kind}.list"), &json!({})).unwrap(); + list["styles"].as_array().unwrap().iter().find(|st| st["name"] == name.as_str()).unwrap()["attrs"].clone() + }; + assert_eq!(style(&mut s, "paraStyle"), json!({"leading_model": "emBoxTop"})); + assert_eq!(style(&mut s, "charStyle"), json!({"charAlign": "emBoxCenter"})); + s.ui_language = Some("en".into()); + assert_eq!(style(&mut s, "paraStyle"), json!({})); + // Imported text keeps the Roman baseline. + s.ui_language = Some("ja".into()); + let svg = r#"雅楽"#; + s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let mut found = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found.push((t.para.leading_model, t.runs[0].style.char_align)); + } + }); + assert_eq!(found, [roman]); +} + +#[test] +fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() { + use vectorcraft_doc::LeadingModel; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts. + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para.leading_model, LeadingModel::RomanBaseline); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"leadingModel": "middle"})).is_err()); + let before = text(&s).cached_bounds; + s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); + let t = text(&s); + assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); + // The fallback used without craft-fonts has no glyph metrics to move the first line. + if !vectorcraft_text::CRAFT_FONTS.is_empty() { + assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); + } + assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none()); +} + +#[test] +fn character_alignment_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::CharAlign; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charAlign").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"charAlign": "middle"})).is_err()); + s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_align == CharAlign::EmBoxCenter)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], "emBoxCenter"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "top"})).is_err()); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap(); + let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect(); + assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]); + // The ideographic character face's top and bottom. + for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] { + s.execute("text.setFormat", &json!({"charAlign": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_align == want)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key); + } +} + +/// Character Direction (Character panel menu, Middle Eastern options): a character attribute +/// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when +/// set and kept by a native save. +#[test] +fn character_direction_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::ParaDirection; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); + assert_eq!(runs(&s)[0].style.char_direction, None); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { + s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); + } + let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); + assert!(err.contains("`charDirection` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); + s.execute("edit.redo", &json!({})).unwrap(); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); + assert_eq!(reopened, set(&s)); + // A character style captured from the characters carries it, Default included. + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); +} + +/// Proportional Metrics (#966): a character attribute, saved only when on. +#[test] +fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.proportional_metrics); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.proportional_metrics); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); + assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); + // Styles written without it (documents from before it) read as off. + let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(old.get("proportionalMetrics").is_none()); + assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); +} + +/// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only +/// when on. Documents written before it read as off. +#[test] +fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "2026年"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.tate_chu_yoko); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("tateChuYoko").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"tateChuYoko": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.tate_chu_yoko)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["tateChuYoko"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.tate_chu_yoko); + // The year only (2026 is 4 bytes); 年 stays unmarked. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "tateChuYoko": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect(); + assert_eq!(on, [("2026".to_string(), true), ("年".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("tateChuYoko").is_none()); + assert!(!serde_json::from_value::(old).unwrap().tate_chu_yoko); + s.execute("text.setFormat", &json!({"tateChuYoko": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); +} + +/// No Break is a character attribute, saved only when on. +#[test] +fn no_break_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.no_break); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("noBreak").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"noBreak": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.no_break)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["noBreak"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.no_break); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "noBreak": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.no_break)).collect(); + assert_eq!(on, [("A".to_string(), true), ("b".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("noBreak").is_none()); + assert!(!serde_json::from_value::(old).unwrap().no_break); + s.execute("text.setFormat", &json!({"noBreak": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.no_break)); +} + +/// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only +/// when set; the text keeps the digits that were typed. +#[test] +fn digits_are_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::Digits; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "صفحة 12"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert_eq!(runs(&s)[0].style.digits, Digits::Default); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("digits").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("hindi", Digits::Hindi), ("farsi", Digits::Farsi), ("arabic", Digits::Arabic), ("default", Digits::Default)] { + s.execute("text.setFormat", &json!({"digits": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.digits == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("digits").and_then(|v| v.as_str()), (want != Digits::Default).then_some(key)); + // The text is as typed: only the glyphs change. + assert_eq!(runs(&s).iter().map(|r| r.text.as_str()).collect::(), "صفحة 12"); + } + s.execute("text.setFormat", &json!({"digits": "hindi"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s)[0].style.digits, Digits::Default); + let err = s.execute("text.setFormat", &json!({"digits": "roman"})).unwrap_err().to_string(); + assert!(err.contains("`digits` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (صفحة is 8 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 11, "digits": "farsi"})).unwrap(); + let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.digits)).collect(); + assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]); +} + +/// Variable Font settings (Character panel › Variable Font): a character attribute, saved only +/// when set, each value clamped to its axis's range, and cleared by another font or style. +#[test] +fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned(); + let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width(); + assert_eq!(saved(&s), None); + let regular = width(&s); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wght": 650.0}))); + assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s)); + // Past the axis's range: its maximum; an axis the font hasn't: left out; others kept. + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0}))); + // Null goes back to the style's own setting, and so does a value equal to it. + s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); + s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap(); + assert_eq!(saved(&s), None); + assert!((width(&s) - regular).abs() < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); + // Characters selected with the Type tool: only the range takes it. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap(); + let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect(); + let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::>(); + assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]); + // A saved file keeps them. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.runs, runs(&s)); + // Another style or font clears them: their settings were the old face's. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap(); + assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true))); + s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); + s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); + s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); + // A static font has no axes to set. + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); + assert_eq!(saved(&s), None); + // A missing font keeps them as given, for when it is installed. + s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap(); + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wght": 2000.0}))); + for (bad, says) in + [(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")] + { + let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string(); + assert!(err.contains(says), "{err}"); + } + // The axes and the setting of a style, as the sliders show them. + let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap(); + assert_eq!( + list["axes"], + json!([ + {"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false}, + {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, + ]) + ); + assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); +} + +#[test] +fn new_type_is_composed_with_line_end_half_width_punctuation() { + use vectorcraft_doc::Mojikumi; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err()); + s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + // Saved only when set; documents from before it read as None. + let json = serde_json::to_value(para(&s)).unwrap(); + assert_eq!(json["mojikumi"], "lineEndHalf"); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.mojikumi, Mojikumi::None); + assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none()); +} + +#[test] +fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap(); + let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + let wide = bounds(&s); + assert!(wide.width() > wide.height()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + let tall = bounds(&s); + assert!(tall.height() > tall.width(), "{tall:?}"); + assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(bounds(&s), wide); +} + +#[test] +fn area_options_columns_change_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let text = "word ".repeat(80); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1))); + let lay = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t), + _ => panic!(), + }; + assert_eq!(lay(&s).frames.len(), 1); + let r = s.execute("text.areaOptions", &json!({"columns": 3, "gutter": 12, "inset": 4, "firstBaseline": "capHeight"})).unwrap(); + assert_eq!(r["firstBaseline"], "capHeight"); + let l = lay(&s); + assert_eq!(l.frames.len(), 3, "three column cells"); + // Glyphs land in more than one column. + let xs: Vec = l.glyphs.iter().map(|g| g.origin.x).collect(); + assert!(xs.iter().any(|x| *x > 110.0 + 10.0) && xs.iter().any(|x| *x < 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(lay(&s).frames.len(), 1); + assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 0106e9895..be5fec64a 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -21,24 +21,14 @@ use super::fileio::{absolute_path, read_file_head, write_file}; use super::textedit::set_plain_text; use super::*; +mod csv; +mod library; + +use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +use library::load_library; + /// The longest variable or dataset name, in characters (names arrive from MCP and files). const MAX_NAME: usize = 255; -/// Cap on a graph variable's CSV (MCP and files are untrusted). -const MAX_GRAPH_CSV: usize = 256 * 1024; - -/// Caps on a datasets CSV (paths and MCP payloads are untrusted). -const MAX_CSV_BYTES: usize = 1_048_576; -const MAX_CSV_ROWS: usize = 1_000; -const MAX_CSV_COLS: usize = 256; - -/// Caps on a variable library (paths and MCP payloads are untrusted): its size, its XML nodes, how -/// deep its elements nest, and the entities, variables and data sets it declares. -const MAX_LIBRARY_BYTES: usize = 8 << 20; -const MAX_LIBRARY_ENTITIES: usize = 64; -const MAX_LIBRARY_NODES: u32 = 2_000_000; -const MAX_LIBRARY_NESTING: usize = 256; -const MAX_LIBRARY_VARIABLES: usize = 256; -const MAX_LIBRARY_DATASETS: usize = 1_000; /// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options), /// Make … Dynamic, an import whose header would define a variable and a Load Variable Library @@ -545,346 +535,6 @@ fn dataset_select(s: &mut Session, p: &Value) -> Result { apply_dataset(s, &var_name(p, "dataset.select")?) } -/// Bytes of a datasets CSV from `path`, `data`, `dataBase64` or `csv`. -fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result> { - let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64"), str_param(p, "csv")) { - // One byte past the cap is enough to refuse the file below. - (Some(path), ..) => read_file_head(&absolute_path(path), MAX_CSV_BYTES + 1)?, - (None, Some(text), ..) => text.as_bytes().to_vec(), - (None, None, Some(b64), _) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, - (None, None, None, Some(csv)) => csv.as_bytes().to_vec(), - _ => return Err(bad(cmd, "give `path`, `data`, `dataBase64` or `csv`")), - }; - if bytes.len() > MAX_CSV_BYTES { - return Err(bad(cmd, format!("CSV is larger than {MAX_CSV_BYTES} bytes"))); - } - Ok(bytes) -} - -/// A row of a datasets CSV: the file line it starts on, and its cells. -type CsvRow = (usize, Vec); - -/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that -/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside -/// quotes. -fn tab_separated(text: &str) -> bool { - // `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds - // anything but white space yet. - let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false); - let mut chars = text.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '"' if in_quotes => { - if chars.peek() == Some(&'"') { - chars.next(); - blank = false; - } else { - in_quotes = false; - } - } - '"' if start => in_quotes = true, - c if in_quotes => blank &= c.is_whitespace(), - ',' => return false, - '\t' => { - tab = true; - start = true; - continue; - } - '\r' => continue, - '\n' if blank => { - tab = false; - start = true; - continue; - } - '\n' => return tab, - c => blank &= c.is_whitespace(), - } - start = false; - } - tab -} - -/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated -/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside), -/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted -/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and -/// drops white space after the closing quote at the end of the cell. Blank lines are skipped. -fn parse_datasets_csv(text: &str, cmd: &str) -> Result> { - let text = text.strip_prefix('\u{feff}').unwrap_or(text); - let sep = if tab_separated(text) { '\t' } else { ',' }; - let (mut rows, mut row, mut cur) = (Vec::new(), Vec::::new(), String::new()); - // `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the - // current row starts on. - let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize); - let mut chars = text.chars().peekable(); - loop { - let c = chars.next(); - match c { - Some('"') if in_quotes => { - if chars.peek() == Some(&'"') { - cur.push('"'); - chars.next(); - } else { - in_quotes = false; - closed = Some(cur.len()); - } - } - Some('"') if cur.is_empty() => in_quotes = true, - Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), - Some('\r') if !in_quotes => {} - Some('\n') | None if !in_quotes => { - row.push(end_csv_cell(&mut cur, &mut closed)); - if row.iter().any(|c| !c.trim().is_empty()) { - rows.push((start, std::mem::take(&mut row))); - // +1 for the header. - if rows.len() > MAX_CSV_ROWS + 1 { - return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); - } - } - row.clear(); - if c.is_none() { - return Ok(rows); - } - line += 1; - start = line; - } - None => return Err(bad(cmd, format!("line {line}: unclosed quote"))), - Some(c) => { - line += usize::from(c == '\n'); - cur.push(c); - } - } - if row.len() > MAX_CSV_COLS { - return Err(bad(cmd, format!("line {line}: more than {MAX_CSV_COLS} columns"))); - } - } -} - -/// Take the cell read so far out of `cur`. Without a closing quote (`closed` is `None`) the cell -/// is trimmed; with one, the text before it is kept as written and the text after it is -/// trimmed at the end. -fn end_csv_cell(cur: &mut String, closed: &mut Option) -> String { - let cell = std::mem::take(cur); - match closed.take() { - Some(at) => match cell.split_at_checked(at) { - Some((quoted, after)) => format!("{quoted}{}", after.trim_end()), - None => cell, - }, - None => cell.trim().to_string(), - } -} - -fn csv_escape(s: &str) -> String { - if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } -} - -/// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive). -fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result { - match s.trim().to_ascii_lowercase().as_str() { - "true" | "1" | "yes" => Ok(true), - "false" | "0" | "no" => Ok(false), - _ => Err(bad(cmd, format!("value for `{var}` must be true or false"))), - } -} - -fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result> { - // A text cell is not trimmed here (the parser trims unquoted cells); image, graph and - // visibility cells are trimmed. - let t = if kind == VariableKind::Text { cell } else { cell.trim() }; - if t.is_empty() { - return Ok(None); - } - Ok(Some(match kind { - VariableKind::Text => DataValue::Text(t.to_string()), - VariableKind::Image => DataValue::Image(t.to_string()), - VariableKind::Graph if t.len() <= MAX_GRAPH_CSV => DataValue::Graph(t.to_string()), - VariableKind::Graph => return Err(bad(cmd, format!("value for `{var}` is longer than {MAX_GRAPH_CSV} bytes"))), - VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, cmd)?), - })) -} - -fn value_to_cell(v: &DataValue) -> String { - match v { - DataValue::Text(t) | DataValue::Image(t) | DataValue::Graph(t) => t.clone(), - DataValue::Visible(b) => if *b { "true" } else { "false" }.to_string(), - } -} - -/// Build the CSV of every dataset (header `name` then variables in definition order). -fn datasets_to_csv(vars: &Variables) -> String { - let mut out = String::from("name"); - for v in &vars.variables { - out.push(','); - out.push_str(&csv_escape(&v.name)); - } - out.push('\n'); - for ds in &vars.datasets { - out.push_str(&csv_escape(&ds.name)); - for v in &vars.variables { - out.push(','); - if let Some(val) = ds.values.get(&v.name) { - out.push_str(&csv_escape(&value_to_cell(val))); - } - } - out.push('\n'); - } - out -} - -/// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names, -/// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables -/// that earlier columns define) names that variable. Otherwise `#` before the name makes it a -/// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text -/// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new -/// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused. -fn header_variable( - cell: &str, - column: usize, - kinds: &BTreeMap, - defined: &mut Vec, - cmd: &str, -) -> Result<(String, VariableKind)> { - let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind)); - if let Some(kind) = known(cell, defined) { - return Ok((cell.to_string(), kind)); - } - let (name, kind) = if let Some(rest) = cell.strip_prefix('#') { - (rest.trim(), VariableKind::Visibility) - } else if let Some(rest) = cell.strip_prefix('@') { - (rest.trim(), VariableKind::Image) - } else if cell.starts_with('%') { - return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read"))); - } else { - (cell, VariableKind::Text) - }; - if name.is_empty() { - return Err(bad(cmd, format!("column {column}: `{cell}` names no variable"))); - } - match known(name, defined) { - Some(k) if k == kind => Ok((name.to_string(), kind)), - Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))), - None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))), - None => { - defined.push(Variable { name: name.to_string(), kind }); - Ok((name.to_string(), kind)) - } - } -} - -/// Import datasets from CSV. A first header cell `name` makes the first column the dataset -/// names; the other headers name variables, as [`header_variable`] reads them, and the -/// variables the document doesn't define yet are defined, unbound. Without the `name` column, -/// every header names a variable and the rows take the first free names "Data Set 1", "Data -/// Set 2" and so on. Existing rows of the same name are replaced; new names are appended. One -/// undo step. -fn dataset_import(s: &mut Session, p: &Value) -> Result { - const C: &str = "dataset.import"; - let bytes = datasets_csv_bytes(p, C)?; - let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?; - let rows = parse_datasets_csv(text, C)?; - let Some((_, header)) = rows.first() else { - return Err(bad(C, "CSV is empty")); - }; - let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name")); - let (kinds, existing_names) = { - let st = s.doc()?; - let kinds: BTreeMap = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect(); - let existing: BTreeSet = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); - (kinds, existing) - }; - let mut columns: Vec<(usize, String, VariableKind)> = Vec::new(); - let mut defined: Vec = Vec::new(); - for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) { - let cell = h.trim(); - if cell.is_empty() { - return Err(bad(C, format!("empty header in column {}", i + 1))); - } - let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?; - columns.push((i, name, kind)); - } - // Lock Variables refuses New Variable, so the import is refused when a header would define one. - if !defined.is_empty() { - variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; - } - if rows.len() == 1 { - return Err(bad(C, "CSV has a header but no data rows")); - } - let mut parsed: Vec = Vec::new(); - // The names of the rows read so far, and the last number given to a row without a name. - let (mut seen, mut number) = (BTreeSet::new(), 0usize); - for &(line, ref row) in rows.iter().skip(1) { - let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" }; - let name = if raw_name.is_empty() { - // Same numbering as Capture Data Set when a row leaves the name blank or the file has - // no name column. The search starts after the last number given. - let (n, name) = (number + 1..) - .map(|n| (n, format!("Data Set {n}"))) - .find(|(_, n)| !seen.contains(n) && !existing_names.contains(n)) - .unwrap_or_else(|| (line, format!("Data Set {line}"))); - number = n; - name - } else if raw_name.chars().count() > MAX_NAME { - return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters"))); - } else { - raw_name.to_string() - }; - if !seen.insert(name.clone()) { - return Err(bad(C, format!("line {line}: dataset `{name}` appears twice"))); - } - let mut values = BTreeMap::new(); - for &(col, ref var, kind) in &columns { - let cell = row.get(col).map(String::as_str).unwrap_or(""); - match cell_to_value(kind, cell, var, C) { - Ok(Some(v)) => { - values.insert(var.clone(), v); - } - Ok(None) => {} - Err(_) => return Err(bad(C, format!("line {line}: value for `{var}` must match its variable kind"))), - } - } - parsed.push(DataSet { name, values }); - } - let names: Vec = parsed.iter().map(|d| d.name.clone()).collect(); - let new_variables: Vec = defined.iter().map(|v| v.name.clone()).collect(); - let (imported, updated) = s.edit("Import Data Sets", |d, _| { - d.variables.variables.extend(defined); - let mut imported = 0usize; - let mut updated = 0usize; - for row in parsed { - if let Some(existing) = d.variables.datasets.iter_mut().find(|x| x.name == row.name) { - *existing = row; - updated += 1; - } else { - d.variables.datasets.push(row); - imported += 1; - } - } - Ok((imported, updated)) - })?; - Ok(json!({"imported": imported, "updated": updated, "names": names, "defined": new_variables})) -} - -/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`. -fn dataset_export(s: &mut Session, p: &Value) -> Result { - const C: &str = "dataset.export"; - let st = s.doc()?; - if st.doc.variables.variables.is_empty() { - return Err(bad(C, "define a variable first")); - } - let csv = datasets_to_csv(&st.doc.variables); - if csv.len() > MAX_CSV_BYTES { - return Err(bad(C, format!("CSV would be larger than {MAX_CSV_BYTES} bytes"))); - } - match str_param(p, "path") { - Some(path) => { - let path = absolute_path(path); - write_file(&path, csv.as_bytes())?; - Ok(json!({"path": path, "datasets": st.doc.variables.datasets.len()})) - } - None => Ok(json!({"csv": csv, "datasets": st.doc.variables.datasets.len()})), - } -} - fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { let st = s.doc()?; let n = st.doc.variables.datasets.len(); @@ -902,289 +552,6 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { apply_dataset(s, &name) } -/// Bytes of a variable library from `path`, `xml` or `dataBase64`. -fn library_bytes(p: &Value, cmd: &str) -> Result> { - let bytes = match (str_param(p, "path"), str_param(p, "xml"), str_param(p, "dataBase64")) { - // One byte past the cap is enough to refuse the file below. - (Some(path), ..) => read_file_head(&absolute_path(path), MAX_LIBRARY_BYTES + 1)?, - (None, Some(xml), _) => xml.as_bytes().to_vec(), - (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, - _ => return Err(bad(cmd, "give `path`, `xml` or `dataBase64`")), - }; - if bytes.len() > MAX_LIBRARY_BYTES { - return Err(bad(cmd, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); - } - Ok(bytes) -} - -/// `xml` without the document type declaration of its prolog, with each reference to a general -/// entity that the declaration defines replaced by the entity's value, its quotes and `>` escaped, -/// outside CDATA sections, comments and processing instructions. The declaration may hold an -/// external identifier and an internal subset of white space, comments, processing instructions and -/// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`] -/// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is -/// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses -/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the -/// elements the parser reads. -fn without_doctype(xml: &str, cmd: &str) -> Result { - let b = xml.as_bytes(); - let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len()); - let spaces = |from: usize| from + b.get(from..).map_or(0, |rest| rest.iter().take_while(|c| c.is_ascii_whitespace()).count()); - let at = |i: usize, s: &str| xml.get(i..).is_some_and(|rest| rest.starts_with(s)); - let refused = || bad(cmd, "the variable library's document type declaration holds something other than entities of plain text"); - // The prolog: an XML declaration, white space, comments and processing instructions. - let mut i = 0; - loop { - i = spaces(i); - if at(i, "").ok_or_else(refused)?; - } else if at(i, "").ok_or_else(refused)?; - } else { - break; - } - } - if !at(i, " Option<(usize, &str)> { - let quote = *b.get(i).filter(|c| matches!(c, b'"' | b'\''))?; - let len = b.get(i + 1..)?.iter().position(|c| *c == quote)?; - Some((i + len + 2, xml.get(i + 1..i + 1 + len)?)) - }; - // `' - i = spaces(i + "')).count()); - i = spaces(i); - for (id, literals) in [("SYSTEM", 1), ("PUBLIC", 2)] { - if at(i, id) { - i += id.len(); - for _ in 0..literals { - i = literal(spaces(i)).ok_or_else(refused)?.0; - } - i = spaces(i); - } - } - let mut entities: Vec<(&str, String)> = Vec::new(); - if at(i, "[") { - i += 1; - loop { - i = spaces(i); - if at(i, "]") { - i = spaces(i + 1); - break; - } else if at(i, "").ok_or_else(refused)?; - } else if at(i, "").ok_or_else(refused)?; - } else if at(i, "')).count()); - let name = xml.get(i..i + len).filter(|n| !n.is_empty() && n.len() <= MAX_NAME && !n.starts_with('%')).ok_or_else(refused)?; - let (end, value) = literal(spaces(i + len)).ok_or_else(refused)?; - if value.contains(['<', '&', '%']) { - return Err(refused()); - } - i = spaces(end); - if !at(i, ">") { - return Err(refused()); - } - i += 1; - if entities.len() == MAX_LIBRARY_ENTITIES { - return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_ENTITIES} entities"))); - } - entities.push((name, value.replace('"', """).replace('\'', "'").replace('>', ">"))); - } else { - return Err(refused()); - } - } - } - if !at(i, ">") { - return Err(refused()); - } - let mut out = String::with_capacity(xml.len()); - out.push_str(xml.get(..start).unwrap_or_default()); - // The rest, copied with the references replaced outside CDATA sections, comments and - // processing instructions, where the parser reads none. - let mut i = i + 1; - while let Some(c) = xml.get(i..).and_then(|rest| rest.chars().next()) { - let verbatim = [(""), (""), ("")].into_iter().find(|(open, _)| at(i, open)); - if let Some((open, close)) = verbatim { - let end = past(i + open.len(), close).unwrap_or(xml.len()); - out.push_str(xml.get(i..end).unwrap_or_default()); - i = end; - continue; - } - // Entity names are at most `MAX_NAME` bytes long, so the `;` is looked for that far. - let entity = if c == '&' { - let name = xml.get(i + 1..).and_then(|rest| rest.get(..rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?)); - name.and_then(|name| entities.iter().find(|(n, _)| *n == name)) - } else { - None - }; - match entity { - Some((name, value)) => { - out.push_str(value); - i += name.len() + 2; - if out.len() > MAX_LIBRARY_BYTES { - return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); - } - } - None => { - out.push(c); - i += c.len_utf8(); - } - } - } - if out.len() > MAX_LIBRARY_BYTES { - return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); - } - Ok(out) -} - -/// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { - text: Vec, - other: Vec<(String, String)>, - datasets: Vec, -} - -/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each -/// with a `variables` element of `variable` elements (`varName`, `trait`) and a `sampleDataSets` -/// element of `sampleDataSet` elements (`dataSetName`). Elements are matched by their local names. -/// A variable whose `trait` is `textcontent` is a text variable. In a data set, an element named -/// after a text variable gives its value: the text of its `p` elements, one paragraph each, and -/// none when it has no `p` element. Values for variables of other kinds are left out. A variable -/// or data set declared twice, a value for a variable the library doesn't declare, and a library -/// past the caps are refused. Entities that its document type declaration defines are read as -/// [`without_doctype`] says. -fn parse_library(xml: &str, cmd: &str) -> Result { - let xml = without_doctype(xml, cmd)?; - // roxmltree parses nested elements recursively. - if super::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { - return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); - } - let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; - let doc = - roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; - let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { - return Err(bad(cmd, "the file holds no `variableSets` element")); - }; - let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); - for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { - let name = named(var.attribute("varName"), "varName", cmd)?; - if !declared.insert(name.clone()) { - return Err(bad(cmd, format!("variable `{name}` is declared twice"))); - } - if declared.len() > MAX_LIBRARY_VARIABLES { - return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); - } - match var.attribute("trait") { - Some("textcontent") => text.push(name), - Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), - None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), - } - } - if declared.is_empty() { - return Err(bad(cmd, "the variable library declares no variables")); - } - let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); - let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); - for row in - sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) - { - let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; - if !seen.insert(name.clone()) { - return Err(bad(cmd, format!("data set `{name}` is declared twice"))); - } - if seen.len() > MAX_LIBRARY_DATASETS { - return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); - } - let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); - for el in row.children().filter(roxmltree::Node::is_element) { - let var = el.tag_name().name(); - if !declared.contains(var) { - return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); - } - if !held.insert(var) { - return Err(bad(cmd, format!("data set `{name}` holds two values for `{var}`"))); - } - if !text_names.contains(var) { - continue; - } - let paras: Vec = el - .children() - .filter(|n| is(n, "p")) - .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) - .collect(); - if !paras.is_empty() { - values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); - } - } - datasets.push(DataSet { name, values }); - } - Ok(Library { text, other, datasets }) -} - -/// Load Variable Library: replace the document's variables and data sets with a variable library's -/// ([`parse_library`]) in one undo step. Every variable of the document is deleted with its bindings -/// and values, and every data set is deleted. The library's text variables are then defined, -/// unbound, and its data sets are added. The library's variables of other kinds are left out, named -/// in `skipped` and in a warning. -fn load_library(s: &mut Session, p: &Value) -> Result { - const C: &str = "variable.loadLibrary"; - let bytes = library_bytes(p, C)?; - let xml = std::str::from_utf8(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).map_err(|_| bad(C, "the variable library is not UTF-8"))?; - let lib = parse_library(xml, C)?; - let vars = &s.doc()?.doc.variables; - let deleted: Vec = vars.variables.iter().map(|v| v.name.clone()).collect(); - let deleted_sets: Vec = vars.datasets.iter().map(|d| d.name.clone()).collect(); - // Lock Variables refuses New Variable and Delete Variable, so the load is refused when it would - // delete or define a variable. - if !deleted.is_empty() || !lib.text.is_empty() { - variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; - } - let added: Vec = lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(); - let names: Vec = lib.datasets.iter().map(|d| d.name.clone()).collect(); - s.edit("Load Variable Library", |d, _| { - d.variables.prune(&deleted); - d.variables.variables.extend(added); - d.variables.datasets = lib.datasets; - d.variables.active_dataset = None; - Ok(()) - })?; - // A highlight of a variable that is gone highlights nothing. - let st = s.doc_mut()?; - if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) { - st.variables_highlight = None; - } - st.revision += 1; - let skipped: Vec = lib.other.iter().map(|(name, t)| json!({"name": name, "trait": t})).collect(); - let mut out = json!({ - "variables": lib.text, "deleted": deleted, "deletedDataSets": deleted_sets, "names": names, "skipped": skipped, - }); - // Each `trait` once, in the order the library first uses it, with how many variables have it. - let mut kinds: Vec<(&str, usize)> = Vec::new(); - for (_, t) in &lib.other { - match kinds.iter_mut().find(|(k, _)| k == t) { - Some((_, n)) => *n += 1, - None => kinds.push((t, 1)), - } - } - if !kinds.is_empty() { - let kinds = kinds.iter().map(|(k, n)| format!("{k} ({n})")).collect::>().join(", "); - out["warning"] = json!(format!("variables that aren't text were left out: {kinds}")); - } - Ok(out) -} - pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs new file mode 100644 index 000000000..6cb10284b --- /dev/null +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -0,0 +1,351 @@ +use super::*; + +/// Cap on a graph variable's CSV (MCP and files are untrusted). +pub(super) const MAX_GRAPH_CSV: usize = 256 * 1024; + +/// Caps on a datasets CSV (paths and MCP payloads are untrusted). +const MAX_CSV_BYTES: usize = 1_048_576; + +const MAX_CSV_ROWS: usize = 1_000; + +const MAX_CSV_COLS: usize = 256; + +/// Bytes of a datasets CSV from `path`, `data`, `dataBase64` or `csv`. +fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64"), str_param(p, "csv")) { + // One byte past the cap is enough to refuse the file below. + (Some(path), ..) => read_file_head(&absolute_path(path), MAX_CSV_BYTES + 1)?, + (None, Some(text), ..) => text.as_bytes().to_vec(), + (None, None, Some(b64), _) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, + (None, None, None, Some(csv)) => csv.as_bytes().to_vec(), + _ => return Err(bad(cmd, "give `path`, `data`, `dataBase64` or `csv`")), + }; + if bytes.len() > MAX_CSV_BYTES { + return Err(bad(cmd, format!("CSV is larger than {MAX_CSV_BYTES} bytes"))); + } + Ok(bytes) +} + +/// A row of a datasets CSV: the file line it starts on, and its cells. +type CsvRow = (usize, Vec); + +/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that +/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside +/// quotes. +fn tab_separated(text: &str) -> bool { + // `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds + // anything but white space yet. + let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if in_quotes => { + if chars.peek() == Some(&'"') { + chars.next(); + blank = false; + } else { + in_quotes = false; + } + } + '"' if start => in_quotes = true, + c if in_quotes => blank &= c.is_whitespace(), + ',' => return false, + '\t' => { + tab = true; + start = true; + continue; + } + '\r' => continue, + '\n' if blank => { + tab = false; + start = true; + continue; + } + '\n' => return tab, + c => blank &= c.is_whitespace(), + } + start = false; + } + tab +} + +/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated +/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside), +/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted +/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and +/// drops white space after the closing quote at the end of the cell. Blank lines are skipped. +fn parse_datasets_csv(text: &str, cmd: &str) -> Result> { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let sep = if tab_separated(text) { '\t' } else { ',' }; + let (mut rows, mut row, mut cur) = (Vec::new(), Vec::::new(), String::new()); + // `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the + // current row starts on. + let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize); + let mut chars = text.chars().peekable(); + loop { + let c = chars.next(); + match c { + Some('"') if in_quotes => { + if chars.peek() == Some(&'"') { + cur.push('"'); + chars.next(); + } else { + in_quotes = false; + closed = Some(cur.len()); + } + } + Some('"') if cur.is_empty() => in_quotes = true, + Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), + Some('\r') if !in_quotes => {} + Some('\n') | None if !in_quotes => { + row.push(end_csv_cell(&mut cur, &mut closed)); + if row.iter().any(|c| !c.trim().is_empty()) { + rows.push((start, std::mem::take(&mut row))); + // +1 for the header. + if rows.len() > MAX_CSV_ROWS + 1 { + return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); + } + } + row.clear(); + if c.is_none() { + return Ok(rows); + } + line += 1; + start = line; + } + None => return Err(bad(cmd, format!("line {line}: unclosed quote"))), + Some(c) => { + line += usize::from(c == '\n'); + cur.push(c); + } + } + if row.len() > MAX_CSV_COLS { + return Err(bad(cmd, format!("line {line}: more than {MAX_CSV_COLS} columns"))); + } + } +} + +/// Take the cell read so far out of `cur`. Without a closing quote (`closed` is `None`) the cell +/// is trimmed; with one, the text before it is kept as written and the text after it is +/// trimmed at the end. +fn end_csv_cell(cur: &mut String, closed: &mut Option) -> String { + let cell = std::mem::take(cur); + match closed.take() { + Some(at) => match cell.split_at_checked(at) { + Some((quoted, after)) => format!("{quoted}{}", after.trim_end()), + None => cell, + }, + None => cell.trim().to_string(), + } +} + +fn csv_escape(s: &str) -> String { + if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } +} + +/// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive). +fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(true), + "false" | "0" | "no" => Ok(false), + _ => Err(bad(cmd, format!("value for `{var}` must be true or false"))), + } +} + +fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result> { + // A text cell is not trimmed here (the parser trims unquoted cells); image, graph and + // visibility cells are trimmed. + let t = if kind == VariableKind::Text { cell } else { cell.trim() }; + if t.is_empty() { + return Ok(None); + } + Ok(Some(match kind { + VariableKind::Text => DataValue::Text(t.to_string()), + VariableKind::Image => DataValue::Image(t.to_string()), + VariableKind::Graph if t.len() <= MAX_GRAPH_CSV => DataValue::Graph(t.to_string()), + VariableKind::Graph => return Err(bad(cmd, format!("value for `{var}` is longer than {MAX_GRAPH_CSV} bytes"))), + VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, cmd)?), + })) +} + +fn value_to_cell(v: &DataValue) -> String { + match v { + DataValue::Text(t) | DataValue::Image(t) | DataValue::Graph(t) => t.clone(), + DataValue::Visible(b) => if *b { "true" } else { "false" }.to_string(), + } +} + +/// Build the CSV of every dataset (header `name` then variables in definition order). +fn datasets_to_csv(vars: &Variables) -> String { + let mut out = String::from("name"); + for v in &vars.variables { + out.push(','); + out.push_str(&csv_escape(&v.name)); + } + out.push('\n'); + for ds in &vars.datasets { + out.push_str(&csv_escape(&ds.name)); + for v in &vars.variables { + out.push(','); + if let Some(val) = ds.values.get(&v.name) { + out.push_str(&csv_escape(&value_to_cell(val))); + } + } + out.push('\n'); + } + out +} + +/// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names, +/// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables +/// that earlier columns define) names that variable. Otherwise `#` before the name makes it a +/// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text +/// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new +/// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused. +fn header_variable( + cell: &str, + column: usize, + kinds: &BTreeMap, + defined: &mut Vec, + cmd: &str, +) -> Result<(String, VariableKind)> { + let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind)); + if let Some(kind) = known(cell, defined) { + return Ok((cell.to_string(), kind)); + } + let (name, kind) = if let Some(rest) = cell.strip_prefix('#') { + (rest.trim(), VariableKind::Visibility) + } else if let Some(rest) = cell.strip_prefix('@') { + (rest.trim(), VariableKind::Image) + } else if cell.starts_with('%') { + return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read"))); + } else { + (cell, VariableKind::Text) + }; + if name.is_empty() { + return Err(bad(cmd, format!("column {column}: `{cell}` names no variable"))); + } + match known(name, defined) { + Some(k) if k == kind => Ok((name.to_string(), kind)), + Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))), + None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))), + None => { + defined.push(Variable { name: name.to_string(), kind }); + Ok((name.to_string(), kind)) + } + } +} + +/// Import datasets from CSV. A first header cell `name` makes the first column the dataset +/// names; the other headers name variables, as [`header_variable`] reads them, and the +/// variables the document doesn't define yet are defined, unbound. Without the `name` column, +/// every header names a variable and the rows take the first free names "Data Set 1", "Data +/// Set 2" and so on. Existing rows of the same name are replaced; new names are appended. One +/// undo step. +pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.import"; + let bytes = datasets_csv_bytes(p, C)?; + let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?; + let rows = parse_datasets_csv(text, C)?; + let Some((_, header)) = rows.first() else { + return Err(bad(C, "CSV is empty")); + }; + let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name")); + let (kinds, existing_names) = { + let st = s.doc()?; + let kinds: BTreeMap = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect(); + let existing: BTreeSet = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + (kinds, existing) + }; + let mut columns: Vec<(usize, String, VariableKind)> = Vec::new(); + let mut defined: Vec = Vec::new(); + for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) { + let cell = h.trim(); + if cell.is_empty() { + return Err(bad(C, format!("empty header in column {}", i + 1))); + } + let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?; + columns.push((i, name, kind)); + } + // Lock Variables refuses New Variable, so the import is refused when a header would define one. + if !defined.is_empty() { + variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; + } + if rows.len() == 1 { + return Err(bad(C, "CSV has a header but no data rows")); + } + let mut parsed: Vec = Vec::new(); + // The names of the rows read so far, and the last number given to a row without a name. + let (mut seen, mut number) = (BTreeSet::new(), 0usize); + for &(line, ref row) in rows.iter().skip(1) { + let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" }; + let name = if raw_name.is_empty() { + // Same numbering as Capture Data Set when a row leaves the name blank or the file has + // no name column. The search starts after the last number given. + let (n, name) = (number + 1..) + .map(|n| (n, format!("Data Set {n}"))) + .find(|(_, n)| !seen.contains(n) && !existing_names.contains(n)) + .unwrap_or_else(|| (line, format!("Data Set {line}"))); + number = n; + name + } else if raw_name.chars().count() > MAX_NAME { + return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters"))); + } else { + raw_name.to_string() + }; + if !seen.insert(name.clone()) { + return Err(bad(C, format!("line {line}: dataset `{name}` appears twice"))); + } + let mut values = BTreeMap::new(); + for &(col, ref var, kind) in &columns { + let cell = row.get(col).map(String::as_str).unwrap_or(""); + match cell_to_value(kind, cell, var, C) { + Ok(Some(v)) => { + values.insert(var.clone(), v); + } + Ok(None) => {} + Err(_) => return Err(bad(C, format!("line {line}: value for `{var}` must match its variable kind"))), + } + } + parsed.push(DataSet { name, values }); + } + let names: Vec = parsed.iter().map(|d| d.name.clone()).collect(); + let new_variables: Vec = defined.iter().map(|v| v.name.clone()).collect(); + let (imported, updated) = s.edit("Import Data Sets", |d, _| { + d.variables.variables.extend(defined); + let mut imported = 0usize; + let mut updated = 0usize; + for row in parsed { + if let Some(existing) = d.variables.datasets.iter_mut().find(|x| x.name == row.name) { + *existing = row; + updated += 1; + } else { + d.variables.datasets.push(row); + imported += 1; + } + } + Ok((imported, updated)) + })?; + Ok(json!({"imported": imported, "updated": updated, "names": names, "defined": new_variables})) +} + +/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`. +pub(super) fn dataset_export(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.export"; + let st = s.doc()?; + if st.doc.variables.variables.is_empty() { + return Err(bad(C, "define a variable first")); + } + let csv = datasets_to_csv(&st.doc.variables); + if csv.len() > MAX_CSV_BYTES { + return Err(bad(C, format!("CSV would be larger than {MAX_CSV_BYTES} bytes"))); + } + match str_param(p, "path") { + Some(path) => { + let path = absolute_path(path); + write_file(&path, csv.as_bytes())?; + Ok(json!({"path": path, "datasets": st.doc.variables.datasets.len()})) + } + None => Ok(json!({"csv": csv, "datasets": st.doc.variables.datasets.len()})), + } +} diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs new file mode 100644 index 000000000..83374a16d --- /dev/null +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -0,0 +1,298 @@ +use super::*; + +/// Caps on a variable library (paths and MCP payloads are untrusted): its size, its XML nodes, how +/// deep its elements nest, and the entities, variables and data sets it declares. +const MAX_LIBRARY_BYTES: usize = 8 << 20; + +const MAX_LIBRARY_ENTITIES: usize = 64; + +const MAX_LIBRARY_NODES: u32 = 2_000_000; + +const MAX_LIBRARY_NESTING: usize = 256; + +const MAX_LIBRARY_VARIABLES: usize = 256; + +const MAX_LIBRARY_DATASETS: usize = 1_000; + +/// Bytes of a variable library from `path`, `xml` or `dataBase64`. +fn library_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (str_param(p, "path"), str_param(p, "xml"), str_param(p, "dataBase64")) { + // One byte past the cap is enough to refuse the file below. + (Some(path), ..) => read_file_head(&absolute_path(path), MAX_LIBRARY_BYTES + 1)?, + (None, Some(xml), _) => xml.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, + _ => return Err(bad(cmd, "give `path`, `xml` or `dataBase64`")), + }; + if bytes.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + Ok(bytes) +} + +/// `xml` without the document type declaration of its prolog, with each reference to a general +/// entity that the declaration defines replaced by the entity's value, its quotes and `>` escaped, +/// outside CDATA sections, comments and processing instructions. The declaration may hold an +/// external identifier and an internal subset of white space, comments, processing instructions and +/// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`] +/// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is +/// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses +/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the +/// elements the parser reads. +fn without_doctype(xml: &str, cmd: &str) -> Result { + let b = xml.as_bytes(); + let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len()); + let spaces = |from: usize| from + b.get(from..).map_or(0, |rest| rest.iter().take_while(|c| c.is_ascii_whitespace()).count()); + let at = |i: usize, s: &str| xml.get(i..).is_some_and(|rest| rest.starts_with(s)); + let refused = || bad(cmd, "the variable library's document type declaration holds something other than entities of plain text"); + // The prolog: an XML declaration, white space, comments and processing instructions. + let mut i = 0; + loop { + i = spaces(i); + if at(i, "").ok_or_else(refused)?; + } else if at(i, "").ok_or_else(refused)?; + } else { + break; + } + } + if !at(i, " Option<(usize, &str)> { + let quote = *b.get(i).filter(|c| matches!(c, b'"' | b'\''))?; + let len = b.get(i + 1..)?.iter().position(|c| *c == quote)?; + Some((i + len + 2, xml.get(i + 1..i + 1 + len)?)) + }; + // `' + i = spaces(i + "')).count()); + i = spaces(i); + for (id, literals) in [("SYSTEM", 1), ("PUBLIC", 2)] { + if at(i, id) { + i += id.len(); + for _ in 0..literals { + i = literal(spaces(i)).ok_or_else(refused)?.0; + } + i = spaces(i); + } + } + let mut entities: Vec<(&str, String)> = Vec::new(); + if at(i, "[") { + i += 1; + loop { + i = spaces(i); + if at(i, "]") { + i = spaces(i + 1); + break; + } else if at(i, "").ok_or_else(refused)?; + } else if at(i, "").ok_or_else(refused)?; + } else if at(i, "')).count()); + let name = xml.get(i..i + len).filter(|n| !n.is_empty() && n.len() <= MAX_NAME && !n.starts_with('%')).ok_or_else(refused)?; + let (end, value) = literal(spaces(i + len)).ok_or_else(refused)?; + if value.contains(['<', '&', '%']) { + return Err(refused()); + } + i = spaces(end); + if !at(i, ">") { + return Err(refused()); + } + i += 1; + if entities.len() == MAX_LIBRARY_ENTITIES { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_ENTITIES} entities"))); + } + entities.push((name, value.replace('"', """).replace('\'', "'").replace('>', ">"))); + } else { + return Err(refused()); + } + } + } + if !at(i, ">") { + return Err(refused()); + } + let mut out = String::with_capacity(xml.len()); + out.push_str(xml.get(..start).unwrap_or_default()); + // The rest, copied with the references replaced outside CDATA sections, comments and + // processing instructions, where the parser reads none. + let mut i = i + 1; + while let Some(c) = xml.get(i..).and_then(|rest| rest.chars().next()) { + let verbatim = [(""), (""), ("")].into_iter().find(|(open, _)| at(i, open)); + if let Some((open, close)) = verbatim { + let end = past(i + open.len(), close).unwrap_or(xml.len()); + out.push_str(xml.get(i..end).unwrap_or_default()); + i = end; + continue; + } + // Entity names are at most `MAX_NAME` bytes long, so the `;` is looked for that far. + let entity = if c == '&' { + let name = xml.get(i + 1..).and_then(|rest| rest.get(..rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?)); + name.and_then(|name| entities.iter().find(|(n, _)| *n == name)) + } else { + None + }; + match entity { + Some((name, value)) => { + out.push_str(value); + i += name.len() + 2; + if out.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + } + None => { + out.push(c); + i += c.len_utf8(); + } + } + } + if out.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + Ok(out) +} + +/// A variable library as Load Variable Library reads it: the names of its text variables in the +/// order it declares them, the name and `trait` of each variable of another kind, and its data +/// sets with their text values. +struct Library { + text: Vec, + other: Vec<(String, String)>, + datasets: Vec, +} + +/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each +/// with a `variables` element of `variable` elements (`varName`, `trait`) and a `sampleDataSets` +/// element of `sampleDataSet` elements (`dataSetName`). Elements are matched by their local names. +/// A variable whose `trait` is `textcontent` is a text variable. In a data set, an element named +/// after a text variable gives its value: the text of its `p` elements, one paragraph each, and +/// none when it has no `p` element. Values for variables of other kinds are left out. A variable +/// or data set declared twice, a value for a variable the library doesn't declare, and a library +/// past the caps are refused. Entities that its document type declaration defines are read as +/// [`without_doctype`] says. +fn parse_library(xml: &str, cmd: &str) -> Result { + let xml = without_doctype(xml, cmd)?; + // roxmltree parses nested elements recursively. + if crate::cmd::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { + return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; + let doc = + roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { + return Err(bad(cmd, "the file holds no `variableSets` element")); + }; + let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); + let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { + let name = named(var.attribute("varName"), "varName", cmd)?; + if !declared.insert(name.clone()) { + return Err(bad(cmd, format!("variable `{name}` is declared twice"))); + } + if declared.len() > MAX_LIBRARY_VARIABLES { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); + } + match var.attribute("trait") { + Some("textcontent") => text.push(name), + Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), + None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), + } + } + if declared.is_empty() { + return Err(bad(cmd, "the variable library declares no variables")); + } + let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); + let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); + for row in + sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) + { + let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; + if !seen.insert(name.clone()) { + return Err(bad(cmd, format!("data set `{name}` is declared twice"))); + } + if seen.len() > MAX_LIBRARY_DATASETS { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); + } + let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); + for el in row.children().filter(roxmltree::Node::is_element) { + let var = el.tag_name().name(); + if !declared.contains(var) { + return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); + } + if !held.insert(var) { + return Err(bad(cmd, format!("data set `{name}` holds two values for `{var}`"))); + } + if !text_names.contains(var) { + continue; + } + let paras: Vec = el + .children() + .filter(|n| is(n, "p")) + .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) + .collect(); + if !paras.is_empty() { + values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); + } + } + datasets.push(DataSet { name, values }); + } + Ok(Library { text, other, datasets }) +} + +/// Load Variable Library: replace the document's variables and data sets with a variable library's +/// ([`parse_library`]) in one undo step. Every variable of the document is deleted with its bindings +/// and values, and every data set is deleted. The library's text variables are then defined, +/// unbound, and its data sets are added. The library's variables of other kinds are left out, named +/// in `skipped` and in a warning. +pub(super) fn load_library(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.loadLibrary"; + let bytes = library_bytes(p, C)?; + let xml = std::str::from_utf8(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).map_err(|_| bad(C, "the variable library is not UTF-8"))?; + let lib = parse_library(xml, C)?; + let vars = &s.doc()?.doc.variables; + let deleted: Vec = vars.variables.iter().map(|v| v.name.clone()).collect(); + let deleted_sets: Vec = vars.datasets.iter().map(|d| d.name.clone()).collect(); + // Lock Variables refuses New Variable and Delete Variable, so the load is refused when it would + // delete or define a variable. + if !deleted.is_empty() || !lib.text.is_empty() { + variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; + } + let added: Vec = lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(); + let names: Vec = lib.datasets.iter().map(|d| d.name.clone()).collect(); + s.edit("Load Variable Library", |d, _| { + d.variables.prune(&deleted); + d.variables.variables.extend(added); + d.variables.datasets = lib.datasets; + d.variables.active_dataset = None; + Ok(()) + })?; + // A highlight of a variable that is gone highlights nothing. + let st = s.doc_mut()?; + if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) { + st.variables_highlight = None; + } + st.revision += 1; + let skipped: Vec = lib.other.iter().map(|(name, t)| json!({"name": name, "trait": t})).collect(); + let mut out = json!({ + "variables": lib.text, "deleted": deleted, "deletedDataSets": deleted_sets, "names": names, "skipped": skipped, + }); + // Each `trait` once, in the order the library first uses it, with how many variables have it. + let mut kinds: Vec<(&str, usize)> = Vec::new(); + for (_, t) in &lib.other { + match kinds.iter_mut().find(|(k, _)| k == t) { + Some((_, n)) => *n += 1, + None => kinds.push((t, 1)), + } + } + if !kinds.is_empty() { + let kinds = kinds.iter().map(|(k, n)| format!("{k} ({n})")).collect::>().join(", "); + out["warning"] = json!(format!("variables that aren't text were left out: {kinds}")); + } + Ok(out) +} diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs new file mode 100644 index 000000000..8f3f35528 --- /dev/null +++ b/crates/engine/src/command_sets.rs @@ -0,0 +1,115 @@ +use super::*; + +/// The commands that act on the active artboard (#693), which the app passes as their `artboard`: +/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute +/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, and All on +/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their +/// steps that names none. +pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [ + "edit.pasteInPlace", + "edit.pasteInFront", + "edit.pasteInBack", + "object.align", + "object.distribute", + "object.distributeSpacing", + "select.allOnArtboard", +]; + +/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the +/// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to +/// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to +/// each of their steps that names none. +pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"]; + +/// `(command, param)` pairs the journal records only so a replay reproduces the original run +/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the +/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves +/// them out ([`Session::journal_for_action`]). +const REPLAY_ONLY: [(&str, &str); 14] = [ + ("file.new", "created"), + ("document.save", "modified"), + ("file.saveAs", "modified"), + ("edit.pasteInPlace", "artboard"), + ("edit.pasteInFront", "artboard"), + ("edit.pasteInBack", "artboard"), + ("object.align", "artboard"), + ("object.distribute", "artboard"), + ("object.distributeSpacing", "artboard"), + ("artboard.fitToArt", "index"), + ("artboard.fitToArt", "indices"), + ("artboard.fitToSelection", "index"), + ("select.allOnArtboard", "artboard"), + ("command.batch", "artboard"), +]; + +/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps'). +pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { + let strip = |id: &str, p: &mut Value| { + if let Value::Object(m) = p { + m.retain(|k, _| !REPLAY_ONLY.iter().any(|&(c, key)| c == id && key == k)); + } + }; + strip(id, p); + // Batches don't nest: one level of steps. + if id == "command.batch" + && let Some(Value::Array(steps)) = p.get_mut("commands") + { + for step in steps { + let id = step.get("command").and_then(Value::as_str).unwrap_or_default().to_string(); + if let Some(p) = step.get_mut("params") { + strip(&id, p); + } + } + } +} + +/// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with +/// its checks), then running as on the selection: the result and the selection afterwards are the +/// menu command's (#2130). +pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ + "edit.duplicate", + "object.arrange.bringForward", + "object.arrange.bringToFront", + "object.arrange.sendBackward", + "object.arrange.sendToBack", + "object.arrange.sendToCurrentLayer", + "object.clippingMask.make", + "object.clippingMask.release", + "object.compoundPath.make", + "object.compoundPath.release", + "object.group", + "object.hide", + "object.lock", + "object.move", + "object.pathfinder.crop", + "object.pathfinder.divide", + "object.pathfinder.exclude", + "object.pathfinder.intersect", + "object.pathfinder.merge", + "object.pathfinder.minusBack", + "object.pathfinder.minusFront", + "object.pathfinder.outline", + "object.pathfinder.trap", + "object.pathfinder.trim", + "object.pathfinder.unite", + "object.reflect", + "object.rotate", + "object.scale", + "object.shear", + "object.ungroup", +]; + +/// The non-empty `ids` given to a command whose params description names `ids` as a key (the +/// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of +/// one when no `ids` are given and the description takes `id` so +/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. +pub(crate) fn given_ids(description: &'static str, params: &Value) -> Option> { + if !cmd::params::takes_key(description, "ids") { + return None; + } + match params.get("ids").filter(|v| !v.is_null()) { + Some(_) => cmd::ids_param(params, "ids"), + None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]), + } + .filter(|ids| !ids.is_empty()) +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs new file mode 100644 index 000000000..403d771d7 --- /dev/null +++ b/crates/engine/src/doc_state.rs @@ -0,0 +1,465 @@ +use super::error::{EngineError, Result}; +use super::history::{History, HistoryEntry}; +use super::interaction::{Interaction, LastTransform, UndoGroup}; +use super::*; + +/// Per-document editing state. +#[derive(Clone, Debug)] +pub struct DocState { + pub doc: Arc, + pub selection: Selection, + pub history: History, + pub path: Option, + /// Increments on every change (also selection and view-only changes); UIs re-render when it + /// moves. Not a dirty flag: see [`DocState::is_dirty`]. + pub revision: u64, + /// The document as last saved (or opened). Structural sharing makes "unchanged" a pointer + /// comparison, and undoing back to the saved state counts as clean. + pub(crate) saved_doc: Arc, + /// The documents that changes that are not edits to save replaced ([`DocState::same_content`]). + unsaved_changes: SameContent, + /// The layer new art goes into (the "current layer" in the Layers panel). + pub active_layer: Option, + /// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order + /// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on. + /// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`, + /// `layer.highlight`). + pub layer_rows: Vec, + /// The variable highlighted in the Variables panel: what its Delete, Options… and Select + /// Bound Object act on. Panel state, not art selection: not saved, not undoable + /// (`variable.highlight`). A name, so renaming a variable moves the highlight with it. + pub variables_highlight: Option, + /// Isolation mode container. + pub isolation: Option, + /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape + /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. + pub global_edit: Option, + pub interaction: Option, + /// Edits made while this is open are one undo step (a scrubbed numeric field). + pub undo_group: Option, + /// For Object → Transform → Transform Again (⌘D): what the last transformation was. A + /// transform command keeps the `copy`, `typeAreas` and `patterns` it used, and `strokes` and + /// `corners` when it scales; a drag keeps the params of its last preview with the values that + /// preview noted ([`Session::note_journal`]). + pub last_transform: Option, + /// What Select → Reselect repeats: the last selection command, its params and the objects + /// selected when it ran (a Same command's reference objects). + pub last_selection_cmd: Option<(String, Value, Vec)>, + /// The saved selection last recalled (`select.recall`), which Select → Update Selection + /// changes. It is not saved with the file, and a recall is not an undo step. + pub recalled_selection: RecalledSelection, + /// Process-unique id of this open document (tab indices shift when tabs close). + pub uid: u64, + /// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas + /// shows alone, in greyscale, while that mask is edited (see [`DocState::shown_mask`]). + pub mask_view: Option, + /// View → Show Transparency Grid, per document (saved view state, not undoable). + pub transparency_grid: bool, + /// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened + /// from or last saved as. + pub format: &'static str, + /// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF; + /// empty for native files): Save reuses them and `file.formatOptions` reads them back. + pub save_options: serde_json::Map, + /// The ids of the document's artboards, in order, when `save_options` were stored: the + /// artboards their `artboard`, `artboards` and `range` named by number then + /// ([`cmd::fileio::remembered_save_options`]). + pub save_artboards: Vec, + /// Opened from an older native file (former name or format version): the title says + /// "[Converted]" and Save asks for a new name instead of overwriting it. + pub converted: bool, + /// The view saved into native files (`Document::last_view`); the UI keeps it current before a + /// save and restores it when the document opens. + pub view: Option, + /// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it + /// (suggesting `path`, the file it was copied from) instead of overwriting that file. + pub recovered: bool, + /// The document's Data Recovery copy, once one was written ([`cmd::recovery`]). + pub recovery: Option, + /// View → Show Print Tiling, per document (view state: not saved, not undoable). + pub print_tiling: bool, + /// The rows open in the Layers panel (view state: not undoable; native files keep it). + pub layers_open: OpenRows, + /// The file this document was read from, and what reading it left out (hidden text, art or + /// layers it could not read): writing the document over that file would lose those for good, + /// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved. + pub imported_from: Option, + pub import_losses: Vec, + /// Transform Again after a perspective move or scale (Perspective Selection tool): the + /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. + pub last_perspective: Option, + /// The folders a confined (automation) caller that opened or made the document may read: the + /// paths the document links to are looked at within them later too, when the Links panel + /// asks about a link outside any caller's scope. + pub opened_by: Option>, +} + +static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold. +/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only +/// its top-level layers open (a `.ai` another app wrote opens with none open). +#[derive(Clone, Debug, Default)] +pub struct OpenRows { + ids: std::collections::HashSet, + /// Counts the changes, so views can keep what they work out from the open rows. + generation: u64, +} + +impl OpenRows { + /// The rows `saved` in a file, else `doc`'s top-level layers. + fn new(doc: &Document, saved: Option>) -> Self { + let ids = match saved { + Some(ids) => ids.into_iter().collect(), + None => doc.layers.iter().map(|l| l.id).collect(), + }; + Self { ids, generation: 0 } + } + pub fn contains(&self, id: NodeId) -> bool { + self.ids.contains(&id) + } + /// Open or close row `id`. + pub fn set(&mut self, id: NodeId, open: bool) { + let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) }; + self.generation += u64::from(changed); + } + /// Changes so far: the same number means the same open rows. + pub fn generation(&self) -> u64 { + self.generation + } + /// What a native file keeps of the open rows: those of `doc` that hold others, in id order; + /// `None` when they are the default (only the top-level layers). + pub fn saved(&self, doc: &Document) -> Option> { + let mut ids = vec![]; + doc.walk(|n| { + if n.children().is_some() && self.ids.contains(&n.id) { + ids.push(n.id); + } + }); + ids.sort_unstable(); + let mut layers: Vec = doc.layers.iter().map(|l| l.id).collect(); + layers.sort_unstable(); + (ids != layers).then_some(ids) + } +} + +/// The saved selection last recalled, which Select → Update Selection changes. The Select commands +/// give each saved selection an id for the session and keep the ids for every version of the +/// document's saved selections they make or read, so the recalled one is found again after Edit +/// Selection…, Undo and Redo. A version that nothing holds any more (neither the document nor its +/// undo history) is dropped the next time a version is added. +#[derive(Clone, Debug, Default)] +pub struct RecalledSelection { + /// The recalled saved selection's id. + id: Option, + /// Each version of the saved selections (identified by its allocation), with the id of each saved + /// selection in it. + versions: Vec<(std::sync::Weak>, Vec)>, + /// The last id given. + last: u64, +} + +impl RecalledSelection { + /// The ids of `list`'s saved selections; a version not met before gets new ones. + pub(crate) fn ids(&mut self, list: &vectorcraft_doc::Shared>) -> Vec { + let version = vectorcraft_doc::Shared::downgrade(list); + if let Some((_, ids)) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version)) { + return ids.clone(); + } + let ids: Vec = (0..list.len()).map(|_| self.new_id()).collect(); + self.remember(list, ids.clone()); + ids + } + /// An id no saved selection has had. + pub(crate) fn new_id(&mut self) -> u64 { + self.last += 1; + self.last + } + /// Keep `ids` as those of `list`'s saved selections, dropping versions nothing holds any more. + pub(crate) fn remember(&mut self, list: &vectorcraft_doc::Shared>, ids: Vec) { + let version = vectorcraft_doc::Shared::downgrade(list); + self.versions.retain(|(v, _)| v.strong_count() > 0 && !v.ptr_eq(&version)); + self.versions.push((version, ids)); + } + /// Recall the saved selection at `index` in `list`. + pub(crate) fn recall(&mut self, list: &vectorcraft_doc::Shared>, index: usize) { + self.id = self.ids(list).get(index).copied(); + } + /// Where the recalled saved selection is in `list`, if `list` holds it. + pub fn index_in(&self, list: &vectorcraft_doc::Shared>) -> Option { + let id = self.id?; + let version = vectorcraft_doc::Shared::downgrade(list); + let (_, ids) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version))?; + ids.iter().position(|&x| x == id).filter(|&i| i < list.len()) + } +} + +/// The documents that changes that are not edits to save (Puppet Warp pins dropped, Edit Text ended, +/// type laid out again after a font scan) replaced, each counted as having the content of the +/// document it was replaced by ([`DocState::change_unsaved`]). A document that nothing holds any +/// more is dropped the next time one is added. +#[derive(Clone, Debug, Default)] +struct SameContent { + /// The document the last such change made. + now: std::sync::Weak, + /// The documents with its content that such changes replaced, oldest first. + before: Vec>, +} + +impl SameContent { + /// Note that `now` replaced `before` without a change to its content. + fn replaced(&mut self, before: std::sync::Weak, now: &Arc) { + if !self.now.ptr_eq(&before) { + self.before.clear(); + } + self.before.retain(|d| d.strong_count() > 0); + if before.strong_count() > 0 { + self.before.push(before); + } + self.now = Arc::downgrade(now); + } + /// Whether `doc` is one of the documents with the content of `now`. + fn holds(&self, now: &Arc, doc: &Arc) -> bool { + std::ptr::eq(self.now.as_ptr(), Arc::as_ptr(now)) && self.before.iter().any(|d| std::ptr::eq(d.as_ptr(), Arc::as_ptr(doc))) + } +} + +impl DocState { + pub fn new(mut doc: Document, path: Option) -> Self { + let active_layer = doc.default_layer(); + // Saved view state lives here while the document is open (saves write it back). + let view = doc.last_view.take(); + let transparency_grid = std::mem::take(&mut doc.transparency_grid); + let saved_open = doc.layers_open.take(); + let layers_open = OpenRows::new(&doc, saved_open); + let doc = Arc::new(doc); + Self { + saved_doc: doc.clone(), + unsaved_changes: SameContent::default(), + doc, + selection: Selection::default(), + history: History { limit: 500, ..Default::default() }, + path, + revision: 1, + active_layer, + layer_rows: vec![], + variables_highlight: None, + isolation: None, + global_edit: None, + interaction: None, + undo_group: None, + last_transform: None, + last_selection_cmd: None, + recalled_selection: RecalledSelection::default(), + uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + mask_view: None, + transparency_grid, + format: "vectorcraft", + save_options: Default::default(), + save_artboards: vec![], + converted: false, + view, + recovered: false, + recovery: None, + print_tiling: false, + layers_open, + imported_from: None, + import_losses: vec![], + last_perspective: None, + opened_by: file_access::current(), + } + } + /// Unsaved changes: the document differs from the saved one (selection changes and changes that + /// are not edits to save don't count, [`DocState::same_content`]). + pub fn is_dirty(&self) -> bool { + !self.same_content(&self.saved_doc) + } + /// Record the current document as saved. + pub fn mark_saved(&mut self) { + self.saved_doc = self.doc.clone(); + } + /// Record `snapshot` (the document as a background save took it) as saved: edits made since + /// keep the document modified. + pub fn mark_saved_as(&mut self, snapshot: &Arc) { + self.saved_doc = snapshot.clone(); + } + /// Whether `doc` counts as having the content of the document now: it is the document, or one + /// that changes that are not edits to save replaced since ([`DocState::change_unsaved`]). + pub fn same_content(&self, doc: &Arc) -> bool { + Arc::ptr_eq(&self.doc, doc) || self.unsaved_changes.holds(&self.doc, doc) + } + /// Make a change that is not an edit to save with `f`, without an undo step: editing state that + /// is never saved, or type laid out again after a font scan (which can also refit the frame of + /// Auto Size area type). A saved document stays saved, and a save or recovery copy taken of the + /// document before counts as having its content ([`DocState::same_content`]). + pub(crate) fn change_unsaved(&mut self, f: impl FnOnce(&mut Arc)) { + let saved = !self.is_dirty(); + let before = Arc::downgrade(&self.doc); + f(&mut self.doc); + if !std::ptr::eq(before.as_ptr(), Arc::as_ptr(&self.doc)) { + self.unsaved_changes.replaced(before, &self.doc); + } + if saved { + self.mark_saved(); + } + } + /// Keep what interaction `it` (taken from this document) changed, as one undo step. + pub(crate) fn keep_interaction(&mut self, it: Interaction) { + if !Arc::ptr_eq(&it.doc, &self.doc) { + let selection = match self.history.undo.last() { + Some(l) if l.selection == it.selection => l.selection.clone(), + _ => it.selection, + }; + self.push_undo(it.label, it.doc, selection); + self.revision += 1; + } + } + /// Record an undo step: `doc` and `selection` as they were before an edit (the document now is + /// the one after it). In an undo group only the group's first edit records one: the edits + /// after it extend that step. + pub(crate) fn push_undo(&mut self, mut label: String, doc: Arc, selection: Arc) { + if let Some(g) = &mut self.undo_group { + let recorded = |first: &Arc| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first)); + if g.first.as_ref().is_some_and(recorded) { + self.history.recost_last(&self.doc); + self.history.trim(); + return; + } + g.first = Some(doc.clone()); + if let Some(l) = &g.label { + label = l.clone(); + } + } + let e = HistoryEntry::kept(label, doc, selection, &self.doc); + self.history.undo.push(e); + self.history.redo.clear(); + self.history.trim(); + } + /// End the interaction in progress, undoing what it changed. + pub(crate) fn undo_interaction(&mut self) { + if let Some(it) = self.interaction.take() { + self.doc = it.doc; + self.selection = Arc::unwrap_or_clone(it.selection); + self.sync_new_art(); + cmd::globaledit::follow_history(self); + self.active_layer = it.active_layer; + self.layer_rows = it.layer_rows; + self.variables_highlight = it.variables_highlight; + self.isolation = it.isolation; + self.revision += 1; + } + } + /// Count the document as modified, as if never saved (a document restored by Data Recovery). + pub fn mark_unsaved(&mut self) { + self.saved_doc = Arc::new(Document::new(1.0, 1.0)); + } + /// Deselect the art on a locked artboard, except new art ([`Document::new_art`]). New art + /// becomes the objects of layers on a locked artboard that hold selected objects (a group for + /// its selected members) and that an edit made (those not in `before`, the document before + /// it), or that held objects selected in `was` that were new art (`old`, the new art of that + /// selection), or were inside new art in `before` (the members of an ungrouped group). New art + /// that a selection leaves out, or that leaves the locked artboards, is protected from then + /// on. The selection and the document get the new art; the document's changes in place, so a + /// selection change doesn't replace the document. + pub(crate) fn protect_locked_artboards(&mut self, was: &[NodeId], old: &std::collections::HashSet, before: Option<&Document>) { + let new_art: std::collections::HashSet = { + let (doc, locks) = (&self.doc, self.doc.locked_artboards()); + if locks.is_empty() { + Default::default() + } else { + // `was` and `old` name the objects of the document they were selected in. + let then = before.unwrap_or(doc); + let was: std::collections::HashSet = + was.iter().filter_map(|o| then.layer_object(*o)).map(|n| n.id).filter(|o| old.contains(o)).collect(); + let selected: Vec<(NodeId, &vectorcraft_doc::Node)> = + self.selection.objects.iter().filter_map(|o| doc.layer_object(*o).map(|n| (*o, n))).collect(); + // One walk of the document before the edit finds where the selected objects were; + // the objects the edit made aren't in it. + let paths = before.filter(|_| !selected.is_empty()).map(|b| b.index_paths(selected.iter().flat_map(|(o, n)| [*o, n.id]))); + let new = |o: NodeId, object: NodeId| { + was.contains(&object) + || before.zip(paths.as_ref()).is_some_and(|(b, p)| { + !p.contains_key(&object) || p.get(&o).and_then(|p| b.layer_object_at(p)).is_some_and(|n| was.contains(&n.id)) + }) + }; + selected.into_iter().filter(|(o, n)| new(*o, n.id) && locks.overlaps(n)).map(|(_, n)| n.id).collect() + } + }; + if new_art != *self.selection.new_art { + self.selection.new_art = Arc::new(new_art); + } + self.sync_new_art(); + self.selection.deselect_on_locked_artboards(&self.doc); + } + /// Give the document the selection's new art ([`Selection::new_art`]), less the objects the + /// selection no longer holds: after Undo, Redo or a failed batch brings back a document and + /// a selection, and after a command that changed the selection directly. + pub(crate) fn sync_new_art(&mut self) { + if !self.selection.new_art.is_empty() { + let held: std::collections::HashSet = + self.selection.objects.iter().filter_map(|o| self.doc.layer_object(*o)).map(|n| n.id).collect(); + if !self.selection.new_art.is_subset(&held) { + self.selection.new_art = Arc::new(self.selection.new_art.intersection(&held).copied().collect()); + } + } + let doc = self.doc.new_art.get(); + if !Arc::ptr_eq(&doc, &self.selection.new_art) && *doc != *self.selection.new_art { + self.doc.new_art.set(self.selection.new_art.clone()); + } + } + pub fn title(&self) -> String { + let name = self + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.doc.title.clone()); + if self.converted { + format!("{} [Converted]", cmd::fileio::file_stem(&name)) + } else if self.recovered { + format!("{} [Recovered]", cmd::fileio::file_stem(&name)) + } else { + name + } + } + /// Where new art is inserted: the isolation container, else the active layer. + /// The current layer, if the remembered id still names a layer (ids are reused after undo). + pub fn current_layer(&self) -> Option { + self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer())) + } + pub fn insertion_parent(&self) -> Option { + // Ids are reused after undo (the id counter is part of the document), so a remembered + // layer or isolated group must still be one: a pasted group must never land in a path. + if let Some(i) = self.isolation + && self.doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) + { + return Some(i); + } + // A single object isolated: new art goes beside it, in its group or layer. + if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i)) + && self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) + { + return Some(p); + } + // A sublayer takes new art only while it and the layers around it are shown and unlocked. + self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) + } + /// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error + /// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away. + pub fn target_parent(&self) -> Result> { + match self.insertion_parent() { + Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())), + parent => Ok(parent), + } + } + /// The highlighted Layers panel rows that still exist (ids are reused after undo, so a + /// remembered row must still be in the document). + pub fn highlighted_rows(&self) -> Vec { + self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect() + } + /// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while + /// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it. + pub fn shown_mask(&self) -> Option { + self.mask_view.filter(|id| self.doc.mask_edit.is_some_and(|m| m.object == *id)) + } +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 000000000..f894e516a --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,40 @@ +use super::*; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + /// An unknown command id, with the command id it most likely misspells. + #[error("unknown command `{0}`; did you mean `{1}`?")] + UnknownCommandNear(String, String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active document")] + NoDocument, + #[error("no such object {0}")] + NoNode(NodeId), + #[error("{0}")] + Other(String), + /// The caller asked for the command to stop ([`cancel`]). It changed nothing. + #[error("cancelled; nothing was changed")] + Cancelled, + /// A bug: the command panicked. The document was rolled back to its state before the command. + #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] + Internal { cmd: String, msg: String }, +} + +impl From for EngineError { + fn from(_: cancel::Cancelled) -> Self { + EngineError::Cancelled + } +} + +impl From for EngineError { + fn from(e: vectorcraft_doc::DocError) -> Self { + EngineError::Other(e.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 000000000..f82ba3079 --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,168 @@ +use super::*; + +/// One undo step. +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub label: String, + /// The document as it was. It keeps no lookup cache ([`Document::drop_lookup_cache`]): a + /// table per step would add up, and undoing builds it again once. + pub doc: Arc, + /// The selection as it was, shared with the neighbouring steps that have the same one. + pub selection: Arc, + /// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the + /// document now) doesn't share ([`Document::retained_bytes`]). + pub bytes: usize, + /// The same the other way round: what that next state keeps that `doc` doesn't share. Undoing + /// (redoing) this step makes a step that keeps the next state: this is its `bytes`. + next_bytes: usize, + /// Which document the figures were taken against (its address): the next state. + next: usize, + /// The template and name `label` was made from ([`Session::edit_named`]). + pub(crate) named: Option, +} + +/// A step name made by filling the `{name}` placeholder of a template with a name +/// ([`Session::edit_named`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NamedStep { + /// The English template, with one `{name}` placeholder (`Apply {name}`). + pub template: &'static str, + /// The name in the placeholder, as the document holds it (a style's name). + pub name: String, + /// The name is one VectorCraft gives (`[Normal Character Style]`), not one the user typed. + pub built_in: bool, +} + +impl NamedStep { + /// The step name: the template with the name in its placeholder. + pub fn label(&self) -> String { + self.template.replace("{name}", &self.name) + } +} + +impl HistoryEntry { + /// A step keeping `doc` and `selection`, as they were before the edit that made `next`. + pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { + doc.drop_lookup_cache(); + let (bytes, next_bytes) = doc.retained_bytes_each(next); + HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None } + } + + /// The template and name the step's label was made from, while the label is still the one + /// they make: `None` for a step named any other way, and for one whose label was changed + /// since it was recorded. + pub fn named(&self) -> Option<&NamedStep> { + self.named.as_ref().filter(|n| n.label() == self.label) + } + + /// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the + /// document before the undo, whose next state is now `now` (that step's document). Its figures + /// are that step's the other way round when `doc` is still the document they were taken + /// against (nothing replaced it since), so undo and redo needn't walk the documents again. A + /// change made to it in place without an undo step (a setting, a refresh) goes unnoticed: a + /// small error, for an estimate. + pub(crate) fn turned(label: String, doc: Arc, selection: Arc, now: &Arc, costs: StepCosts) -> Self { + if costs.next != Arc::as_ptr(&doc) as usize { + return Self::kept(label, doc, selection, now); + } + doc.drop_lookup_cache(); + HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize, named: None } + } + + /// The figures [`HistoryEntry::turned`] reuses. + pub(crate) fn costs(&self) -> StepCosts { + StepCosts { bytes: self.bytes, next_bytes: self.next_bytes, next: self.next } + } +} + +/// A step's memory figures ([`HistoryEntry::costs`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct StepCosts { + bytes: usize, + next_bytes: usize, + next: usize, +} + +/// The undo history of a document: bounded by a number of steps (Preferences › Performance › +/// History States) and by the memory the steps keep ([`History::byte_budget`]). +#[derive(Clone, Debug)] +pub struct History { + pub undo: Vec, + pub redo: Vec, + /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). + pub limit: usize, + /// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest + /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. + pub byte_budget: usize, +} + +impl Default for History { + fn default() -> Self { + History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget() } + } +} + +impl History { + /// Undo (and redo) steps kept whatever memory they take. + pub const MIN_STEPS: usize = 8; + + /// Roughly the memory the steps keep of their own ([`HistoryEntry::bytes`]). + pub fn bytes(&self) -> usize { + self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() + } + + /// Let the oldest steps go while over the step limit or the memory budget: undo steps first + /// (the oldest), then redo steps (the furthest ahead). + pub fn trim(&mut self) { + let over = self.undo.len().saturating_sub(self.limit); + if over > 0 { + self.undo.drain(..over); + } + let mut total = self.bytes(); + if total <= self.byte_budget { + return; + } + let mut n = 0; + while total > self.byte_budget && self.undo.len() - n > Self::MIN_STEPS { + total -= self.undo.get(n).map_or(0, |e| e.bytes); + n += 1; + } + self.undo.drain(..n); + let mut n = 0; + while total > self.byte_budget && self.redo.len() - n > Self::MIN_STEPS { + total -= self.redo.get(n).map_or(0, |e| e.bytes); + n += 1; + } + self.redo.drain(..n); + } + + /// Estimate the newest undo step again, its next state now being `now` (the steps after it were + /// merged into it, or an undo group extended it). + pub(crate) fn recost_last(&mut self, now: &Document) { + if let Some(e) = self.undo.last_mut() { + (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); + e.next = std::ptr::from_ref(now) as usize; + } + } +} + +/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known +/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small. +pub fn default_history_budget() -> usize { + const MAX: usize = 1 << 30; + if cfg!(target_arch = "wasm32") { + return 256 << 20; + } + static BUDGET: std::sync::OnceLock = std::sync::OnceLock::new(); + *BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX))) +} + +/// The machine's memory, where cheap to learn without platform calls. +fn physical_memory() -> Option { + if !cfg!(target_os = "linux") { + return None; + } + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; + kb.checked_mul(1024) +} diff --git a/crates/engine/src/interaction.rs b/crates/engine/src/interaction.rs new file mode 100644 index 000000000..986e9e566 --- /dev/null +++ b/crates/engine/src/interaction.rs @@ -0,0 +1,86 @@ +use super::*; + +/// An in-progress drag: the snapshot the previews are applied to. +#[derive(Clone, Debug)] +pub struct Interaction { + pub label: String, + pub doc: Arc, + /// The selection the previews start from (shared, so a preview frame copies nothing). + pub selection: Arc, + pub preview: Option<(String, Value)>, + /// Per-document state restored on cancel (current layer, highlighted Layers panel rows, + /// isolation). + pub active_layer: Option, + pub layer_rows: Vec, + pub variables_highlight: Option, + pub isolation: Option, + /// The perspective transform the previews make (`perspective.transform` params): Transform + /// Again repeats it once the drag is committed. + pub perspective_again: Option, + /// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here + /// ([`PreviewCopies`]). + pub(crate) copies: Option>, +} + +/// What Object → Transform → Transform Again (⌘D) repeats ([`SessionState::last_transform`]). +#[derive(Clone, Debug, PartialEq)] +pub enum LastTransform { + /// The last transform command's matrix, and the options Transform Again passes with it. + Matrix(Affine, Value), + /// The last Transform Each's parameters: each object transforms about its own bounds, so the + /// repeat runs the command on the selection of the moment rather than one matrix. + Each(Value), +} + +/// This holds the copies that a transform or a perspective edit with `copy` made of an +/// interaction's snapshot ([`cmd::object::apply_transform`]; `persp_edit` and `plane_move` in +/// `cmd::distortcmds`). A later transform or perspective edit with `copy` of the same objects, +/// while the document is still that snapshot, starts from `doc` and transforms only the copies. +#[derive(Debug)] +pub(crate) struct PreviewCopies { + /// The copies were made from this snapshot. + pub(crate) from: Arc, + /// The transform or perspective edit copied these objects. + pub(crate) roots: Vec, + /// This is `from` with the copies added, untransformed. + pub(crate) doc: Arc, + /// This is the selection after copying, with the copies selected. + pub(crate) selection: Selection, + /// These are the copies' ids, in the order of `roots`. + pub(crate) ids: Vec, +} + +/// An open undo group ([`Session::begin_undo_group`]): the edits made in it are one undo step. +#[derive(Clone, Debug)] +pub struct UndoGroup { + /// The document before the group's first edit, as the undo step that edit recorded keeps it. + pub(crate) first: Option>, + /// The numbers of the journal entries made in the group's document while it was open: a canceled + /// group drops them. An entry that changed the active document has the index of the document it + /// left active, and a canceled group turns it into that change. + pub(crate) journal: Vec<(usize, Option)>, + /// For each `command.batch` entry in `journal`, the steps of its `commands` that belong to the + /// group (by number), each with the index of the document it left active when it changed the + /// active document: a canceled group drops those steps and keeps the batch's other ones. + pub(crate) steps: Vec, + /// The undo step's name; else the first edit's. + pub(crate) label: Option, +} + +/// A `command.batch` journal entry's number and the steps of it that belong to an undo group, each +/// with the index of the document it left active when it changed the active document +/// ([`UndoGroup::steps`]). +pub(crate) type BatchSteps = (usize, Vec<(usize, Option)>); + +/// One undo step begun with [`Session::begin_undo_scope`], which [`Session::end_undo_scope`] ends. +#[derive(Clone, Debug)] +pub struct UndoScope { + /// The document ([`DocState::uid`]). + pub(crate) uid: u64, + /// The journal's length when the scope began. + pub(crate) journal: usize, + /// When the document's undo group was open already: the document, its selection and the document + /// that the group's undo step keeps, as they were when the scope began; `None` when the scope + /// opened the group. + pub(crate) joined: Option<(Arc, Selection, Option>)>, +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ef447b01e..ebae936f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -40,1335 +40,10 @@ pub use vectorcraft_doc::cancel; pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, simplify_level_tolerance}; pub use vectorcraft_tools as tools; -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("unknown command `{0}`")] - UnknownCommand(String), - /// An unknown command id, with the command id it most likely misspells. - #[error("unknown command `{0}`; did you mean `{1}`?")] - UnknownCommandNear(String, String), - #[error("command `{0}` is not available right now: {1}")] - Disabled(String, String), - #[error("invalid parameters for `{cmd}`: {msg}")] - BadParams { cmd: String, msg: String }, - #[error("no active document")] - NoDocument, - #[error("no such object {0}")] - NoNode(NodeId), - #[error("{0}")] - Other(String), - /// The caller asked for the command to stop ([`cancel`]). It changed nothing. - #[error("cancelled; nothing was changed")] - Cancelled, - /// A bug: the command panicked. The document was rolled back to its state before the command. - #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] - Internal { cmd: String, msg: String }, -} - -impl From for EngineError { - fn from(_: cancel::Cancelled) -> Self { - EngineError::Cancelled - } -} - -impl From for EngineError { - fn from(e: vectorcraft_doc::DocError) -> Self { - EngineError::Other(e.to_string()) - } -} - -pub type Result = std::result::Result; - -/// One undo step. -#[derive(Clone, Debug)] -pub struct HistoryEntry { - pub label: String, - /// The document as it was. It keeps no lookup cache ([`Document::drop_lookup_cache`]): a - /// table per step would add up, and undoing builds it again once. - pub doc: Arc, - /// The selection as it was, shared with the neighbouring steps that have the same one. - pub selection: Arc, - /// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the - /// document now) doesn't share ([`Document::retained_bytes`]). - pub bytes: usize, - /// The same the other way round: what that next state keeps that `doc` doesn't share. Undoing - /// (redoing) this step makes a step that keeps the next state: this is its `bytes`. - next_bytes: usize, - /// Which document the figures were taken against (its address): the next state. - next: usize, - /// The template and name `label` was made from ([`Session::edit_named`]). - named: Option, -} - -/// A step name made by filling the `{name}` placeholder of a template with a name -/// ([`Session::edit_named`]). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NamedStep { - /// The English template, with one `{name}` placeholder (`Apply {name}`). - pub template: &'static str, - /// The name in the placeholder, as the document holds it (a style's name). - pub name: String, - /// The name is one VectorCraft gives (`[Normal Character Style]`), not one the user typed. - pub built_in: bool, -} - -impl NamedStep { - /// The step name: the template with the name in its placeholder. - pub fn label(&self) -> String { - self.template.replace("{name}", &self.name) - } -} - -impl HistoryEntry { - /// A step keeping `doc` and `selection`, as they were before the edit that made `next`. - pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { - doc.drop_lookup_cache(); - let (bytes, next_bytes) = doc.retained_bytes_each(next); - HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None } - } - - /// The template and name the step's label was made from, while the label is still the one - /// they make: `None` for a step named any other way, and for one whose label was changed - /// since it was recorded. - pub fn named(&self) -> Option<&NamedStep> { - self.named.as_ref().filter(|n| n.label() == self.label) - } - - /// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the - /// document before the undo, whose next state is now `now` (that step's document). Its figures - /// are that step's the other way round when `doc` is still the document they were taken - /// against (nothing replaced it since), so undo and redo needn't walk the documents again. A - /// change made to it in place without an undo step (a setting, a refresh) goes unnoticed: a - /// small error, for an estimate. - pub(crate) fn turned(label: String, doc: Arc, selection: Arc, now: &Arc, costs: StepCosts) -> Self { - if costs.next != Arc::as_ptr(&doc) as usize { - return Self::kept(label, doc, selection, now); - } - doc.drop_lookup_cache(); - HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize, named: None } - } - - /// The figures [`HistoryEntry::turned`] reuses. - pub(crate) fn costs(&self) -> StepCosts { - StepCosts { bytes: self.bytes, next_bytes: self.next_bytes, next: self.next } - } -} - -/// A step's memory figures ([`HistoryEntry::costs`]). -#[derive(Clone, Copy, Debug)] -pub(crate) struct StepCosts { - bytes: usize, - next_bytes: usize, - next: usize, -} - -/// The undo history of a document: bounded by a number of steps (Preferences › Performance › -/// History States) and by the memory the steps keep ([`History::byte_budget`]). -#[derive(Clone, Debug)] -pub struct History { - pub undo: Vec, - pub redo: Vec, - /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). - pub limit: usize, - /// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest - /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. - pub byte_budget: usize, -} - -impl Default for History { - fn default() -> Self { - History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget() } - } -} - -impl History { - /// Undo (and redo) steps kept whatever memory they take. - pub const MIN_STEPS: usize = 8; - - /// Roughly the memory the steps keep of their own ([`HistoryEntry::bytes`]). - pub fn bytes(&self) -> usize { - self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() - } - - /// Let the oldest steps go while over the step limit or the memory budget: undo steps first - /// (the oldest), then redo steps (the furthest ahead). - pub fn trim(&mut self) { - let over = self.undo.len().saturating_sub(self.limit); - if over > 0 { - self.undo.drain(..over); - } - let mut total = self.bytes(); - if total <= self.byte_budget { - return; - } - let mut n = 0; - while total > self.byte_budget && self.undo.len() - n > Self::MIN_STEPS { - total -= self.undo.get(n).map_or(0, |e| e.bytes); - n += 1; - } - self.undo.drain(..n); - let mut n = 0; - while total > self.byte_budget && self.redo.len() - n > Self::MIN_STEPS { - total -= self.redo.get(n).map_or(0, |e| e.bytes); - n += 1; - } - self.redo.drain(..n); - } - - /// Estimate the newest undo step again, its next state now being `now` (the steps after it were - /// merged into it, or an undo group extended it). - pub(crate) fn recost_last(&mut self, now: &Document) { - if let Some(e) = self.undo.last_mut() { - (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); - e.next = std::ptr::from_ref(now) as usize; - } - } -} - -/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known -/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small. -pub fn default_history_budget() -> usize { - const MAX: usize = 1 << 30; - if cfg!(target_arch = "wasm32") { - return 256 << 20; - } - static BUDGET: std::sync::OnceLock = std::sync::OnceLock::new(); - *BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX))) -} - -/// The machine's memory, where cheap to learn without platform calls. -fn physical_memory() -> Option { - if !cfg!(target_os = "linux") { - return None; - } - let info = std::fs::read_to_string("/proc/meminfo").ok()?; - let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; - kb.checked_mul(1024) -} - -/// An in-progress drag: the snapshot the previews are applied to. -#[derive(Clone, Debug)] -pub struct Interaction { - pub label: String, - pub doc: Arc, - /// The selection the previews start from (shared, so a preview frame copies nothing). - pub selection: Arc, - pub preview: Option<(String, Value)>, - /// Per-document state restored on cancel (current layer, highlighted Layers panel rows, - /// isolation). - pub active_layer: Option, - pub layer_rows: Vec, - pub variables_highlight: Option, - pub isolation: Option, - /// The perspective transform the previews make (`perspective.transform` params): Transform - /// Again repeats it once the drag is committed. - pub perspective_again: Option, - /// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here - /// ([`PreviewCopies`]). - pub(crate) copies: Option>, -} - -/// What Object → Transform → Transform Again (⌘D) repeats ([`SessionState::last_transform`]). -#[derive(Clone, Debug, PartialEq)] -pub enum LastTransform { - /// The last transform command's matrix, and the options Transform Again passes with it. - Matrix(Affine, Value), - /// The last Transform Each's parameters: each object transforms about its own bounds, so the - /// repeat runs the command on the selection of the moment rather than one matrix. - Each(Value), -} - -/// This holds the copies that a transform or a perspective edit with `copy` made of an -/// interaction's snapshot ([`cmd::object::apply_transform`]; `persp_edit` and `plane_move` in -/// `cmd::distortcmds`). A later transform or perspective edit with `copy` of the same objects, -/// while the document is still that snapshot, starts from `doc` and transforms only the copies. -#[derive(Debug)] -pub(crate) struct PreviewCopies { - /// The copies were made from this snapshot. - pub(crate) from: Arc, - /// The transform or perspective edit copied these objects. - pub(crate) roots: Vec, - /// This is `from` with the copies added, untransformed. - pub(crate) doc: Arc, - /// This is the selection after copying, with the copies selected. - pub(crate) selection: Selection, - /// These are the copies' ids, in the order of `roots`. - pub(crate) ids: Vec, -} - -/// An open undo group ([`Session::begin_undo_group`]): the edits made in it are one undo step. -#[derive(Clone, Debug)] -pub struct UndoGroup { - /// The document before the group's first edit, as the undo step that edit recorded keeps it. - first: Option>, - /// The numbers of the journal entries made in the group's document while it was open: a canceled - /// group drops them. An entry that changed the active document has the index of the document it - /// left active, and a canceled group turns it into that change. - journal: Vec<(usize, Option)>, - /// For each `command.batch` entry in `journal`, the steps of its `commands` that belong to the - /// group (by number), each with the index of the document it left active when it changed the - /// active document: a canceled group drops those steps and keeps the batch's other ones. - steps: Vec, - /// The undo step's name; else the first edit's. - label: Option, -} - -/// A `command.batch` journal entry's number and the steps of it that belong to an undo group, each -/// with the index of the document it left active when it changed the active document -/// ([`UndoGroup::steps`]). -type BatchSteps = (usize, Vec<(usize, Option)>); - -/// One undo step begun with [`Session::begin_undo_scope`], which [`Session::end_undo_scope`] ends. -#[derive(Clone, Debug)] -pub struct UndoScope { - /// The document ([`DocState::uid`]). - uid: u64, - /// The journal's length when the scope began. - journal: usize, - /// When the document's undo group was open already: the document, its selection and the document - /// that the group's undo step keeps, as they were when the scope began; `None` when the scope - /// opened the group. - joined: Option<(Arc, Selection, Option>)>, -} - -/// Per-document editing state. -#[derive(Clone, Debug)] -pub struct DocState { - pub doc: Arc, - pub selection: Selection, - pub history: History, - pub path: Option, - /// Increments on every change (also selection and view-only changes); UIs re-render when it - /// moves. Not a dirty flag: see [`DocState::is_dirty`]. - pub revision: u64, - /// The document as last saved (or opened). Structural sharing makes "unchanged" a pointer - /// comparison, and undoing back to the saved state counts as clean. - saved_doc: Arc, - /// The documents that changes that are not edits to save replaced ([`DocState::same_content`]). - unsaved_changes: SameContent, - /// The layer new art goes into (the "current layer" in the Layers panel). - pub active_layer: Option, - /// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order - /// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on. - /// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`, - /// `layer.highlight`). - pub layer_rows: Vec, - /// The variable highlighted in the Variables panel: what its Delete, Options… and Select - /// Bound Object act on. Panel state, not art selection: not saved, not undoable - /// (`variable.highlight`). A name, so renaming a variable moves the highlight with it. - pub variables_highlight: Option, - /// Isolation mode container. - pub isolation: Option, - /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape - /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. - pub global_edit: Option, - pub interaction: Option, - /// Edits made while this is open are one undo step (a scrubbed numeric field). - pub undo_group: Option, - /// For Object → Transform → Transform Again (⌘D): what the last transformation was. A - /// transform command keeps the `copy`, `typeAreas` and `patterns` it used, and `strokes` and - /// `corners` when it scales; a drag keeps the params of its last preview with the values that - /// preview noted ([`Session::note_journal`]). - pub last_transform: Option, - /// What Select → Reselect repeats: the last selection command, its params and the objects - /// selected when it ran (a Same command's reference objects). - pub last_selection_cmd: Option<(String, Value, Vec)>, - /// The saved selection last recalled (`select.recall`), which Select → Update Selection - /// changes. It is not saved with the file, and a recall is not an undo step. - pub recalled_selection: RecalledSelection, - /// Process-unique id of this open document (tab indices shift when tabs close). - pub uid: u64, - /// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas - /// shows alone, in greyscale, while that mask is edited (see [`DocState::shown_mask`]). - pub mask_view: Option, - /// View → Show Transparency Grid, per document (saved view state, not undoable). - pub transparency_grid: bool, - /// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened - /// from or last saved as. - pub format: &'static str, - /// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF; - /// empty for native files): Save reuses them and `file.formatOptions` reads them back. - pub save_options: serde_json::Map, - /// The ids of the document's artboards, in order, when `save_options` were stored: the - /// artboards their `artboard`, `artboards` and `range` named by number then - /// ([`cmd::fileio::remembered_save_options`]). - pub save_artboards: Vec, - /// Opened from an older native file (former name or format version): the title says - /// "[Converted]" and Save asks for a new name instead of overwriting it. - pub converted: bool, - /// The view saved into native files (`Document::last_view`); the UI keeps it current before a - /// save and restores it when the document opens. - pub view: Option, - /// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it - /// (suggesting `path`, the file it was copied from) instead of overwriting that file. - pub recovered: bool, - /// The document's Data Recovery copy, once one was written ([`cmd::recovery`]). - pub recovery: Option, - /// View → Show Print Tiling, per document (view state: not saved, not undoable). - pub print_tiling: bool, - /// The rows open in the Layers panel (view state: not undoable; native files keep it). - pub layers_open: OpenRows, - /// The file this document was read from, and what reading it left out (hidden text, art or - /// layers it could not read): writing the document over that file would lose those for good, - /// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved. - pub imported_from: Option, - pub import_losses: Vec, - /// Transform Again after a perspective move or scale (Perspective Selection tool): the - /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. - pub last_perspective: Option, - /// The folders a confined (automation) caller that opened or made the document may read: the - /// paths the document links to are looked at within them later too, when the Links panel - /// asks about a link outside any caller's scope. - pub opened_by: Option>, -} - -static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold. -/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only -/// its top-level layers open (a `.ai` another app wrote opens with none open). -#[derive(Clone, Debug, Default)] -pub struct OpenRows { - ids: std::collections::HashSet, - /// Counts the changes, so views can keep what they work out from the open rows. - generation: u64, -} - -impl OpenRows { - /// The rows `saved` in a file, else `doc`'s top-level layers. - fn new(doc: &Document, saved: Option>) -> Self { - let ids = match saved { - Some(ids) => ids.into_iter().collect(), - None => doc.layers.iter().map(|l| l.id).collect(), - }; - Self { ids, generation: 0 } - } - pub fn contains(&self, id: NodeId) -> bool { - self.ids.contains(&id) - } - /// Open or close row `id`. - pub fn set(&mut self, id: NodeId, open: bool) { - let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) }; - self.generation += u64::from(changed); - } - /// Changes so far: the same number means the same open rows. - pub fn generation(&self) -> u64 { - self.generation - } - /// What a native file keeps of the open rows: those of `doc` that hold others, in id order; - /// `None` when they are the default (only the top-level layers). - pub fn saved(&self, doc: &Document) -> Option> { - let mut ids = vec![]; - doc.walk(|n| { - if n.children().is_some() && self.ids.contains(&n.id) { - ids.push(n.id); - } - }); - ids.sort_unstable(); - let mut layers: Vec = doc.layers.iter().map(|l| l.id).collect(); - layers.sort_unstable(); - (ids != layers).then_some(ids) - } -} - -/// The saved selection last recalled, which Select → Update Selection changes. The Select commands -/// give each saved selection an id for the session and keep the ids for every version of the -/// document's saved selections they make or read, so the recalled one is found again after Edit -/// Selection…, Undo and Redo. A version that nothing holds any more (neither the document nor its -/// undo history) is dropped the next time a version is added. -#[derive(Clone, Debug, Default)] -pub struct RecalledSelection { - /// The recalled saved selection's id. - id: Option, - /// Each version of the saved selections (identified by its allocation), with the id of each saved - /// selection in it. - versions: Vec<(std::sync::Weak>, Vec)>, - /// The last id given. - last: u64, -} - -impl RecalledSelection { - /// The ids of `list`'s saved selections; a version not met before gets new ones. - pub(crate) fn ids(&mut self, list: &vectorcraft_doc::Shared>) -> Vec { - let version = vectorcraft_doc::Shared::downgrade(list); - if let Some((_, ids)) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version)) { - return ids.clone(); - } - let ids: Vec = (0..list.len()).map(|_| self.new_id()).collect(); - self.remember(list, ids.clone()); - ids - } - /// An id no saved selection has had. - pub(crate) fn new_id(&mut self) -> u64 { - self.last += 1; - self.last - } - /// Keep `ids` as those of `list`'s saved selections, dropping versions nothing holds any more. - pub(crate) fn remember(&mut self, list: &vectorcraft_doc::Shared>, ids: Vec) { - let version = vectorcraft_doc::Shared::downgrade(list); - self.versions.retain(|(v, _)| v.strong_count() > 0 && !v.ptr_eq(&version)); - self.versions.push((version, ids)); - } - /// Recall the saved selection at `index` in `list`. - pub(crate) fn recall(&mut self, list: &vectorcraft_doc::Shared>, index: usize) { - self.id = self.ids(list).get(index).copied(); - } - /// Where the recalled saved selection is in `list`, if `list` holds it. - pub fn index_in(&self, list: &vectorcraft_doc::Shared>) -> Option { - let id = self.id?; - let version = vectorcraft_doc::Shared::downgrade(list); - let (_, ids) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version))?; - ids.iter().position(|&x| x == id).filter(|&i| i < list.len()) - } -} - -/// The documents that changes that are not edits to save (Puppet Warp pins dropped, Edit Text ended, -/// type laid out again after a font scan) replaced, each counted as having the content of the -/// document it was replaced by ([`DocState::change_unsaved`]). A document that nothing holds any -/// more is dropped the next time one is added. -#[derive(Clone, Debug, Default)] -struct SameContent { - /// The document the last such change made. - now: std::sync::Weak, - /// The documents with its content that such changes replaced, oldest first. - before: Vec>, -} - -impl SameContent { - /// Note that `now` replaced `before` without a change to its content. - fn replaced(&mut self, before: std::sync::Weak, now: &Arc) { - if !self.now.ptr_eq(&before) { - self.before.clear(); - } - self.before.retain(|d| d.strong_count() > 0); - if before.strong_count() > 0 { - self.before.push(before); - } - self.now = Arc::downgrade(now); - } - /// Whether `doc` is one of the documents with the content of `now`. - fn holds(&self, now: &Arc, doc: &Arc) -> bool { - std::ptr::eq(self.now.as_ptr(), Arc::as_ptr(now)) && self.before.iter().any(|d| std::ptr::eq(d.as_ptr(), Arc::as_ptr(doc))) - } -} - -impl DocState { - pub fn new(mut doc: Document, path: Option) -> Self { - let active_layer = doc.default_layer(); - // Saved view state lives here while the document is open (saves write it back). - let view = doc.last_view.take(); - let transparency_grid = std::mem::take(&mut doc.transparency_grid); - let saved_open = doc.layers_open.take(); - let layers_open = OpenRows::new(&doc, saved_open); - let doc = Arc::new(doc); - Self { - saved_doc: doc.clone(), - unsaved_changes: SameContent::default(), - doc, - selection: Selection::default(), - history: History { limit: 500, ..Default::default() }, - path, - revision: 1, - active_layer, - layer_rows: vec![], - variables_highlight: None, - isolation: None, - global_edit: None, - interaction: None, - undo_group: None, - last_transform: None, - last_selection_cmd: None, - recalled_selection: RecalledSelection::default(), - uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - mask_view: None, - transparency_grid, - format: "vectorcraft", - save_options: Default::default(), - save_artboards: vec![], - converted: false, - view, - recovered: false, - recovery: None, - print_tiling: false, - layers_open, - imported_from: None, - import_losses: vec![], - last_perspective: None, - opened_by: file_access::current(), - } - } - /// Unsaved changes: the document differs from the saved one (selection changes and changes that - /// are not edits to save don't count, [`DocState::same_content`]). - pub fn is_dirty(&self) -> bool { - !self.same_content(&self.saved_doc) - } - /// Record the current document as saved. - pub fn mark_saved(&mut self) { - self.saved_doc = self.doc.clone(); - } - /// Record `snapshot` (the document as a background save took it) as saved: edits made since - /// keep the document modified. - pub fn mark_saved_as(&mut self, snapshot: &Arc) { - self.saved_doc = snapshot.clone(); - } - /// Whether `doc` counts as having the content of the document now: it is the document, or one - /// that changes that are not edits to save replaced since ([`DocState::change_unsaved`]). - pub fn same_content(&self, doc: &Arc) -> bool { - Arc::ptr_eq(&self.doc, doc) || self.unsaved_changes.holds(&self.doc, doc) - } - /// Make a change that is not an edit to save with `f`, without an undo step: editing state that - /// is never saved, or type laid out again after a font scan (which can also refit the frame of - /// Auto Size area type). A saved document stays saved, and a save or recovery copy taken of the - /// document before counts as having its content ([`DocState::same_content`]). - pub(crate) fn change_unsaved(&mut self, f: impl FnOnce(&mut Arc)) { - let saved = !self.is_dirty(); - let before = Arc::downgrade(&self.doc); - f(&mut self.doc); - if !std::ptr::eq(before.as_ptr(), Arc::as_ptr(&self.doc)) { - self.unsaved_changes.replaced(before, &self.doc); - } - if saved { - self.mark_saved(); - } - } - /// Keep what interaction `it` (taken from this document) changed, as one undo step. - pub(crate) fn keep_interaction(&mut self, it: Interaction) { - if !Arc::ptr_eq(&it.doc, &self.doc) { - let selection = match self.history.undo.last() { - Some(l) if l.selection == it.selection => l.selection.clone(), - _ => it.selection, - }; - self.push_undo(it.label, it.doc, selection); - self.revision += 1; - } - } - /// Record an undo step: `doc` and `selection` as they were before an edit (the document now is - /// the one after it). In an undo group only the group's first edit records one: the edits - /// after it extend that step. - fn push_undo(&mut self, mut label: String, doc: Arc, selection: Arc) { - if let Some(g) = &mut self.undo_group { - let recorded = |first: &Arc| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first)); - if g.first.as_ref().is_some_and(recorded) { - self.history.recost_last(&self.doc); - self.history.trim(); - return; - } - g.first = Some(doc.clone()); - if let Some(l) = &g.label { - label = l.clone(); - } - } - let e = HistoryEntry::kept(label, doc, selection, &self.doc); - self.history.undo.push(e); - self.history.redo.clear(); - self.history.trim(); - } - /// End the interaction in progress, undoing what it changed. - pub(crate) fn undo_interaction(&mut self) { - if let Some(it) = self.interaction.take() { - self.doc = it.doc; - self.selection = Arc::unwrap_or_clone(it.selection); - self.sync_new_art(); - cmd::globaledit::follow_history(self); - self.active_layer = it.active_layer; - self.layer_rows = it.layer_rows; - self.variables_highlight = it.variables_highlight; - self.isolation = it.isolation; - self.revision += 1; - } - } - /// Count the document as modified, as if never saved (a document restored by Data Recovery). - pub fn mark_unsaved(&mut self) { - self.saved_doc = Arc::new(Document::new(1.0, 1.0)); - } - /// Deselect the art on a locked artboard, except new art ([`Document::new_art`]). New art - /// becomes the objects of layers on a locked artboard that hold selected objects (a group for - /// its selected members) and that an edit made (those not in `before`, the document before - /// it), or that held objects selected in `was` that were new art (`old`, the new art of that - /// selection), or were inside new art in `before` (the members of an ungrouped group). New art - /// that a selection leaves out, or that leaves the locked artboards, is protected from then - /// on. The selection and the document get the new art; the document's changes in place, so a - /// selection change doesn't replace the document. - fn protect_locked_artboards(&mut self, was: &[NodeId], old: &std::collections::HashSet, before: Option<&Document>) { - let new_art: std::collections::HashSet = { - let (doc, locks) = (&self.doc, self.doc.locked_artboards()); - if locks.is_empty() { - Default::default() - } else { - // `was` and `old` name the objects of the document they were selected in. - let then = before.unwrap_or(doc); - let was: std::collections::HashSet = - was.iter().filter_map(|o| then.layer_object(*o)).map(|n| n.id).filter(|o| old.contains(o)).collect(); - let selected: Vec<(NodeId, &vectorcraft_doc::Node)> = - self.selection.objects.iter().filter_map(|o| doc.layer_object(*o).map(|n| (*o, n))).collect(); - // One walk of the document before the edit finds where the selected objects were; - // the objects the edit made aren't in it. - let paths = before.filter(|_| !selected.is_empty()).map(|b| b.index_paths(selected.iter().flat_map(|(o, n)| [*o, n.id]))); - let new = |o: NodeId, object: NodeId| { - was.contains(&object) - || before.zip(paths.as_ref()).is_some_and(|(b, p)| { - !p.contains_key(&object) || p.get(&o).and_then(|p| b.layer_object_at(p)).is_some_and(|n| was.contains(&n.id)) - }) - }; - selected.into_iter().filter(|(o, n)| new(*o, n.id) && locks.overlaps(n)).map(|(_, n)| n.id).collect() - } - }; - if new_art != *self.selection.new_art { - self.selection.new_art = Arc::new(new_art); - } - self.sync_new_art(); - self.selection.deselect_on_locked_artboards(&self.doc); - } - /// Give the document the selection's new art ([`Selection::new_art`]), less the objects the - /// selection no longer holds: after Undo, Redo or a failed batch brings back a document and - /// a selection, and after a command that changed the selection directly. - pub(crate) fn sync_new_art(&mut self) { - if !self.selection.new_art.is_empty() { - let held: std::collections::HashSet = - self.selection.objects.iter().filter_map(|o| self.doc.layer_object(*o)).map(|n| n.id).collect(); - if !self.selection.new_art.is_subset(&held) { - self.selection.new_art = Arc::new(self.selection.new_art.intersection(&held).copied().collect()); - } - } - let doc = self.doc.new_art.get(); - if !Arc::ptr_eq(&doc, &self.selection.new_art) && *doc != *self.selection.new_art { - self.doc.new_art.set(self.selection.new_art.clone()); - } - } - pub fn title(&self) -> String { - let name = self - .path - .as_deref() - .and_then(|p| std::path::Path::new(p).file_name()) - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| self.doc.title.clone()); - if self.converted { - format!("{} [Converted]", cmd::fileio::file_stem(&name)) - } else if self.recovered { - format!("{} [Recovered]", cmd::fileio::file_stem(&name)) - } else { - name - } - } - /// Where new art is inserted: the isolation container, else the active layer. - /// The current layer, if the remembered id still names a layer (ids are reused after undo). - pub fn current_layer(&self) -> Option { - self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer())) - } - pub fn insertion_parent(&self) -> Option { - // Ids are reused after undo (the id counter is part of the document), so a remembered - // layer or isolated group must still be one: a pasted group must never land in a path. - if let Some(i) = self.isolation - && self.doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) - { - return Some(i); - } - // A single object isolated: new art goes beside it, in its group or layer. - if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i)) - && self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) - { - return Some(p); - } - // A sublayer takes new art only while it and the layers around it are shown and unlocked. - self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) - } - /// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error - /// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away. - pub fn target_parent(&self) -> Result> { - match self.insertion_parent() { - Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())), - parent => Ok(parent), - } - } - /// The highlighted Layers panel rows that still exist (ids are reused after undo, so a - /// remembered row must still be in the document). - pub fn highlighted_rows(&self) -> Vec { - self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect() - } - /// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while - /// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it. - pub fn shown_mask(&self) -> Option { - self.mask_view.filter(|id| self.doc.mask_edit.is_some_and(|m| m.object == *id)) - } -} - /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. pub use vectorcraft_doc::MAX_COORD; - -/// The commands that act on the active artboard (#693), which the app passes as their `artboard`: -/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute -/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, and All on -/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their -/// steps that names none. -pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [ - "edit.pasteInPlace", - "edit.pasteInFront", - "edit.pasteInBack", - "object.align", - "object.distribute", - "object.distributeSpacing", - "select.allOnArtboard", -]; - -/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the -/// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to -/// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to -/// each of their steps that names none. -pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"]; - -/// `(command, param)` pairs the journal records only so a replay reproduces the original run -/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the -/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves -/// them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 14] = [ - ("file.new", "created"), - ("document.save", "modified"), - ("file.saveAs", "modified"), - ("edit.pasteInPlace", "artboard"), - ("edit.pasteInFront", "artboard"), - ("edit.pasteInBack", "artboard"), - ("object.align", "artboard"), - ("object.distribute", "artboard"), - ("object.distributeSpacing", "artboard"), - ("artboard.fitToArt", "index"), - ("artboard.fitToArt", "indices"), - ("artboard.fitToSelection", "index"), - ("select.allOnArtboard", "artboard"), - ("command.batch", "artboard"), -]; - -/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps'). -fn strip_replay_only(id: &str, p: &mut Value) { - let strip = |id: &str, p: &mut Value| { - if let Value::Object(m) = p { - m.retain(|k, _| !REPLAY_ONLY.iter().any(|&(c, key)| c == id && key == k)); - } - }; - strip(id, p); - // Batches don't nest: one level of steps. - if id == "command.batch" - && let Some(Value::Array(steps)) = p.get_mut("commands") - { - for step in steps { - let id = step.get("command").and_then(Value::as_str).unwrap_or_default().to_string(); - if let Some(p) = step.get_mut("params") { - strip(&id, p); - } - } - } -} - -/// Is `id` a path made into a guide (View → Guides → Make Guides)? -pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool { - d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. })) -} - -/// Deselect the ruler guides and the paths made into guides: while View → Guides → Lock Guides is -/// on, they can't be selected (#1534), whatever selects them. → whether any was selected. -pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool { - let paths: Vec = sel.objects.iter().copied().filter(|id| is_guide_path(d, *id)).collect(); - let any = !paths.is_empty() || !sel.guides.is_empty(); - for id in paths { - sel.remove(id); - } - sel.guides.clear(); - sel.key_guide = None; - // A key object kept only for the guides to line up on goes with them. - if sel.key.is_some_and(|k| sel.objects.iter().all(|o| *o == k)) { - sel.key = None; - } - any -} - -/// Deselect what the View menu keeps from being selected: the guides while Lock Guides is on -/// ([`deselect_guides`]), and the slices that the Slice Selection tool or a slice command selected -/// ([`Selection::slices`]) while Lock Slices or Hide Slices is on. A selected object that is an -/// object slice stays selected. → whether any was selected. -pub(crate) fn deselect_hidden(menu: &cmd::menucmds::MenuState, d: &Document, sel: &mut Selection) -> bool { - let guides = menu.guides_locked && deselect_guides(d, sel); - let slices = (menu.slices_locked || menu.slices_hidden) && !sel.slices.is_empty(); - if slices { - sel.slices.clear(); - } - guides || slices -} - -/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`]. -pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep"; const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap"); -/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must -/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. -fn doc_sane(d: &Document, sel: &Selection) -> bool { - d.ruler_origin.is_finite() - && d.ruler_origin.x.abs() <= MAX_COORD - && d.ruler_origin.y.abs() <= MAX_COORD - && d.artboards.iter().all(|a| rect_in_range(a.rect)) - && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) - && objects_in_range(d, &sel.objects) -} - -fn rect_in_range(r: vectorcraft_geom::Rect) -> bool { - [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) -} - -/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)? -fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool { - let paths = d.index_paths(ids.iter().copied()); - ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range)) -} - -/// Where new art goes (Illustrator's drawing modes, Shift+D cycles). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DrawMode { - #[default] - Normal, - Behind, - Inside, -} - -/// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` / -/// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]). -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Prefs { - // General - /// Arrow-key nudge distance (General → Keyboard Increment), points. - pub keyboard_increment: f64, - pub constrain_angle: f64, - pub corner_radius: f64, - pub disable_auto_add_delete: bool, - pub use_precise_cursors: bool, - pub show_tool_tips: bool, - pub anti_aliased_artwork: bool, - pub select_same_tint_percent: bool, - pub show_home_screen: bool, - pub use_preview_bounds: bool, - pub display_print_size: bool, - pub double_click_to_isolate: bool, - pub transform_pattern_tiles: bool, - pub scale_corners: bool, - /// Scale Strokes & Effects. - pub scale_strokes: bool, - pub zoom_with_mouse_wheel: bool, - /// A horizontal drag on a numeric field or its label steps its value (#400). - pub scrub_numeric_fields: bool, - /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. - pub trackpad_rotate_view: bool, - /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). - pub paste_offset: f64, - // Selection & Anchor Display - pub selection_tolerance: f64, - pub object_selection_by_path_only: bool, - pub snap_to_point_tolerance: f64, - pub ctrl_click_selects_behind: bool, - pub zoom_to_selection: bool, - pub move_locked_with_artboard: bool, - pub anchor_size: u32, - pub handle_style: String, - pub highlight_anchors_on_hover: bool, - pub show_handles_multiple_anchors: bool, - pub hide_corner_widget_above: f64, - pub pen_rubber_band: bool, - pub curvature_rubber_band: bool, - // Type - pub type_size_increment: f64, - pub tracking_increment: f64, - pub baseline_shift_increment: f64, - pub show_east_asian_options: bool, - pub show_indic_options: bool, - pub type_selection_by_path_only: bool, - pub font_names_in_english: bool, - pub auto_size_area_type: bool, - pub font_preview: bool, - pub font_preview_size: String, - pub recent_fonts_count: u32, - pub missing_glyph_protection: bool, - pub highlight_alternate_glyphs: bool, - /// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing - /// font is highlighted, in every document (on by default). - pub highlight_substituted_fonts: bool, - pub placeholder_text: bool, - // Units - pub units_general: String, - pub units_stroke: String, - pub units_type: String, - pub units_asian_type: String, - /// Numbers Without Units Are Points: a number typed with no unit into a length field in picas - /// is read in points (on by default; the reference app dims it unless a unit is Picas). - pub numbers_without_units_are_points: bool, - pub identify_objects_by: String, - // Guides & Grid - pub guide_color: String, - pub guide_style: String, - pub grid_color: String, - pub grid_style: String, - pub gridline_every: f64, - pub grid_subdivisions: u32, - pub grids_in_back: bool, - pub show_pixel_grid: bool, - // Smart Guides - pub smart_guide_color: String, - pub alignment_guides: bool, - pub object_highlighting: bool, - pub transform_tools_guides: bool, - pub construction_guides: bool, - pub construction_angles: String, - pub anchor_path_labels: bool, - pub measurement_labels: bool, - pub spacing_guides: bool, - pub distance_guides: bool, - pub snapping_tolerance: f64, - pub snap_tangent: bool, - pub snap_perpendicular: bool, - // Slices - pub show_slice_numbers: bool, - pub slice_line_color: String, - // Hyphenation - pub hyphenation_language: String, - pub hyphenation_exceptions: String, - /// Type › Options › Additional Fonts Folder: a folder (read with its subfolders) whose fonts - /// are listed and used as if installed (#683); empty for none. - pub fonts_folder: String, - // Plug-ins & Scratch Disks - pub plugins_folder: String, - pub scratch_primary: String, - pub scratch_secondary: String, - // User Interface - pub ui_brightness: String, - /// User Interface › Accent Color: the colour of what is on, chosen or focused. - pub ui_accent: String, - /// User Interface › Color Scheme: "modern" (with the accent above) or "classic" (neutral - /// greys and a blue accent). - pub ui_color_scheme: String, - pub canvas_color: String, - pub auto_collapse_icon_panels: bool, - /// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…); - /// off (as it ships, #1736), the tools run on from group to group with nothing between them. - pub tool_group_labels: bool, - pub open_documents_as_tabs: bool, - pub large_tabs: bool, - pub ui_scaling: f64, - pub scale_cursor_with_ui: bool, - /// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`. - /// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`. - pub interface_language: String, - /// Windows and Linux: use the system's title bar and window buttons instead of the app bar - /// acting as the title bar (tiling window managers, desktops that draw their own decorations). - /// Read when the app starts. macOS always uses the system's. - pub system_title_bar: bool, - // Performance - pub gpu_performance: bool, - pub animated_zoom: bool, - /// Which graphics processor the desktop app asks for at startup (it takes effect after a - /// restart): `automatic`, `lowPower` (the integrated GPU on hybrid-graphics machines) or - /// `highPerformance` (the discrete one). The canvas is rasterized on the CPU and only - /// composited on the GPU, so the integrated GPU is plenty; presenting from the discrete GPU - /// through the integrated one made some hybrid laptops flicker (#306). Automatic is power - /// saving on Windows and macOS and the system's default GPU elsewhere, the one the desktop - /// runs on: a Wayland compositor may not show frames from another GPU (#502). Single-GPU - /// machines are unaffected. - pub gpu_preference: String, - pub history_states: u32, - pub real_time_drawing: bool, - /// Rasterizer worker threads; -1 = automatic. - pub render_threads: i32, - /// Linux: `automatic` opens the window on the session's display server; `x11` opens it through - /// XWayland on a Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it - /// through XWayland only when a pen is attached (#2028). Read when the app starts. - pub linux_display_server: String, - // File Handling - pub background_save: bool, - pub background_export: bool, - pub autosave_recovery: bool, - pub autosave_interval: u32, - pub recovery_folder: String, - pub recovery_off_for_complex: bool, - pub recent_files_count: u32, - pub low_res_proxy_eps: bool, - pub anti_aliased_bitmaps: bool, - pub update_links: String, - // Clipboard Handling - pub copy_as_svg: bool, - pub copy_as_pdf: bool, - pub copy_aicb: bool, - pub aicb_mode: String, - pub paste_text_formatting: String, - // Appearance of Black - pub black_on_screen: String, - pub black_output: String, - // Devices - pub touch_workspace: bool, - pub touch_gestures: bool, - // Graphic Styles panel - /// Override Character Color: a graphic style applied to type replaces its characters' fill - /// and stroke with the style's fills and strokes. - pub override_char_color: bool, - // Pattern editing - /// Object → Pattern → Tile Edge Color (`#rrggbb`): the tile edge and swatch bounds in pattern - /// editing mode. - pub pattern_tile_edge_color: String, - /// Eyedropper Options (`eyedropper.setOptions`; a preference group, see - /// [`cmd::prefscmds::PREF_GROUPS`]). - pub eyedropper: EyedropperOptions, - /// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`; a preference group, see - /// [`cmd::prefscmds::PREF_GROUPS`]). - pub pathfinder: PathfinderOptions, - /// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored). - /// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*` - /// edit it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub flattener_presets: Vec, - /// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a - /// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub width_profiles: Vec, - /// General → Use Japanese Crop Marks: the style of Create Trim Marks and Effect → Crop Marks. - pub japanese_crop_marks: bool, - /// Appearance panel → New Art Has Basic Appearance (on): new art takes one fill and stroke; - /// off, the whole appearance of the last selection (`appearance.setNewArtBasic`). - pub new_art_basic: bool, - /// The Color Themes panel's saved themes (`colorTheme.*`): a local library, not a Preferences - /// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub color_themes: Vec, - /// New Document → Saved: the user's document presets (`file.newPresets.save`). A local - /// library, not a Preferences dialog field: resetting the preferences keeps it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub new_doc_presets: Vec, - /// New Document → Recent: the settings of the last documents made (newest first). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub recent_new_docs: Vec, - /// Edit → PDF Presets: the user's presets (the built-in ones aren't stored). A local library, - /// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub pdf_presets: Vec, - /// File → Export for Screens: the user's batch export presets (`document.exportPresets.*`; - /// the built-in ones aren't stored). A local library, not a Preferences dialog field. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub screen_export_presets: Vec, - // File Handling (continued) - /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). - pub templates_folder: String, - /// Open older native files as " [Converted]" so Save asks for a new name. - pub append_converted: bool, - /// File Handling → Use Compression: native saves are gzip-compressed (`document.save - /// {compress}` overrides it). - pub use_compression: bool, - /// Save for Web: the user's presets (the built-in ones aren't stored). A local library, not a - /// Preferences dialog field: resetting the preferences keeps it; `webExport.presets.*` edit it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub web_export_presets: Vec, - /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. - #[serde(skip_serializing_if = "Option::is_none")] - pub web_export_settings: Option, - /// Edit → Print Presets: the user's presets ([Default] isn't stored). A local library, not a - /// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub print_presets: Vec, - /// Constrain Width and Height Proportions: the link between W and H in the Transform panel, - /// the Properties panel and the Control bar (their size fields pass `proportional` to - /// `object.setBounds`). - pub constrain_proportions: bool, - /// One Corner Radius field sets every corner of a live rectangle (off: a field per corner). - pub single_corner_radius: bool, - /// The Transform panel menu's Transform Object Only / Transform Pattern Only / Transform Both - /// (`object`, `pattern`, `both`): what the panel's fields, flips and menu flips transform - /// (they pass `objects` and `patterns` to the transform commands). `default` (and any other - /// value), until one is chosen: they pass neither, so patterns follow General › Transform - /// Pattern Tiles (`transformPatternTiles`), as before the menu acted. - pub transform_panel_patterns: String, - /// The Transform panel menu's Use Registration Point for Symbol: with one symbol instance - /// selected, the panel and the Properties panel's Transform section measure and transform it - /// from its symbol's registration point instead of the reference point. - pub use_registration_point_for_symbol: bool, - /// The tools' persistent options by store (a tool id, or a store a family shares: `liquify` - /// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]: - /// kept across tool switches and saved with the preferences. Not a Preferences dialog field: - /// resetting the preferences keeps them; `tool.setOption` edits them. - #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub tool_settings: std::collections::BTreeMap>, - /// View → Perspective Grid presets: the user's (the built-in ones aren't stored). A local - /// library, not a Preferences dialog field: resetting the preferences keeps it; - /// `perspective.presets.*` edit it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub perspective_presets: Vec, - /// Blend Options set with no blend selected: what new blends start with - /// (`object.blend.options`; none: Smooth Color, Align to Page). A tool setting, not a - /// Preferences dialog field: it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting the - /// preferences keeps it. - #[serde(skip_serializing_if = "Option::is_none")] - pub blend_options: Option, - /// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane - /// Switching Widget shows and where (`perspective.widget.options`). - pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions, - /// What names and codes in other apps' shortcut sets stand for, as the user confirmed them on - /// import (`importMatches.*`). A local library, not a Preferences dialog field: resetting the - /// preferences keeps it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub import_matches: Vec, -} - -impl Default for Prefs { - fn default() -> Self { - let s = |v: &str| v.to_string(); - Self { - keyboard_increment: 1.0, - constrain_angle: 0.0, - corner_radius: 12.0, - disable_auto_add_delete: false, - use_precise_cursors: false, - show_tool_tips: true, - anti_aliased_artwork: true, - select_same_tint_percent: false, - show_home_screen: true, - use_preview_bounds: false, - display_print_size: false, - double_click_to_isolate: true, - transform_pattern_tiles: false, - scale_corners: false, - scale_strokes: false, - zoom_with_mouse_wheel: false, - scrub_numeric_fields: true, - trackpad_rotate_view: true, - paste_offset: 10.0, - selection_tolerance: 3.0, - object_selection_by_path_only: false, - snap_to_point_tolerance: 2.0, - ctrl_click_selects_behind: true, - zoom_to_selection: true, - move_locked_with_artboard: false, - anchor_size: 3, - handle_style: s("solid"), - highlight_anchors_on_hover: true, - show_handles_multiple_anchors: true, - hide_corner_widget_above: 177.0, - pen_rubber_band: true, - curvature_rubber_band: true, - type_size_increment: 2.0, - tracking_increment: 20.0, - baseline_shift_increment: 2.0, - show_east_asian_options: false, - show_indic_options: false, - type_selection_by_path_only: false, - font_names_in_english: true, - auto_size_area_type: false, - font_preview: true, - font_preview_size: s("medium"), - recent_fonts_count: 10, - missing_glyph_protection: true, - highlight_alternate_glyphs: false, - highlight_substituted_fonts: true, - placeholder_text: true, - units_general: s("points"), - units_stroke: s("points"), - units_type: s("points"), - units_asian_type: s("points"), - numbers_without_units_are_points: true, - identify_objects_by: s("objectName"), - guide_color: s("#4affff"), - guide_style: s("lines"), - grid_color: s("#c8c8c8"), - grid_style: s("lines"), - gridline_every: 72.0, - grid_subdivisions: 8, - grids_in_back: true, - show_pixel_grid: true, - smart_guide_color: s("#ff3dfc"), - alignment_guides: true, - object_highlighting: true, - transform_tools_guides: true, - construction_guides: true, - construction_angles: s(vectorcraft_tools::guides::DEFAULT_CONSTRUCTION_ANGLES), - anchor_path_labels: true, - measurement_labels: true, - spacing_guides: true, - distance_guides: true, - snapping_tolerance: 4.0, - snap_tangent: true, - snap_perpendicular: true, - show_slice_numbers: true, - slice_line_color: s("#ff3f3f"), - hyphenation_language: s("English: USA"), - hyphenation_exceptions: String::new(), - fonts_folder: String::new(), - plugins_folder: String::new(), - scratch_primary: s("Startup"), - scratch_secondary: s("None"), - ui_brightness: s("mediumDark"), - ui_accent: s("coral"), - ui_color_scheme: s("modern"), - canvas_color: s("matchUi"), - auto_collapse_icon_panels: false, - tool_group_labels: false, - open_documents_as_tabs: true, - large_tabs: false, - ui_scaling: 1.0, - scale_cursor_with_ui: false, - interface_language: s("auto"), - system_title_bar: false, - gpu_performance: true, - animated_zoom: true, - gpu_preference: s("automatic"), - history_states: 500, - real_time_drawing: true, - render_threads: -1, - linux_display_server: s("automatic"), - background_save: true, - background_export: true, - autosave_recovery: true, - autosave_interval: 2, - recovery_folder: String::new(), - recovery_off_for_complex: false, - recent_files_count: 20, - low_res_proxy_eps: false, - anti_aliased_bitmaps: false, - update_links: s("askWhenModified"), - copy_as_svg: true, - copy_as_pdf: false, - copy_aicb: false, - aicb_mode: s("preserveAppearance"), - paste_text_formatting: s("keep"), - black_on_screen: s("accurate"), - black_output: s("accurate"), - touch_workspace: true, - touch_gestures: true, - override_char_color: true, - pattern_tile_edge_color: { - let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1; - Color::rgb8(r, g, b).to_hex() - }, - eyedropper: Default::default(), - pathfinder: Default::default(), - flattener_presets: vec![], - width_profiles: vec![], - japanese_crop_marks: false, - new_art_basic: true, - color_themes: vec![], - new_doc_presets: vec![], - recent_new_docs: vec![], - pdf_presets: vec![], - screen_export_presets: vec![], - templates_folder: String::new(), - append_converted: true, - use_compression: false, - web_export_presets: vec![], - web_export_settings: None, - print_presets: vec![], - constrain_proportions: false, - single_corner_radius: true, - transform_panel_patterns: "default".to_string(), - use_registration_point_for_symbol: false, - tool_settings: Default::default(), - perspective_presets: vec![], - blend_options: None, - perspective_widget: Default::default(), - import_matches: vec![], - } - } -} - pub struct Session { docs: Vec, active: Option, @@ -1545,1048 +220,6 @@ impl Default for Session { } } -impl Session { - /// A session with no document open, the default preferences and the Selection tool. It also - /// installs the hooks the document crate calls back into: the loader and file reader of placed - /// documents, and where an image's effects draw its fills and strokes, for clicks. - pub fn new() -> Self { - // Placed documents are read by the native format's loader. - vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); - vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again); - vectorcraft_doc::placed_document::set_read_check(|path| file_access::check_read(path).is_ok()); - // The background workers of placed documents read files in the scope that asked. - vectorcraft_render::placed_document::set_scope_capture(|| { - let scope = file_access::ScopeHandle::capture(); - Box::new(move |f: &mut dyn FnMut()| scope.run(f)) - }); - // Clicks find an image's own fills and strokes where their effects draw them. - vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); - // OpenType SVG glyphs are drawn as the SVG importer reads their documents. - vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art); - Self { - docs: vec![], - active: None, - prefs: Prefs::default(), - paint: PaintDefaults::default(), - fill_active: true, - clipboard: Clipboard::default(), - journal: Journal::default(), - tool: vectorcraft_tools::create("selection"), - lender: None, - last_selection_tool: None, - last_view: ViewInfo::default(), - preview_memo: Default::default(), - depth: 0, - tool_panic: None, - tool_resets: 0, - tool_set_since_reset: false, - draw_mode: DrawMode::Normal, - draw_inside: None, - untitled_counter: 0, - menu: Default::default(), - gradient_stop: None, - active_appearance_item: None, - last_solid: Color::WHITE, - last_gradient: GradientPaint::new(Default::default()), - recent_colors: vec![], - pending_paint: None, - swatch_libraries: Default::default(), - freeform_point: None, - style_libraries: Default::default(), - symbol_libraries: Default::default(), - brush_libraries: Default::default(), - libraries: Default::default(), - pdf_preset_dir: None, - profile_dir: None, - plugin_dir: None, - plugin_output: None, - recent_urls: vec![], - ui_language: None, - journal_note: Default::default(), - note_depth: 1, - journal_query: false, - explicit_undo_group: None, - batch_stash: None, - batch_steps: None, - place_hand_off: None, - queue_hand_off: None, - place_cursor_requests: false, - package_hand_off: None, - screens_hand_off: None, - recovery: Default::default(), - in_tool_actions: false, - previewing: false, - envelope_defaults: None, - liquify_stroke: None, - plane_widget_press: false, - pointer_held: false, - ruler_guide: None, - port_marks: Default::default(), - font_search: None, - link_search: None, - search_rules: None, - deferred_overlay_cache: None, - search_threads: None, - link_stamps: Default::default(), - link_scan: None, - global_edit_options: Default::default(), - } - } - - pub fn documents(&self) -> &[DocState] { - &self.docs - } - /// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up). - pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> { - self.docs.iter_mut().find(|d| d.uid == uid) - } - pub fn active_index(&self) -> Option { - self.active - } - pub fn active(&self) -> Option<&DocState> { - self.active.and_then(|i| self.docs.get(i)) - } - pub fn active_mut(&mut self) -> Option<&mut DocState> { - self.active.and_then(|i| self.docs.get_mut(i)) - } - pub fn doc(&self) -> Result<&DocState> { - self.active().ok_or(EngineError::NoDocument) - } - pub fn doc_mut(&mut self) -> Result<&mut DocState> { - self.active_mut().ok_or(EngineError::NoDocument) - } - /// Finish the tool's pending work in the current document and give it fresh state, so nothing - /// (e.g. uncommitted typing) leaks into the document we are switching to. - fn reset_tool_for_doc_switch(&mut self) { - if self.active.is_none() { - return; - } - let view = self.last_view; - // The switch goes on whatever the lent tool's last actions did, as for the deactivation. - let _ = self.give_back_tool(view); - let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); - let _ = self.apply_actions(acts); - // A batch leaves its interaction open in the document it leaves, to keep or roll back with - // the rest of the batch; anything else in progress (a drag) is cancelled. - if self.batch_stash.is_none() { - let _ = self.cancel_interaction(); - } - self.keep_tool_settings(); - self.tool = self.make_tool(self.tool.id()); - self.tool_resets = self.tool_resets.wrapping_add(1); - self.tool_set_since_reset = false; - } - pub fn set_active(&mut self, index: usize) -> bool { - if index < self.docs.len() { - if self.active != Some(index) { - self.reset_tool_for_doc_switch(); - } - self.active = Some(index); - true - } else { - false - } - } - /// The name [`Session::next_untitled`] gives next (New Document's Name field). - pub fn peek_untitled(&self) -> String { - format!("Untitled-{}", self.untitled_counter + 1) - } - pub fn next_untitled(&mut self) -> String { - self.untitled_counter += 1; - format!("Untitled-{}", self.untitled_counter) - } - /// Text layout bounds are a cache (not saved): compute them for a document just read, or - /// selection boxes and hit testing would use the rough estimate until each text is edited, and - /// what is fitted to type's bounds (an envelope warping it) would come out differently. That - /// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too, - /// not only in the layers (#1738). - pub(crate) fn refresh_text_bounds(doc: &mut Document) { - // Inline graphics in text take their size from their symbols' art (not saved either). - doc.resolve_inline_art(); - let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); - let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); - for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { - refresh_tree_text_bounds(tree); - } - } - /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, - /// an undo group with the begun group's name opens in it: here outside a command, and after the - /// command that adds it otherwise ([`Session::execute`]). - pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { - // Every edit is checked against the document before it, so the first one is within the cap - // (importers and the native reader have seen to theirs already, with a warning). - doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); - // Guides from files saved before guides had layers go on a layer. - doc.adopt_guides(); - cmd::dimension::conform(&mut doc); - self.reset_tool_for_doc_switch(); - let mut st = DocState::new(doc, path); - st.history.limit = self.prefs.history_states as usize; - self.docs.push(st); - let i = self.docs.len() - 1; - self.active = Some(i); - if self.depth == 0 - && let Some(label) = self.explicit_undo_group.clone() - { - self.begin_named_undo_group(label); - } - i - } - /// Replace the document in tab `index` (File → Revert): new content, cleared history and - /// selection, saved state. The tab keeps its place, path, format and view. - pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool { - if index >= self.docs.len() { - return false; - } - doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); - doc.adopt_guides(); - cmd::dimension::conform(&mut doc); - if self.active == Some(index) { - // Pending tool work (typing, a drag) belongs to the content being thrown away. - self.reset_tool_for_doc_switch(); - } - let old = &self.docs[index]; - let mut st = DocState::new(doc, old.path.clone()); - st.history.limit = old.history.limit; - // Same open document (caches keyed by uid stay valid); a new revision redraws it. - st.uid = old.uid; - st.revision = old.revision + 1; - st.format = old.format; - st.save_options = old.save_options.clone(); - // The remembered artboard pick follows the artboards it named when the file read back has - // the artboards the document had at the save, in the same order and with the same ids (a - // native file keeps them; a PDF's pages get new ids in page order); otherwise it is left - // out. - if old.save_artboards.is_empty() || st.doc.artboards.iter().map(|a| a.id).eq(old.save_artboards.iter().copied()) { - st.save_artboards = old.save_artboards.clone(); - } else { - for k in cmd::fileio::ARTBOARD_PARAMS { - st.save_options.remove(k); - } - } - st.converted = old.converted; - st.imported_from = old.imported_from.clone(); - st.import_losses = old.import_losses.clone(); - st.view = old.view.clone(); - st.layers_open = old.layers_open.clone(); - let old = std::mem::replace(&mut self.docs[index], st); - if let Some(stash) = &mut self.batch_stash { - stash.push(old); - } - true - } - pub fn close_document(&mut self, index: usize) -> bool { - if index >= self.docs.len() { - return false; - } - self.reset_tool_for_doc_switch(); - // Closed (saved or discarded): nothing left to recover. - let uid = self.docs[index].uid; - cmd::recovery::forget(self, uid); - let old = self.docs.remove(index); - if let Some(stash) = &mut self.batch_stash { - stash.push(old); - } - self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; - true - } - - /// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back. - fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec) -> std::result::Result<(), String> { - let Some(d) = self.active_mut() else { return (spec.enabled)(self) }; - let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() }); - let r = (spec.enabled)(self); - if let Some(d) = self.active_mut() { - d.selection = old; - } - r - } - - /// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or - /// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects. - fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> { - let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) }; - let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) }; - for value in given { - let node = cmd::checked_id(self, value, id)?; - if st.doc.node(node).is_some_and(|n| n.is_layer()) { - return Err(if id.starts_with("object.arrange.") { - cmd::bad(id, format!("id {value} is a layer, not an object; move layers with layer.move")) - } else { - cmd::bad(id, format!("id {value} is a layer, not an object")) - }); - } - } - Ok(()) - } - - /// Execute a command by id. This is THE entry point for every frontend. - pub fn execute(&mut self, id: &str, params: &Value) -> Result { - let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; - // The crate's own tests are the audit of the params descriptions (#1442): a key a test - // passes that its command's description doesn't name fails the test. - #[cfg(test)] - self.check_params(id, params)?; - // Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked - // before anything else: a malformed one, or one naming no object, is an error under this - // command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}` - // without `effect` names the effect (its older key), not an object. - let effect_named = id == "effect.apply" - && params.get("effect").is_none() - && params.get("ids").is_none_or(Value::is_null) - && params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$')); - if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() { - cmd::given_objects(self, params, id)?; - } - if SELECTS_GIVEN_IDS.contains(&id) { - self.check_given_ids(id, params)?; - } - // Clear's menu needs a selection; an agent supplying explicit targets only needs a - // document. The command validates those targets before making any edit. - let given = given_ids(spec.params, params); - let enabled = if id == "edit.clear" && params.get("ids").is_some() { - cmd::has_doc(self) - } else if let Some(ids) = given.clone() { - // Objects given as `ids` (or `id`) stand in for the selection: the command is enabled - // as it is with them selected (#1575). - self.enabled_with_selection(spec, ids) - } else { - (spec.enabled)(self) - }; - if let Err(why) = enabled { - return Err(EngineError::Disabled(id.to_string(), why)); - } - // Only top-level commands are journaled (commands that call other commands would otherwise - // be recorded twice and replay differently). - if self.depth == 0 { - self.journal_note.clear(); - self.note_depth = 1; - self.journal_query = false; - self.batch_stash = None; - self.batch_steps = None; - } - // A selection-only command given `ids` (or `id`) selects them first, in the same undo step - // (#2130); when the command then fails, the selection (and the current layer selecting - // them made current) goes back to how it was, as when the ids are refused. - let select_first = SELECTS_GIVEN_IDS - .contains(&id) - .then(|| { - params.get("ids").filter(|v| !v.is_null()).cloned().or_else(|| given.map(|ids| ids.iter().map(|i| i.0).collect::>().into())) - }) - .flatten(); - let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer)); - let run = |s: &mut Session| { - if let Some(ids) = &select_first { - s.execute("select.set", &serde_json::json!({ "ids": ids }))?; - } - let r = (spec.run)(s, params); - if r.is_err() - && let Some((uid, selection, layer)) = selected_before - && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) - { - st.selection = selection; - st.active_layer = layer; - st.revision += 1; - } - r - }; - // The entries the command journals belong to the undo groups of the documents it edits, and to - // the group of the document it starts in when it ends there too. - let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); - let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::>()); - let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; - let r = r?; - if self.depth == 0 { - self.inherit_new_art(); - } - if let Some(open) = open { - self.open_undo_groups_in_new_documents(&open); - } - let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); - if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { - let p = self.noted(params); - self.journal.push((id.to_string(), p)); - } - if self.depth == 0 { - self.journal_in_undo_groups(doc, &grouped, journal); - self.after_command(); - } - Ok(r) - } - - /// Is the interface in Japanese? Then new type starts with em box top-to-top leading and em - /// box centre character alignment (#432). - pub fn japanese_interface(&self) -> bool { - self.ui_language.as_deref().unwrap_or(&self.prefs.interface_language).eq_ignore_ascii_case("ja") - } - - /// Record `key: value` in the running top-level command's journal entry (or its interaction's - /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the - /// preferences or the clock. - pub fn note_journal(&mut self, key: &str, value: Value) { - if self.depth == self.note_depth { - self.journal_note.insert(key.to_string(), value); - } - } - - /// Journal the running query command when it is the top-level command: it changed the - /// selection (`document.find {select: true}`), so an action recorded around it repeats that. - pub(crate) fn journal_query(&mut self) { - if self.depth == 1 { - self.journal_query = true; - } - } - - /// Run `id` as a step of the running command, which journals its steps (`command.batch`): - /// → the step's result and its params with what it noted ([`Session::note_journal`]), for the - /// step in the running command's journal entry. - pub(crate) fn execute_step(&mut self, id: &str, params: &Value) -> Result<(Value, Value)> { - let outer = (std::mem::take(&mut self.journal_note), self.note_depth); - self.note_depth = self.depth + 1; - let r = self.execute(id, params); - let noted = self.noted(params); - (self.journal_note, self.note_depth) = outer; - Ok((r?, noted)) - } - - /// The journal from entry `start` on, as an action records it: without the params that only - /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. - pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { - self.journal - .since(start) - .iter() - .cloned() - .map(|(id, mut p)| { - strip_replay_only(&id, &mut p); - (id, p) - }) - .collect() - } - - /// `params` with the noted values added (see [`Session::note_journal`]). - fn noted(&mut self, params: &Value) -> Value { - let mut note = std::mem::take(&mut self.journal_note); - match params { - // The params' own values win. - Value::Object(m) if !note.is_empty() => { - note.extend(m.clone()); - Value::Object(note) - } - Value::Null if !note.is_empty() => Value::Object(note), - _ => params.clone(), - } - } - - fn run_nested(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { - self.depth += 1; - let r = f(self); - self.depth -= 1; - r - } - - /// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active - /// document goes back to how it was before the command, instead of crashing the frontend. - fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { - let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone())); - let active = self.active; - let r = match guard::catch_panic(|| self.run_nested(f)) { - Ok(r) => r, - Err(msg) => { - self.depth = 0; - self.journal_note.clear(); - if let Some((uid, doc, selection, interaction)) = snapshot - && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) - { - st.doc = doc; - st.selection = selection; - st.interaction = interaction; - cmd::globaledit::follow_history(st); - st.revision += 1; - } - if active.is_some_and(|i| i < self.docs.len()) { - self.active = active; - } - Err(EngineError::Internal { cmd: id.to_string(), msg }) - } - }; - // Undo, Redo and a failed batch bring back documents with their selections: each document - // gets its selection's new art on a locked artboard. - for st in &mut self.docs { - st.sync_new_art(); - } - r - } - - /// Run `f` on a mutable copy of the active document. Outside an interaction this records one - /// undo step labelled `label`; inside one it just mutates (the interaction commits later). - pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { - // An edit made while typing (the Transform panel) is a step of its own: inside the typing - // session, the next keystroke's preview would start again from before it (#1881). - if !self.previewing && cmd::typing_in_progress(self) { - cmd::settle_interaction(self)?; - } - let menu = self.menu.clone(); - let st = self.doc_mut()?; - let before = st.doc.clone(); - let before_sel = st.selection.clone(); - let doc = Arc::make_mut(&mut st.doc); - // The Global Edit members the edit reached besides the selection. - let mut spread = vec![]; - // Objects attached to a path follow it, and dimensions follow the art they measure. - let result = match f(doc, &mut st.selection).and_then(|v| { - // Global Edit: the other members take what the edit changed about the one it edited. - if let Some(ge) = &st.global_edit { - spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); - } - cmd::objects_on_path::refresh(&before, doc); - cmd::dimension::refresh(&before, doc)?; - Ok(v) - }) { - Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) { - Err(e) => Err(e), - Ok(()) => { - // Attached Shaper groups whose art was rebuilt go back on their path. - cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc)); - // Inline graphics in text follow their symbols. - cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc)); - // Text Wrap: area type follows its wrap objects; then threads re-flow. - cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); - // Type in no thread numbers its list paragraphs from 1 (before the mask copies - // its editing layer's art). - cmd::threads::restart_unthreaded_counts(&mut st.doc); - // Opacity-mask editing: the mask follows its art on the editing layer. - if st.doc.mask_edit.is_some() { - cmd::maskedit::sync(Arc::make_mut(&mut st.doc)); - } - // Threaded text re-flows when any of its frames changed. - if !st.doc.text_threads.is_empty() { - cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); - } - // Asset Export: assets let go of deleted art. - if !st.doc.assets.is_empty() { - Arc::make_mut(&mut st.doc).prune_assets(); - } - // Variables: bindings let go of deleted art. - if !st.doc.variables.bindings.is_empty() { - Arc::make_mut(&mut st.doc).prune_variable_bindings(); - } - if st.doc.too_deep(Some(&before)) { - // Deeper trees overflow the stack of what walks them. - Err(EngineError::Other(TOO_DEEP.into())) - } else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) { - Ok(v) - } else { - Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) - } - } - }, - Err(e) => Err(e), - }; - match result { - Ok(v) => { - st.selection.prune(&st.doc); - // An edit that selects what it unlocks, shows or pastes leaves locked guides out, - // and one that selects the slices it makes leaves them out while they are locked - // or hidden. - deselect_hidden(&menu, &st.doc, &mut st.selection); - // The art on a locked artboard can't stay selected, however it got there, except - // the new art this edit made and new art still selected. - st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before)); - st.revision += 1; - if st.interaction.is_none() { - let selection = match st.history.undo.last() { - Some(l) if *l.selection == before_sel => l.selection.clone(), - _ => Arc::new(before_sel), - }; - st.push_undo(label.to_string(), before, selection); - } - Ok(v) - } - Err(e) => { - st.doc = before; - st.selection = before_sel; - cmd::globaledit::follow_history(st); - Err(e) - } - } - } - - /// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name` - /// (a style's name). The undo step it records keeps the template and the name - /// ([`HistoryEntry::named`]), and a frontend shows the template's translation with the name in - /// it. An edit that records no step of its own (inside an interaction, or a later edit of an - /// undo group) keeps neither, and neither does a step an undo group names. - pub fn edit_named( - &mut self, - template: &'static str, - name: &str, - built_in: bool, - f: impl FnOnce(&mut Document, &mut Selection) -> Result, - ) -> Result { - let named = NamedStep { template, name: name.to_string(), built_in }; - let step = named.label(); - // The step this edit records keeps the document as it is now. - let now = Arc::as_ptr(&self.doc()?.doc); - let v = self.edit(&step, f)?; - if let Ok(st) = self.doc_mut() - && let Some(e) = st.history.undo.last_mut() - && Arc::as_ptr(&e.doc) == now - && e.label == step - { - e.named = Some(named); - } - Ok(v) - } - - /// Change only the selection (not an undo step). - pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { - self.active_appearance_item = None; - let menu = self.menu.clone(); - let st = self.doc_mut()?; - let before = st.selection.objects.clone(); - let new_art = st.selection.new_art.clone(); - f(&st.doc, &mut st.selection); - st.selection.prune(&st.doc); - // Lock Guides locks the paths made into guides too (#1534), and Lock Slices and Hide - // Slices keep the slices out, whatever selects them. - deselect_hidden(&menu, &st.doc, &mut st.selection); - // The art on a locked artboard can't be selected, whatever selects it, except new art - // that was selected and still is. - st.protect_locked_artboards(&before, &new_art, None); - // Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of - // the reference app: new art then goes beside it. - if st.selection.objects != before - && let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l)) - && st.active_layer != Some(layer) - { - st.active_layer = Some(layer); - st.layer_rows.clear(); - } - st.revision += 1; - // Puppet Warp pins belong to the art they were placed on: another selection starts afresh. - if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) { - cmd::distortcmds::drop_puppet_pins(st); - } - Ok(()) - } - - // ---------- interactions (live drags) ---------- - - pub(crate) fn clear_deferred_overlay_cache(&mut self) { - self.deferred_overlay_cache = None; - } - - /// The interaction's latest preview command when the document is still its snapshot (deferred - /// Real-time Drawing), regardless of the preference at commit or overlay time. - pub(crate) fn unapplied_interaction_preview(&self) -> Option<(String, Value)> { - self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone())) - } - - pub fn begin_interaction(&mut self, label: &str) -> Result<()> { - // Typing in progress is kept first: a drag or a dialog's preview would otherwise start - // from the snapshot taken before the typing, and drop it (#1881). - if label != "Typing" && cmd::typing_in_progress(self) { - cmd::settle_interaction(self)?; - } - self.clear_deferred_overlay_cache(); - let st = self.doc_mut()?; - if st.interaction.is_some() { - return Ok(()); - } - st.interaction = Some(Interaction { - label: label.to_string(), - doc: st.doc.clone(), - selection: Arc::new(st.selection.clone()), - preview: None, - active_layer: st.active_layer, - layer_rows: st.layer_rows.clone(), - variables_highlight: st.variables_highlight.clone(), - isolation: st.isolation, - perspective_again: None, - copies: None, - }); - Ok(()) - } - - /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). - pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { - { - let menu = self.menu.clone(); - let st = self.doc_mut()?; - let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; - // Undoing the previous preview counts as a change, so views redraw even when the command - // below returns without an edit. - let selection_changed = st.selection != *it.selection; - if !Arc::ptr_eq(&st.doc, &it.doc) || selection_changed { - st.revision += 1; - } - st.doc = it.doc.clone(); - if selection_changed { - st.selection.clone_from(&it.selection); - } - // Guides locked and slices locked or hidden during the interaction stay deselected, - // even when the preview fails. - deselect_hidden(&menu, &st.doc, &mut st.selection); - cmd::globaledit::follow_history(st); - } - // The previous preview's paint is undone with it. - self.pending_paint = None; - let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params).or_else(|| cmd::bounds_rewrite(self, cmd, params)); - let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); - let outer = std::mem::replace(&mut self.previewing, true); - let r = self.execute(cmd, params); - self.previewing = outer; - let params = self.noted(params); - let st = self.doc_mut()?; - if let Some(it) = &mut st.interaction { - // A failed preview leaves the document at the snapshot, so nothing is left to commit - // (not even an earlier successful preview, which the reset above already undid). - it.preview = r.is_ok().then(|| (cmd.to_string(), params)); - } - r - } - - pub fn commit_interaction(&mut self) -> Result<()> { - self.clear_deferred_overlay_cache(); - self.remember_pending_paint(); - let st = self.doc_mut()?; - let Some(mut it) = st.interaction.take() else { return Ok(()) }; - let Some(preview) = it.preview.take() else { return Ok(()) }; - let perspective_again = it.perspective_again.take(); - let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); - // Alt pressed or released during a move drag turns it into a copy or back: name it for - // what it did in the end. - match (it.label.as_str(), copy) { - ("Move", true) => it.label = "Copy".into(), - ("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(), - _ => {} - } - // A preview that left the document as it was records no Transform Again either (as - // `keep_interaction` records no undo step): the last real transformation stands. - let changed = !Arc::ptr_eq(&it.doc, &st.doc); - st.keep_interaction(it); - if preview.0 == "object.transform" { - let m = cmd::matrix_param(&preview.1, "matrix"); - if let Some(m) = m { - // The options are the last preview's params with the values it noted, without the - // matrix and the ids. - let mut options = preview.1.clone(); - if let Some(o) = options.as_object_mut() { - o.remove("matrix"); - o.remove("ids"); - } - st.last_transform = Some(LastTransform::Matrix(m, options)); - st.last_perspective = None; - } - } else if preview.0 == "object.transformEach" && changed { - // Transform Again repeats it as it stood when OK was pressed. - st.last_transform = Some(LastTransform::Each(preview.1.clone())); - st.last_perspective = None; - } - if perspective_again.is_some() { - st.last_perspective = perspective_again; - } - let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); - self.journal.push(preview); - self.journal_in_undo_groups(doc, &grouped, journal); - Ok(()) - } - - /// Remember the paint a live preview applied (as its interaction is kept). - pub(crate) fn remember_pending_paint(&mut self) { - if let Some(p) = self.pending_paint.take() { - self.remember_paint_now(&p); - } - } - - pub fn cancel_interaction(&mut self) -> Result<()> { - self.clear_deferred_overlay_cache(); - self.pending_paint = None; - let menu = self.menu.clone(); - let st = self.doc_mut()?; - st.undo_interaction(); - // Guides locked and slices locked or hidden during the interaction stay deselected. - deselect_hidden(&menu, &st.doc, &mut st.selection); - Ok(()) - } - - pub fn in_interaction(&self) -> bool { - self.active().is_some_and(|d| d.interaction.is_some()) - } - - // ---------- undo groups (scrubbed numeric fields) ---------- - - /// Open an undo group in the active document: until [`Session::end_undo_group`], the edits - /// made there (commands, committed interactions) are one undo step. A scrubbed numeric field - /// applies each value it passes as its own command, as a typed value is applied; the drag is - /// one step. - pub fn begin_undo_group(&mut self) { - self.begin_named_undo_group(None); - } - - /// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit). - pub fn begin_named_undo_group(&mut self, label: Option) { - if let Some(st) = self.active_mut() - && st.undo_group.is_none() - { - st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label }); - } - } - - /// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, open an undo group with the begun - /// group's name in each document that the top-level command just run opened or created (its - /// [`DocState::uid`] is not in `open`). The group takes the edits after that command: the - /// command's own edits there stay its undo step, and a canceled group keeps them, as the journal - /// entry that made the document stays. - fn open_undo_groups_in_new_documents(&mut self, open: &[u64]) { - let Some(label) = &self.explicit_undo_group else { return }; - for st in &mut self.docs { - if !open.contains(&st.uid) && st.undo_group.is_none() { - st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label: label.clone() }); - } - } - } - - /// The documents with an undo group open ([`DocState::uid`]), each with the document it holds. - fn undo_group_docs(&self) -> Vec<(u64, *const Document)> { - self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect() - } - - /// Note the journal entries from number `from` on in the undo groups they belong to: the group of - /// each document in `grouped` ([`Session::undo_group_docs`] before the entries) that they changed, - /// each group they opened and edited, and the group of the document with [`DocState::uid`] `doc` - /// when they started in it and it is still the active one. A cancel drops them. The last entry of a command that changed the - /// active document keeps the index of the document it left active. A `command.batch` entry also - /// keeps, in each group, the steps that belong to it ([`Session::batch_steps`]). - fn journal_in_undo_groups(&mut self, doc: Option, grouped: &[(u64, *const Document)], from: usize) { - let to = self.journal.len(); - let active = self.active().map(|d| d.uid); - let switched = if active == doc { None } else { self.active }; - let batch = self.batch_steps.take().filter(|_| to > from && self.journal.get(to - 1).is_some_and(|(id, _)| id == "command.batch")); - for st in &mut self.docs { - let Some(g) = &mut st.undo_group else { continue }; - let started = doc == Some(st.uid) && active == doc; - // A group the entries opened has an edit of theirs once it has a first edit. - let edited = match grouped.iter().find(|&&(uid, _)| uid == st.uid) { - Some(&(_, d)) => !std::ptr::eq(d, Arc::as_ptr(&st.doc)), - None => g.first.is_some(), - }; - if started || edited { - // A committed interaction inside the command noted its entry already. - let noted = g.journal.partition_point(|&(n, _)| n < from); - g.journal.truncate(noted); - g.journal.extend((from..to).map(|n| (n, switched.filter(|_| n + 1 == to)))); - g.steps.retain(|&(n, _)| n < from); - if let Some(steps) = &batch { - let own = steps.iter().enumerate().filter(|(_, (uids, _))| uids.contains(&st.uid)); - g.steps.push((to - 1, own.map(|(i, &(_, switched))| (i, switched)).collect())); - } - } - } - } - - /// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are - /// undone and dropped from the journal. A group begun with `edit.undoGroup.begin` ends too, so - /// documents opened after this open no undo group. - pub fn end_undo_group(&mut self, cancel: bool) { - self.explicit_undo_group = None; - self.close_undo_groups(cancel, None); - } - - /// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`, for the - /// document with [`DocState::uid`] `only` alone when given. The groups that stay open follow the - /// journal entries and batch steps of theirs that a cancel renumbers. - fn close_undo_groups(&mut self, cancel: bool, only: Option) { - let mut journal: Vec<(usize, Option)> = vec![]; - let mut steps: Vec = vec![]; - for st in &mut self.docs { - if only.is_some_and(|uid| uid != st.uid) { - continue; - } - let Some(g) = st.undo_group.take() else { continue }; - // Only while the group's step is still the newest one. - if cancel - && let Some(first) = g.first - && st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, &first)) - && let Some(e) = st.history.undo.pop() - { - st.doc = e.doc; - st.selection = Arc::unwrap_or_clone(e.selection); - deselect_hidden(&self.menu, &st.doc, &mut st.selection); - st.sync_new_art(); - cmd::globaledit::follow_history(st); - st.revision += 1; - // The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile. - journal.extend(g.journal); - steps.extend(g.steps); - } - } - // A batch that edited two of the documents is in both groups. - journal.sort_unstable(); - journal.dedup_by_key(|&mut (n, _)| n); - // A batch with steps outside the canceled groups keeps those steps. - let mut rewritten: Vec<(usize, Vec>)> = vec![]; - journal.retain(|&(n, _)| match self.keep_batch_steps(n, &steps) { - Some(kept) => { - rewritten.push((n, kept)); - false - } - None => true, - }); - // An entry that changed the active document becomes that change, so the entries after it run - // in the same document when the journal is replayed. - for &(n, index) in &journal { - if let Some(index) = index { - self.journal.replace(n, ("document.activate".to_string(), serde_json::json!({ "index": index }))); - } - } - let gone: Vec = journal.into_iter().filter(|(_, index)| index.is_none()).map(|(n, _)| n).collect(); - self.journal.remove(&gone); - let below = |n: usize| gone.partition_point(|&m| m < n); - for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) { - g.journal.retain(|(n, _)| gone.binary_search(n).is_err()); - for (n, _) in &mut g.journal { - *n -= below(*n); - } - g.steps.retain(|(n, _)| gone.binary_search(n).is_err()); - for (n, own) in &mut g.steps { - if let Some((_, kept)) = rewritten.iter().find(|(r, _)| r == n) { - own.retain_mut(|(i, _)| kept.get(*i).copied().flatten().map(|k| *i = k).is_some()); - } - *n -= below(*n); - } - } - } - - /// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and - /// other steps that are not `document.activate`: rewrite its `commands` without the noted steps - /// (a noted step that changed the active document becomes that change) → each step's number in - /// the rewritten `commands`, `None` for a noted step. `None` leaves the entry to be dropped as a - /// whole. - fn keep_batch_steps(&mut self, n: usize, steps: &[BatchSteps]) -> Option>> { - let noted: Vec<(usize, Option)> = steps.iter().filter(|(e, _)| *e == n).flat_map(|(_, s)| s.iter().copied()).collect(); - let (id, entry) = self.journal.get(n).filter(|(id, _)| id == "command.batch" && steps.iter().any(|(e, _)| *e == n))?; - let commands = entry.get("commands").and_then(Value::as_array)?; - let kept = |i: usize| !noted.iter().any(|&(s, _)| s == i); - let activate = |c: &Value| c.get("command").and_then(Value::as_str) == Some("document.activate"); - if commands.iter().enumerate().all(|(i, c)| !kept(i) || activate(c)) { - return None; - } - let (mut rewritten, mut numbers) = (Vec::with_capacity(commands.len()), Vec::with_capacity(commands.len())); - for (i, c) in commands.iter().enumerate() { - if kept(i) { - numbers.push(Some(rewritten.len())); - rewritten.push(c.clone()); - } else { - numbers.push(None); - if let Some(index) = noted.iter().find(|&&(s, _)| s == i).and_then(|&(_, index)| index) { - rewritten.push(serde_json::json!({ "command": "document.activate", "params": { "index": index } })); - } - } - } - let (id, mut entry) = (id.clone(), entry.clone()); - if let Some(o) = entry.as_object_mut() { - o.insert("commands".into(), Value::Array(rewritten)); - } - self.journal.replace(n, (id, entry)); - Some(numbers) - } - - /// [`Session::end_undo_group`] for the open document with [`DocState::uid`] `uid` only (it may - /// have closed since): its open undo group closes, and undo groups open in other documents stay - /// open. - fn end_document_undo_group(&mut self, uid: u64, cancel: bool) { - self.close_undo_groups(cancel, Some(uid)); - } - - /// Make the edits that follow in the active document one undo step, until - /// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it, - /// so that the edits are part of its step. `None` without a document. - pub fn begin_undo_scope(&mut self) -> Option { - let journal = self.journal.len(); - let st = self.active()?; - let uid = st.uid; - let joined = st.undo_group.as_ref().map(|g| (st.doc.clone(), st.selection.clone(), g.first.clone())); - if joined.is_none() { - self.begin_undo_group(); - } - Some(UndoScope { uid, journal, joined }) - } - - /// End `scope` in its own document, whichever document is active now; undo groups open in other - /// documents stay open. The undo group the scope opened closes as [`Session::end_undo_group`] - /// closes it. A scope that joined an open group leaves the group open; with `cancel`, it puts the - /// document and its selection back as they were when the scope began and cuts the journal back to - /// where the scope began (only while the group's step is still the newest one), and an undo step - /// that the scope's first edit recorded goes too. The open undo groups drop the cut entries. - pub fn end_undo_scope(&mut self, scope: UndoScope, cancel: bool) { - let Some((doc, selection, first)) = scope.joined else { - self.end_document_undo_group(scope.uid, cancel); - return; - }; - let menu = self.menu.clone(); - let Some(st) = self.document_mut(scope.uid) else { return }; - let Some(g) = &mut st.undo_group else { return }; - let newest = g.first.as_ref().is_some_and(|f| st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, f))); - if !cancel || !newest { - return; - } - match &first { - // The scope's first edit recorded the group's step. - None => { - st.history.undo.pop(); - g.first = None; - } - Some(f) if g.first.as_ref().is_some_and(|now| Arc::ptr_eq(now, f)) => {} - Some(_) => return, - } - st.doc = doc; - st.selection = selection; - cmd::globaledit::follow_history(st); - deselect_hidden(&menu, &st.doc, &mut st.selection); - st.history.recost_last(&st.doc); - st.revision += 1; - self.journal.truncate(scope.journal); - // The next commands take the cut entries' numbers, so no open undo group keeps them. - for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) { - g.journal.retain(|&(n, _)| n < scope.journal); - g.steps.retain(|&(n, _)| n < scope.journal); - } - } - - /// Commands with enablement (for menus, palette, MCP `list_commands`). - pub fn commands(&self) -> Vec { - command_specs().iter().map(|c| c.info(self)).collect() - } -} - -/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the -/// nodes on the way to such type are copied. -fn refresh_tree_text_bounds(n: &mut Arc) { - fn stale(n: &vectorcraft_doc::Node) -> bool { - matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none()) - || n.extras().mask.as_deref().is_some_and(|m| stale(&m.art)) - || n.children().is_some_and(|ch| ch.iter().any(|c| stale(c))) - } - if !stale(n) { - return; - } - let n = Arc::make_mut(n); - if let NodeKind::Text(t) = &mut n.kind - && t.cached_bounds.is_none() - { - cmd::typecmd::refresh_bounds(t); - } - if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - refresh_tree_text_bounds(&mut m.art); - } - for c in n.children_mut().into_iter().flatten() { - refresh_tree_text_bounds(c); - } -} - /// Held by the tests that add fonts to the shared [`vectorcraft_text::FontDb::global`] for as long /// as they run: adding a font clears the database's caches, which would change how another such /// test's type resolves between its layout and its export. @@ -2596,392 +229,34 @@ pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +#[cfg(test)] +use tests::{ + adjust as tests_adjust, clip as tests_clip, colormgmt as tests_colormgmt, expand as tests_expand, large_edits as tests_large_edits, + links as tests_links, linkspanel as tests_linkspanel, menucmds as tests_menucmds, outlinestroke as tests_outlinestroke, + persp_select as tests_persp_select, textimport as tests_textimport, +}; + +mod command_sets; +mod doc_state; +mod error; +mod history; +mod interaction; +mod prefs; +mod sanity; +mod session; + +#[cfg(test)] +pub(crate) use command_sets::SELECTS_GIVEN_IDS; +#[cfg(test)] +use command_sets::given_ids; +pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD}; +pub use doc_state::{DocState, OpenRows, RecalledSelection}; +pub use error::{EngineError, Result}; +pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; +pub(crate) use interaction::PreviewCopies; +pub use interaction::{Interaction, LastTransform, UndoGroup, UndoScope}; +pub use prefs::{DrawMode, Prefs}; +pub use sanity::TOO_DEEP; +pub(crate) use sanity::{deselect_hidden, is_guide_path}; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_adjust; -#[cfg(test)] -mod tests_appearance; -#[cfg(test)] -mod tests_areafit; -#[cfg(test)] -mod tests_assets; -#[cfg(test)] -mod tests_attributes; -#[cfg(test)] -mod tests_bboxrotate; -#[cfg(test)] -mod tests_blendfidelity; -#[cfg(test)] -mod tests_blendopts; -#[cfg(test)] -mod tests_blendspine; -#[cfg(test)] -mod tests_brushlib; -#[cfg(test)] -mod tests_brushsym; -#[cfg(test)] -mod tests_build; -#[cfg(test)] -mod tests_cancel; -#[cfg(test)] -mod tests_charstroke; -#[cfg(test)] -mod tests_clip; -#[cfg(test)] -mod tests_clipboard; -#[cfg(test)] -mod tests_clipflavours; -#[cfg(test)] -mod tests_clippaint; -#[cfg(test)] -mod tests_cmdsplit; -#[cfg(test)] -mod tests_cmykflatten; -#[cfg(test)] -mod tests_colorguide; -#[cfg(test)] -mod tests_colorguidelib; -#[cfg(test)] -mod tests_colormgmt; -#[cfg(test)] -mod tests_colorthemes; -#[cfg(test)] -mod tests_colourfonts; -#[cfg(test)] -mod tests_compoundpaint; -#[cfg(test)] -mod tests_constrain_new; -#[cfg(test)] -mod tests_constrain_tools; -#[cfg(test)] -mod tests_containers; -#[cfg(test)] -mod tests_css; -#[cfg(test)] -mod tests_cut; -#[cfg(test)] -mod tests_dashalign; -#[cfg(test)] -mod tests_distort; -#[cfg(test)] -mod tests_docsetup; -#[cfg(test)] -mod tests_draw2; -#[cfg(test)] -mod tests_editcolors; -#[cfg(test)] -mod tests_effectedit; -#[cfg(test)] -mod tests_emptytype; -#[cfg(test)] -mod tests_envelope; -#[cfg(test)] -mod tests_envelope_distort; -#[cfg(test)] -mod tests_envelope_edit; -#[cfg(test)] -mod tests_expand; -#[cfg(test)] -mod tests_eyedropper; -#[cfg(test)] -mod tests_file; -#[cfg(test)] -mod tests_fileinfo; -#[cfg(test)] -mod tests_flatpresets; -#[cfg(test)] -mod tests_flatpreview; -#[cfg(test)] -mod tests_flatten; -#[cfg(test)] -mod tests_focal; -#[cfg(test)] -mod tests_fontfiles; -#[cfg(test)] -mod tests_fontlist; -#[cfg(test)] -mod tests_freeform; -#[cfg(test)] -mod tests_globaledit; -#[cfg(test)] -mod tests_gradient; -#[cfg(test)] -mod tests_gradpanel; -#[cfg(test)] -mod tests_gradspan; -#[cfg(test)] -mod tests_halftone; -#[cfg(test)] -mod tests_history_mem; -#[cfg(test)] -mod tests_ids_without_selection; -#[cfg(test)] -mod tests_inline; -#[cfg(test)] -mod tests_interactivity; -#[cfg(test)] -mod tests_intertwine; -#[cfg(test)] -mod tests_journal; -#[cfg(test)] -mod tests_keyguide; -#[cfg(test)] -mod tests_knockout; -#[cfg(test)] -mod tests_labspots; -#[cfg(test)] -mod tests_large_edits; -#[cfg(test)] -mod tests_layerclip; -#[cfg(test)] -mod tests_layers; -#[cfg(test)] -mod tests_legacy_ai; -#[cfg(test)] -mod tests_library; -#[cfg(test)] -mod tests_link_paths; -#[cfg(test)] -mod tests_linked_stops; -#[cfg(test)] -mod tests_linkfiles; -#[cfg(test)] -mod tests_links; -#[cfg(test)] -mod tests_linkspanel; -#[cfg(test)] -mod tests_linkwatch; -#[cfg(test)] -mod tests_liquify; -#[cfg(test)] -mod tests_live; -#[cfg(test)] -mod tests_livecorners; -#[cfg(test)] -mod tests_maskview; -#[cfg(test)] -mod tests_menucmds; -#[cfg(test)] -mod tests_nativefile; -#[cfg(test)] -mod tests_newart; -#[cfg(test)] -mod tests_newdoc; -#[cfg(test)] -mod tests_objects_on_path; -#[cfg(test)] -mod tests_objexpand; -#[cfg(test)] -mod tests_opacitymask; -#[cfg(test)] -mod tests_outlineobject; -#[cfg(test)] -mod tests_outlinestroke; -#[cfg(test)] -mod tests_overprint; -#[cfg(test)] -mod tests_package; -#[cfg(test)] -mod tests_paintproxy; -#[cfg(test)] -mod tests_panelcmds; -#[cfg(test)] -mod tests_paragraphs; -#[cfg(test)] -mod tests_params; -#[cfg(test)] -mod tests_pathops; -#[cfg(test)] -mod tests_pathtype; -#[cfg(test)] -mod tests_pattern; -#[cfg(test)] -mod tests_pdffidelity; -#[cfg(test)] -mod tests_pdfmarks; -#[cfg(test)] -mod tests_pdfpresets; -#[cfg(test)] -mod tests_pdfraster; -#[cfg(test)] -mod tests_persp_planes; -#[cfg(test)] -mod tests_persp_select; -#[cfg(test)] -mod tests_persp_text; -#[cfg(test)] -mod tests_perspgrid; -#[cfg(test)] -mod tests_place; -#[cfg(test)] -mod tests_placed_document; -#[cfg(test)] -mod tests_plugins; -#[cfg(test)] -mod tests_prefs; -#[cfg(test)] -mod tests_previewbounds; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_printadvanced; -#[cfg(test)] -mod tests_printpresets; -#[cfg(test)] -mod tests_printpreview; -#[cfg(test)] -mod tests_printps; -#[cfg(test)] -mod tests_printtiling; -#[cfg(test)] -mod tests_proxyitems; -#[cfg(test)] -mod tests_puppetwarp; -#[cfg(test)] -mod tests_rasterfilters; -#[cfg(test)] -mod tests_rastersettings; -#[cfg(test)] -mod tests_recolor; -#[cfg(test)] -mod tests_recovery; -#[cfg(test)] -mod tests_reflecthit; -#[cfg(test)] -mod tests_registration; -#[cfg(test)] -mod tests_save; -#[cfg(test)] -mod tests_saveoptions; -#[cfg(test)] -mod tests_scalestrokes; -#[cfg(test)] -mod tests_scripts; -#[cfg(test)] -mod tests_select_ids_first; -#[cfg(test)] -mod tests_shaper; -#[cfg(test)] -mod tests_shortcutset; -#[cfg(test)] -mod tests_simplify_part; -#[cfg(test)] -mod tests_slices; -#[cfg(test)] -mod tests_smartguides; -#[cfg(test)] -mod tests_store; -#[cfg(test)] -mod tests_strokegeom; -#[cfg(test)] -mod tests_strokegradient; -#[cfg(test)] -mod tests_strokereach; -#[cfg(test)] -mod tests_strokeux; -#[cfg(test)] -mod tests_stylelib; -#[cfg(test)] -mod tests_stylepanel; -#[cfg(test)] -mod tests_styles; -#[cfg(test)] -mod tests_swatchcmds; -#[cfg(test)] -mod tests_swatches; -#[cfg(test)] -mod tests_swatchlib; -#[cfg(test)] -mod tests_symbolbounds; -#[cfg(test)] -mod tests_symbollib; -#[cfg(test)] -mod tests_targeting; -#[cfg(test)] -mod tests_textcombos; -#[cfg(test)] -mod tests_textedit; -#[cfg(test)] -mod tests_textimport; -#[cfg(test)] -mod tests_tileedge; -#[cfg(test)] -mod tests_tints; -#[cfg(test)] -mod tests_toolsettings; -#[cfg(test)] -mod tests_transformfx; -#[cfg(test)] -mod tests_transparencygrid; -#[cfg(test)] -mod tests_trap; -#[cfg(test)] -mod tests_typearea; -#[cfg(test)] -mod tests_typescale; -#[cfg(test)] -mod tests_units; -#[cfg(test)] -mod tests_variables; -#[cfg(test)] -mod tests_webexport; -#[cfg(test)] -mod tests_widthpoints; -#[cfg(test)] -mod tests_widthprofiles; -#[cfg(test)] -mod tests_widthtool; -#[cfg(test)] -mod tests_xform; - -/// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with -/// its checks), then running as on the selection: the result and the selection afterwards are the -/// menu command's (#2130). -pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ - "edit.duplicate", - "object.arrange.bringForward", - "object.arrange.bringToFront", - "object.arrange.sendBackward", - "object.arrange.sendToBack", - "object.arrange.sendToCurrentLayer", - "object.clippingMask.make", - "object.clippingMask.release", - "object.compoundPath.make", - "object.compoundPath.release", - "object.group", - "object.hide", - "object.lock", - "object.move", - "object.pathfinder.crop", - "object.pathfinder.divide", - "object.pathfinder.exclude", - "object.pathfinder.intersect", - "object.pathfinder.merge", - "object.pathfinder.minusBack", - "object.pathfinder.minusFront", - "object.pathfinder.outline", - "object.pathfinder.trap", - "object.pathfinder.trim", - "object.pathfinder.unite", - "object.reflect", - "object.rotate", - "object.scale", - "object.shear", - "object.ungroup", -]; - -/// The non-empty `ids` given to a command whose params description names `ids` as a key (the -/// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of -/// one when no `ids` are given and the description takes `id` so -/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. -fn given_ids(description: &'static str, params: &Value) -> Option> { - if !cmd::params::takes_key(description, "ids") { - return None; - } - match params.get("ids").filter(|v| !v.is_null()) { - Some(_) => cmd::ids_param(params, "ids"), - None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]), - } - .filter(|ids| !ids.is_empty()) -} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs new file mode 100644 index 000000000..fad473449 --- /dev/null +++ b/crates/engine/src/prefs.rs @@ -0,0 +1,452 @@ +use super::*; + +/// Where new art goes (Illustrator's drawing modes, Shift+D cycles). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DrawMode { + #[default] + Normal, + Behind, + Inside, +} + +/// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` / +/// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + // General + /// Arrow-key nudge distance (General → Keyboard Increment), points. + pub keyboard_increment: f64, + pub constrain_angle: f64, + pub corner_radius: f64, + pub disable_auto_add_delete: bool, + pub use_precise_cursors: bool, + pub show_tool_tips: bool, + pub anti_aliased_artwork: bool, + pub select_same_tint_percent: bool, + pub show_home_screen: bool, + pub use_preview_bounds: bool, + pub display_print_size: bool, + pub double_click_to_isolate: bool, + pub transform_pattern_tiles: bool, + pub scale_corners: bool, + /// Scale Strokes & Effects. + pub scale_strokes: bool, + pub zoom_with_mouse_wheel: bool, + /// A horizontal drag on a numeric field or its label steps its value (#400). + pub scrub_numeric_fields: bool, + /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. + pub trackpad_rotate_view: bool, + /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). + pub paste_offset: f64, + // Selection & Anchor Display + pub selection_tolerance: f64, + pub object_selection_by_path_only: bool, + pub snap_to_point_tolerance: f64, + pub ctrl_click_selects_behind: bool, + pub zoom_to_selection: bool, + pub move_locked_with_artboard: bool, + pub anchor_size: u32, + pub handle_style: String, + pub highlight_anchors_on_hover: bool, + pub show_handles_multiple_anchors: bool, + pub hide_corner_widget_above: f64, + pub pen_rubber_band: bool, + pub curvature_rubber_band: bool, + // Type + pub type_size_increment: f64, + pub tracking_increment: f64, + pub baseline_shift_increment: f64, + pub show_east_asian_options: bool, + pub show_indic_options: bool, + pub type_selection_by_path_only: bool, + pub font_names_in_english: bool, + pub auto_size_area_type: bool, + pub font_preview: bool, + pub font_preview_size: String, + pub recent_fonts_count: u32, + pub missing_glyph_protection: bool, + pub highlight_alternate_glyphs: bool, + /// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing + /// font is highlighted, in every document (on by default). + pub highlight_substituted_fonts: bool, + pub placeholder_text: bool, + // Units + pub units_general: String, + pub units_stroke: String, + pub units_type: String, + pub units_asian_type: String, + /// Numbers Without Units Are Points: a number typed with no unit into a length field in picas + /// is read in points (on by default; the reference app dims it unless a unit is Picas). + pub numbers_without_units_are_points: bool, + pub identify_objects_by: String, + // Guides & Grid + pub guide_color: String, + pub guide_style: String, + pub grid_color: String, + pub grid_style: String, + pub gridline_every: f64, + pub grid_subdivisions: u32, + pub grids_in_back: bool, + pub show_pixel_grid: bool, + // Smart Guides + pub smart_guide_color: String, + pub alignment_guides: bool, + pub object_highlighting: bool, + pub transform_tools_guides: bool, + pub construction_guides: bool, + pub construction_angles: String, + pub anchor_path_labels: bool, + pub measurement_labels: bool, + pub spacing_guides: bool, + pub distance_guides: bool, + pub snapping_tolerance: f64, + pub snap_tangent: bool, + pub snap_perpendicular: bool, + // Slices + pub show_slice_numbers: bool, + pub slice_line_color: String, + // Hyphenation + pub hyphenation_language: String, + pub hyphenation_exceptions: String, + /// Type › Options › Additional Fonts Folder: a folder (read with its subfolders) whose fonts + /// are listed and used as if installed (#683); empty for none. + pub fonts_folder: String, + // Plug-ins & Scratch Disks + pub plugins_folder: String, + pub scratch_primary: String, + pub scratch_secondary: String, + // User Interface + pub ui_brightness: String, + /// User Interface › Accent Color: the colour of what is on, chosen or focused. + pub ui_accent: String, + /// User Interface › Color Scheme: "modern" (with the accent above) or "classic" (neutral + /// greys and a blue accent). + pub ui_color_scheme: String, + pub canvas_color: String, + pub auto_collapse_icon_panels: bool, + /// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…); + /// off (as it ships, #1736), the tools run on from group to group with nothing between them. + pub tool_group_labels: bool, + pub open_documents_as_tabs: bool, + pub large_tabs: bool, + pub ui_scaling: f64, + pub scale_cursor_with_ui: bool, + /// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`. + /// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`. + pub interface_language: String, + /// Windows and Linux: use the system's title bar and window buttons instead of the app bar + /// acting as the title bar (tiling window managers, desktops that draw their own decorations). + /// Read when the app starts. macOS always uses the system's. + pub system_title_bar: bool, + // Performance + pub gpu_performance: bool, + pub animated_zoom: bool, + /// Which graphics processor the desktop app asks for at startup (it takes effect after a + /// restart): `automatic`, `lowPower` (the integrated GPU on hybrid-graphics machines) or + /// `highPerformance` (the discrete one). The canvas is rasterized on the CPU and only + /// composited on the GPU, so the integrated GPU is plenty; presenting from the discrete GPU + /// through the integrated one made some hybrid laptops flicker (#306). Automatic is power + /// saving on Windows and macOS and the system's default GPU elsewhere, the one the desktop + /// runs on: a Wayland compositor may not show frames from another GPU (#502). Single-GPU + /// machines are unaffected. + pub gpu_preference: String, + pub history_states: u32, + pub real_time_drawing: bool, + /// Rasterizer worker threads; -1 = automatic. + pub render_threads: i32, + /// Linux: `automatic` opens the window on the session's display server; `x11` opens it through + /// XWayland on a Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it + /// through XWayland only when a pen is attached (#2028). Read when the app starts. + pub linux_display_server: String, + // File Handling + pub background_save: bool, + pub background_export: bool, + pub autosave_recovery: bool, + pub autosave_interval: u32, + pub recovery_folder: String, + pub recovery_off_for_complex: bool, + pub recent_files_count: u32, + pub low_res_proxy_eps: bool, + pub anti_aliased_bitmaps: bool, + pub update_links: String, + // Clipboard Handling + pub copy_as_svg: bool, + pub copy_as_pdf: bool, + pub copy_aicb: bool, + pub aicb_mode: String, + pub paste_text_formatting: String, + // Appearance of Black + pub black_on_screen: String, + pub black_output: String, + // Devices + pub touch_workspace: bool, + pub touch_gestures: bool, + // Graphic Styles panel + /// Override Character Color: a graphic style applied to type replaces its characters' fill + /// and stroke with the style's fills and strokes. + pub override_char_color: bool, + // Pattern editing + /// Object → Pattern → Tile Edge Color (`#rrggbb`): the tile edge and swatch bounds in pattern + /// editing mode. + pub pattern_tile_edge_color: String, + /// Eyedropper Options (`eyedropper.setOptions`; a preference group, see + /// [`cmd::prefscmds::PREF_GROUPS`]). + pub eyedropper: EyedropperOptions, + /// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`; a preference group, see + /// [`cmd::prefscmds::PREF_GROUPS`]). + pub pathfinder: PathfinderOptions, + /// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored). + /// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*` + /// edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub flattener_presets: Vec, + /// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a + /// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub width_profiles: Vec, + /// General → Use Japanese Crop Marks: the style of Create Trim Marks and Effect → Crop Marks. + pub japanese_crop_marks: bool, + /// Appearance panel → New Art Has Basic Appearance (on): new art takes one fill and stroke; + /// off, the whole appearance of the last selection (`appearance.setNewArtBasic`). + pub new_art_basic: bool, + /// The Color Themes panel's saved themes (`colorTheme.*`): a local library, not a Preferences + /// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub color_themes: Vec, + /// New Document → Saved: the user's document presets (`file.newPresets.save`). A local + /// library, not a Preferences dialog field: resetting the preferences keeps it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub new_doc_presets: Vec, + /// New Document → Recent: the settings of the last documents made (newest first). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_new_docs: Vec, + /// Edit → PDF Presets: the user's presets (the built-in ones aren't stored). A local library, + /// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pdf_presets: Vec, + /// File → Export for Screens: the user's batch export presets (`document.exportPresets.*`; + /// the built-in ones aren't stored). A local library, not a Preferences dialog field. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub screen_export_presets: Vec, + // File Handling (continued) + /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). + pub templates_folder: String, + /// Open older native files as " [Converted]" so Save asks for a new name. + pub append_converted: bool, + /// File Handling → Use Compression: native saves are gzip-compressed (`document.save + /// {compress}` overrides it). + pub use_compression: bool, + /// Save for Web: the user's presets (the built-in ones aren't stored). A local library, not a + /// Preferences dialog field: resetting the preferences keeps it; `webExport.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub web_export_presets: Vec, + /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. + #[serde(skip_serializing_if = "Option::is_none")] + pub web_export_settings: Option, + /// Edit → Print Presets: the user's presets ([Default] isn't stored). A local library, not a + /// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub print_presets: Vec, + /// Constrain Width and Height Proportions: the link between W and H in the Transform panel, + /// the Properties panel and the Control bar (their size fields pass `proportional` to + /// `object.setBounds`). + pub constrain_proportions: bool, + /// One Corner Radius field sets every corner of a live rectangle (off: a field per corner). + pub single_corner_radius: bool, + /// The Transform panel menu's Transform Object Only / Transform Pattern Only / Transform Both + /// (`object`, `pattern`, `both`): what the panel's fields, flips and menu flips transform + /// (they pass `objects` and `patterns` to the transform commands). `default` (and any other + /// value), until one is chosen: they pass neither, so patterns follow General › Transform + /// Pattern Tiles (`transformPatternTiles`), as before the menu acted. + pub transform_panel_patterns: String, + /// The Transform panel menu's Use Registration Point for Symbol: with one symbol instance + /// selected, the panel and the Properties panel's Transform section measure and transform it + /// from its symbol's registration point instead of the reference point. + pub use_registration_point_for_symbol: bool, + /// The tools' persistent options by store (a tool id, or a store a family shares: `liquify` + /// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]: + /// kept across tool switches and saved with the preferences. Not a Preferences dialog field: + /// resetting the preferences keeps them; `tool.setOption` edits them. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub tool_settings: std::collections::BTreeMap>, + /// View → Perspective Grid presets: the user's (the built-in ones aren't stored). A local + /// library, not a Preferences dialog field: resetting the preferences keeps it; + /// `perspective.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub perspective_presets: Vec, + /// Blend Options set with no blend selected: what new blends start with + /// (`object.blend.options`; none: Smooth Color, Align to Page). A tool setting, not a + /// Preferences dialog field: it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting the + /// preferences keeps it. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend_options: Option, + /// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane + /// Switching Widget shows and where (`perspective.widget.options`). + pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions, + /// What names and codes in other apps' shortcut sets stand for, as the user confirmed them on + /// import (`importMatches.*`). A local library, not a Preferences dialog field: resetting the + /// preferences keeps it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub import_matches: Vec, +} + +impl Default for Prefs { + fn default() -> Self { + let s = |v: &str| v.to_string(); + Self { + keyboard_increment: 1.0, + constrain_angle: 0.0, + corner_radius: 12.0, + disable_auto_add_delete: false, + use_precise_cursors: false, + show_tool_tips: true, + anti_aliased_artwork: true, + select_same_tint_percent: false, + show_home_screen: true, + use_preview_bounds: false, + display_print_size: false, + double_click_to_isolate: true, + transform_pattern_tiles: false, + scale_corners: false, + scale_strokes: false, + zoom_with_mouse_wheel: false, + scrub_numeric_fields: true, + trackpad_rotate_view: true, + paste_offset: 10.0, + selection_tolerance: 3.0, + object_selection_by_path_only: false, + snap_to_point_tolerance: 2.0, + ctrl_click_selects_behind: true, + zoom_to_selection: true, + move_locked_with_artboard: false, + anchor_size: 3, + handle_style: s("solid"), + highlight_anchors_on_hover: true, + show_handles_multiple_anchors: true, + hide_corner_widget_above: 177.0, + pen_rubber_band: true, + curvature_rubber_band: true, + type_size_increment: 2.0, + tracking_increment: 20.0, + baseline_shift_increment: 2.0, + show_east_asian_options: false, + show_indic_options: false, + type_selection_by_path_only: false, + font_names_in_english: true, + auto_size_area_type: false, + font_preview: true, + font_preview_size: s("medium"), + recent_fonts_count: 10, + missing_glyph_protection: true, + highlight_alternate_glyphs: false, + highlight_substituted_fonts: true, + placeholder_text: true, + units_general: s("points"), + units_stroke: s("points"), + units_type: s("points"), + units_asian_type: s("points"), + numbers_without_units_are_points: true, + identify_objects_by: s("objectName"), + guide_color: s("#4affff"), + guide_style: s("lines"), + grid_color: s("#c8c8c8"), + grid_style: s("lines"), + gridline_every: 72.0, + grid_subdivisions: 8, + grids_in_back: true, + show_pixel_grid: true, + smart_guide_color: s("#ff3dfc"), + alignment_guides: true, + object_highlighting: true, + transform_tools_guides: true, + construction_guides: true, + construction_angles: s(vectorcraft_tools::guides::DEFAULT_CONSTRUCTION_ANGLES), + anchor_path_labels: true, + measurement_labels: true, + spacing_guides: true, + distance_guides: true, + snapping_tolerance: 4.0, + snap_tangent: true, + snap_perpendicular: true, + show_slice_numbers: true, + slice_line_color: s("#ff3f3f"), + hyphenation_language: s("English: USA"), + hyphenation_exceptions: String::new(), + fonts_folder: String::new(), + plugins_folder: String::new(), + scratch_primary: s("Startup"), + scratch_secondary: s("None"), + ui_brightness: s("mediumDark"), + ui_accent: s("coral"), + ui_color_scheme: s("modern"), + canvas_color: s("matchUi"), + auto_collapse_icon_panels: false, + tool_group_labels: false, + open_documents_as_tabs: true, + large_tabs: false, + ui_scaling: 1.0, + scale_cursor_with_ui: false, + interface_language: s("auto"), + system_title_bar: false, + gpu_performance: true, + animated_zoom: true, + gpu_preference: s("automatic"), + history_states: 500, + real_time_drawing: true, + render_threads: -1, + linux_display_server: s("automatic"), + background_save: true, + background_export: true, + autosave_recovery: true, + autosave_interval: 2, + recovery_folder: String::new(), + recovery_off_for_complex: false, + recent_files_count: 20, + low_res_proxy_eps: false, + anti_aliased_bitmaps: false, + update_links: s("askWhenModified"), + copy_as_svg: true, + copy_as_pdf: false, + copy_aicb: false, + aicb_mode: s("preserveAppearance"), + paste_text_formatting: s("keep"), + black_on_screen: s("accurate"), + black_output: s("accurate"), + touch_workspace: true, + touch_gestures: true, + override_char_color: true, + pattern_tile_edge_color: { + let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1; + Color::rgb8(r, g, b).to_hex() + }, + eyedropper: Default::default(), + pathfinder: Default::default(), + flattener_presets: vec![], + width_profiles: vec![], + japanese_crop_marks: false, + new_art_basic: true, + color_themes: vec![], + new_doc_presets: vec![], + recent_new_docs: vec![], + pdf_presets: vec![], + screen_export_presets: vec![], + templates_folder: String::new(), + append_converted: true, + use_compression: false, + web_export_presets: vec![], + web_export_settings: None, + print_presets: vec![], + constrain_proportions: false, + single_corner_radius: true, + transform_panel_patterns: "default".to_string(), + use_registration_point_for_symbol: false, + tool_settings: Default::default(), + perspective_presets: vec![], + blend_options: None, + perspective_widget: Default::default(), + import_matches: vec![], + } + } +} diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs new file mode 100644 index 000000000..0d06450a8 --- /dev/null +++ b/crates/engine/src/sanity.rs @@ -0,0 +1,60 @@ +use super::*; + +/// Is `id` a path made into a guide (View → Guides → Make Guides)? +pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool { + d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. })) +} + +/// Deselect the ruler guides and the paths made into guides: while View → Guides → Lock Guides is +/// on, they can't be selected (#1534), whatever selects them. → whether any was selected. +pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool { + let paths: Vec = sel.objects.iter().copied().filter(|id| is_guide_path(d, *id)).collect(); + let any = !paths.is_empty() || !sel.guides.is_empty(); + for id in paths { + sel.remove(id); + } + sel.guides.clear(); + sel.key_guide = None; + // A key object kept only for the guides to line up on goes with them. + if sel.key.is_some_and(|k| sel.objects.iter().all(|o| *o == k)) { + sel.key = None; + } + any +} + +/// Deselect what the View menu keeps from being selected: the guides while Lock Guides is on +/// ([`deselect_guides`]), and the slices that the Slice Selection tool or a slice command selected +/// ([`Selection::slices`]) while Lock Slices or Hide Slices is on. A selected object that is an +/// object slice stays selected. → whether any was selected. +pub(crate) fn deselect_hidden(menu: &cmd::menucmds::MenuState, d: &Document, sel: &mut Selection) -> bool { + let guides = menu.guides_locked && deselect_guides(d, sel); + let slices = (menu.slices_locked || menu.slices_hidden) && !sel.slices.is_empty(); + if slices { + sel.slices.clear(); + } + guides || slices +} + +/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`]. +pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep"; + +/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must +/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. +pub(crate) fn doc_sane(d: &Document, sel: &Selection) -> bool { + d.ruler_origin.is_finite() + && d.ruler_origin.x.abs() <= MAX_COORD + && d.ruler_origin.y.abs() <= MAX_COORD + && d.artboards.iter().all(|a| rect_in_range(a.rect)) + && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) + && objects_in_range(d, &sel.objects) +} + +fn rect_in_range(r: vectorcraft_geom::Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) +} + +/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)? +pub(crate) fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool { + let paths = d.index_paths(ids.iter().copied()); + ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range)) +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 000000000..2dd1bc889 --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,13 @@ +use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::doc_state::DocState; +use super::error::{EngineError, Result}; +use super::history::NamedStep; +use super::interaction::{BatchSteps, Interaction, LastTransform, UndoGroup, UndoScope}; +use super::prefs::{DrawMode, Prefs}; +use super::sanity::{TOO_DEEP, deselect_hidden, doc_sane, objects_in_range}; +use super::*; + +mod documents; +mod edit; +mod execute; +mod undo; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs new file mode 100644 index 000000000..b0bf0f792 --- /dev/null +++ b/crates/engine/src/session/documents.rs @@ -0,0 +1,294 @@ +use super::*; + +impl Session { + /// A session with no document open, the default preferences and the Selection tool. It also + /// installs the hooks the document crate calls back into: the loader and file reader of placed + /// documents, and where an image's effects draw its fills and strokes, for clicks. + pub fn new() -> Self { + // Placed documents are read by the native format's loader. + vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); + vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again); + vectorcraft_doc::placed_document::set_read_check(|path| file_access::check_read(path).is_ok()); + // The background workers of placed documents read files in the scope that asked. + vectorcraft_render::placed_document::set_scope_capture(|| { + let scope = file_access::ScopeHandle::capture(); + Box::new(move |f: &mut dyn FnMut()| scope.run(f)) + }); + // Clicks find an image's own fills and strokes where their effects draw them. + vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); + // OpenType SVG glyphs are drawn as the SVG importer reads their documents. + vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art); + Self { + docs: vec![], + active: None, + prefs: Prefs::default(), + paint: PaintDefaults::default(), + fill_active: true, + clipboard: Clipboard::default(), + journal: Journal::default(), + tool: vectorcraft_tools::create("selection"), + lender: None, + last_selection_tool: None, + last_view: ViewInfo::default(), + preview_memo: Default::default(), + depth: 0, + tool_panic: None, + tool_resets: 0, + tool_set_since_reset: false, + draw_mode: DrawMode::Normal, + draw_inside: None, + untitled_counter: 0, + menu: Default::default(), + gradient_stop: None, + active_appearance_item: None, + last_solid: Color::WHITE, + last_gradient: GradientPaint::new(Default::default()), + recent_colors: vec![], + pending_paint: None, + swatch_libraries: Default::default(), + freeform_point: None, + style_libraries: Default::default(), + symbol_libraries: Default::default(), + brush_libraries: Default::default(), + libraries: Default::default(), + pdf_preset_dir: None, + profile_dir: None, + plugin_dir: None, + plugin_output: None, + recent_urls: vec![], + ui_language: None, + journal_note: Default::default(), + note_depth: 1, + journal_query: false, + explicit_undo_group: None, + batch_stash: None, + batch_steps: None, + place_hand_off: None, + queue_hand_off: None, + place_cursor_requests: false, + package_hand_off: None, + screens_hand_off: None, + recovery: Default::default(), + in_tool_actions: false, + previewing: false, + envelope_defaults: None, + liquify_stroke: None, + plane_widget_press: false, + pointer_held: false, + ruler_guide: None, + port_marks: Default::default(), + font_search: None, + link_search: None, + search_rules: None, + deferred_overlay_cache: None, + search_threads: None, + link_stamps: Default::default(), + link_scan: None, + global_edit_options: Default::default(), + } + } + + pub fn documents(&self) -> &[DocState] { + &self.docs + } + + /// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up). + pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> { + self.docs.iter_mut().find(|d| d.uid == uid) + } + + pub fn active_index(&self) -> Option { + self.active + } + + pub fn active(&self) -> Option<&DocState> { + self.active.and_then(|i| self.docs.get(i)) + } + + pub fn active_mut(&mut self) -> Option<&mut DocState> { + self.active.and_then(|i| self.docs.get_mut(i)) + } + + pub fn doc(&self) -> Result<&DocState> { + self.active().ok_or(EngineError::NoDocument) + } + + pub fn doc_mut(&mut self) -> Result<&mut DocState> { + self.active_mut().ok_or(EngineError::NoDocument) + } + + /// Finish the tool's pending work in the current document and give it fresh state, so nothing + /// (e.g. uncommitted typing) leaks into the document we are switching to. + fn reset_tool_for_doc_switch(&mut self) { + if self.active.is_none() { + return; + } + let view = self.last_view; + // The switch goes on whatever the lent tool's last actions did, as for the deactivation. + let _ = self.give_back_tool(view); + let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); + let _ = self.apply_actions(acts); + // A batch leaves its interaction open in the document it leaves, to keep or roll back with + // the rest of the batch; anything else in progress (a drag) is cancelled. + if self.batch_stash.is_none() { + let _ = self.cancel_interaction(); + } + self.keep_tool_settings(); + self.tool = self.make_tool(self.tool.id()); + self.tool_resets = self.tool_resets.wrapping_add(1); + self.tool_set_since_reset = false; + } + + pub fn set_active(&mut self, index: usize) -> bool { + if index < self.docs.len() { + if self.active != Some(index) { + self.reset_tool_for_doc_switch(); + } + self.active = Some(index); + true + } else { + false + } + } + + /// The name [`Session::next_untitled`] gives next (New Document's Name field). + pub fn peek_untitled(&self) -> String { + format!("Untitled-{}", self.untitled_counter + 1) + } + + pub fn next_untitled(&mut self) -> String { + self.untitled_counter += 1; + format!("Untitled-{}", self.untitled_counter) + } + + /// Text layout bounds are a cache (not saved): compute them for a document just read, or + /// selection boxes and hit testing would use the rough estimate until each text is edited, and + /// what is fitted to type's bounds (an envelope warping it) would come out differently. That + /// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too, + /// not only in the layers (#1738). + pub(crate) fn refresh_text_bounds(doc: &mut Document) { + // Inline graphics in text take their size from their symbols' art (not saved either). + doc.resolve_inline_art(); + let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); + let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); + for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { + refresh_tree_text_bounds(tree); + } + } + + /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, + /// an undo group with the begun group's name opens in it: here outside a command, and after the + /// command that adds it otherwise ([`Session::execute`]). + pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { + // Every edit is checked against the document before it, so the first one is within the cap + // (importers and the native reader have seen to theirs already, with a warning). + doc.limit_depth(); + Self::refresh_text_bounds(&mut doc); + // Guides from files saved before guides had layers go on a layer. + doc.adopt_guides(); + cmd::dimension::conform(&mut doc); + self.reset_tool_for_doc_switch(); + let mut st = DocState::new(doc, path); + st.history.limit = self.prefs.history_states as usize; + self.docs.push(st); + let i = self.docs.len() - 1; + self.active = Some(i); + if self.depth == 0 + && let Some(label) = self.explicit_undo_group.clone() + { + self.begin_named_undo_group(label); + } + i + } + + /// Replace the document in tab `index` (File → Revert): new content, cleared history and + /// selection, saved state. The tab keeps its place, path, format and view. + pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool { + if index >= self.docs.len() { + return false; + } + doc.limit_depth(); + Self::refresh_text_bounds(&mut doc); + doc.adopt_guides(); + cmd::dimension::conform(&mut doc); + if self.active == Some(index) { + // Pending tool work (typing, a drag) belongs to the content being thrown away. + self.reset_tool_for_doc_switch(); + } + let old = &self.docs[index]; + let mut st = DocState::new(doc, old.path.clone()); + st.history.limit = old.history.limit; + // Same open document (caches keyed by uid stay valid); a new revision redraws it. + st.uid = old.uid; + st.revision = old.revision + 1; + st.format = old.format; + st.save_options = old.save_options.clone(); + // The remembered artboard pick follows the artboards it named when the file read back has + // the artboards the document had at the save, in the same order and with the same ids (a + // native file keeps them; a PDF's pages get new ids in page order); otherwise it is left + // out. + if old.save_artboards.is_empty() || st.doc.artboards.iter().map(|a| a.id).eq(old.save_artboards.iter().copied()) { + st.save_artboards = old.save_artboards.clone(); + } else { + for k in cmd::fileio::ARTBOARD_PARAMS { + st.save_options.remove(k); + } + } + st.converted = old.converted; + st.imported_from = old.imported_from.clone(); + st.import_losses = old.import_losses.clone(); + st.view = old.view.clone(); + st.layers_open = old.layers_open.clone(); + let old = std::mem::replace(&mut self.docs[index], st); + if let Some(stash) = &mut self.batch_stash { + stash.push(old); + } + true + } + + pub fn close_document(&mut self, index: usize) -> bool { + if index >= self.docs.len() { + return false; + } + self.reset_tool_for_doc_switch(); + // Closed (saved or discarded): nothing left to recover. + let uid = self.docs[index].uid; + cmd::recovery::forget(self, uid); + let old = self.docs.remove(index); + if let Some(stash) = &mut self.batch_stash { + stash.push(old); + } + self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + true + } + + /// Commands with enablement (for menus, palette, MCP `list_commands`). + pub fn commands(&self) -> Vec { + command_specs().iter().map(|c| c.info(self)).collect() + } +} + +/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the +/// nodes on the way to such type are copied. +fn refresh_tree_text_bounds(n: &mut Arc) { + fn stale(n: &vectorcraft_doc::Node) -> bool { + matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none()) + || n.extras().mask.as_deref().is_some_and(|m| stale(&m.art)) + || n.children().is_some_and(|ch| ch.iter().any(|c| stale(c))) + } + if !stale(n) { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::Text(t) = &mut n.kind + && t.cached_bounds.is_none() + { + cmd::typecmd::refresh_bounds(t); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { + refresh_tree_text_bounds(&mut m.art); + } + for c in n.children_mut().into_iter().flatten() { + refresh_tree_text_bounds(c); + } +} diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs new file mode 100644 index 000000000..6e9267720 --- /dev/null +++ b/crates/engine/src/session/edit.rs @@ -0,0 +1,300 @@ +use super::*; + +impl Session { + /// Run `f` on a mutable copy of the active document. Outside an interaction this records one + /// undo step labelled `label`; inside one it just mutates (the interaction commits later). + pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { + // An edit made while typing (the Transform panel) is a step of its own: inside the typing + // session, the next keystroke's preview would start again from before it (#1881). + if !self.previewing && cmd::typing_in_progress(self) { + cmd::settle_interaction(self)?; + } + let menu = self.menu.clone(); + let st = self.doc_mut()?; + let before = st.doc.clone(); + let before_sel = st.selection.clone(); + let doc = Arc::make_mut(&mut st.doc); + // The Global Edit members the edit reached besides the selection. + let mut spread = vec![]; + // Objects attached to a path follow it, and dimensions follow the art they measure. + let result = match f(doc, &mut st.selection).and_then(|v| { + // Global Edit: the other members take what the edit changed about the one it edited. + if let Some(ge) = &st.global_edit { + spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); + } + cmd::objects_on_path::refresh(&before, doc); + cmd::dimension::refresh(&before, doc)?; + Ok(v) + }) { + Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) { + Err(e) => Err(e), + Ok(()) => { + // Attached Shaper groups whose art was rebuilt go back on their path. + cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc)); + // Inline graphics in text follow their symbols. + cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc)); + // Text Wrap: area type follows its wrap objects; then threads re-flow. + cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); + // Type in no thread numbers its list paragraphs from 1 (before the mask copies + // its editing layer's art). + cmd::threads::restart_unthreaded_counts(&mut st.doc); + // Opacity-mask editing: the mask follows its art on the editing layer. + if st.doc.mask_edit.is_some() { + cmd::maskedit::sync(Arc::make_mut(&mut st.doc)); + } + // Threaded text re-flows when any of its frames changed. + if !st.doc.text_threads.is_empty() { + cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); + } + // Asset Export: assets let go of deleted art. + if !st.doc.assets.is_empty() { + Arc::make_mut(&mut st.doc).prune_assets(); + } + // Variables: bindings let go of deleted art. + if !st.doc.variables.bindings.is_empty() { + Arc::make_mut(&mut st.doc).prune_variable_bindings(); + } + if st.doc.too_deep(Some(&before)) { + // Deeper trees overflow the stack of what walks them. + Err(EngineError::Other(TOO_DEEP.into())) + } else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) { + Ok(v) + } else { + Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) + } + } + }, + Err(e) => Err(e), + }; + match result { + Ok(v) => { + st.selection.prune(&st.doc); + // An edit that selects what it unlocks, shows or pastes leaves locked guides out, + // and one that selects the slices it makes leaves them out while they are locked + // or hidden. + deselect_hidden(&menu, &st.doc, &mut st.selection); + // The art on a locked artboard can't stay selected, however it got there, except + // the new art this edit made and new art still selected. + st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before)); + st.revision += 1; + if st.interaction.is_none() { + let selection = match st.history.undo.last() { + Some(l) if *l.selection == before_sel => l.selection.clone(), + _ => Arc::new(before_sel), + }; + st.push_undo(label.to_string(), before, selection); + } + Ok(v) + } + Err(e) => { + st.doc = before; + st.selection = before_sel; + cmd::globaledit::follow_history(st); + Err(e) + } + } + } + + /// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name` + /// (a style's name). The undo step it records keeps the template and the name + /// ([`HistoryEntry::named`]), and a frontend shows the template's translation with the name in + /// it. An edit that records no step of its own (inside an interaction, or a later edit of an + /// undo group) keeps neither, and neither does a step an undo group names. + pub fn edit_named( + &mut self, + template: &'static str, + name: &str, + built_in: bool, + f: impl FnOnce(&mut Document, &mut Selection) -> Result, + ) -> Result { + let named = NamedStep { template, name: name.to_string(), built_in }; + let step = named.label(); + // The step this edit records keeps the document as it is now. + let now = Arc::as_ptr(&self.doc()?.doc); + let v = self.edit(&step, f)?; + if let Ok(st) = self.doc_mut() + && let Some(e) = st.history.undo.last_mut() + && Arc::as_ptr(&e.doc) == now + && e.label == step + { + e.named = Some(named); + } + Ok(v) + } + + /// Change only the selection (not an undo step). + pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { + self.active_appearance_item = None; + let menu = self.menu.clone(); + let st = self.doc_mut()?; + let before = st.selection.objects.clone(); + let new_art = st.selection.new_art.clone(); + f(&st.doc, &mut st.selection); + st.selection.prune(&st.doc); + // Lock Guides locks the paths made into guides too (#1534), and Lock Slices and Hide + // Slices keep the slices out, whatever selects them. + deselect_hidden(&menu, &st.doc, &mut st.selection); + // The art on a locked artboard can't be selected, whatever selects it, except new art + // that was selected and still is. + st.protect_locked_artboards(&before, &new_art, None); + // Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of + // the reference app: new art then goes beside it. + if st.selection.objects != before + && let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l)) + && st.active_layer != Some(layer) + { + st.active_layer = Some(layer); + st.layer_rows.clear(); + } + st.revision += 1; + // Puppet Warp pins belong to the art they were placed on: another selection starts afresh. + if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) { + cmd::distortcmds::drop_puppet_pins(st); + } + Ok(()) + } + + // ---------- interactions (live drags) ---------- + + pub(crate) fn clear_deferred_overlay_cache(&mut self) { + self.deferred_overlay_cache = None; + } + + /// The interaction's latest preview command when the document is still its snapshot (deferred + /// Real-time Drawing), regardless of the preference at commit or overlay time. + pub(crate) fn unapplied_interaction_preview(&self) -> Option<(String, Value)> { + self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone())) + } + + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + // Typing in progress is kept first: a drag or a dialog's preview would otherwise start + // from the snapshot taken before the typing, and drop it (#1881). + if label != "Typing" && cmd::typing_in_progress(self) { + cmd::settle_interaction(self)?; + } + self.clear_deferred_overlay_cache(); + let st = self.doc_mut()?; + if st.interaction.is_some() { + return Ok(()); + } + st.interaction = Some(Interaction { + label: label.to_string(), + doc: st.doc.clone(), + selection: Arc::new(st.selection.clone()), + preview: None, + active_layer: st.active_layer, + layer_rows: st.layer_rows.clone(), + variables_highlight: st.variables_highlight.clone(), + isolation: st.isolation, + perspective_again: None, + copies: None, + }); + Ok(()) + } + + /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). + pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { + { + let menu = self.menu.clone(); + let st = self.doc_mut()?; + let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; + // Undoing the previous preview counts as a change, so views redraw even when the command + // below returns without an edit. + let selection_changed = st.selection != *it.selection; + if !Arc::ptr_eq(&st.doc, &it.doc) || selection_changed { + st.revision += 1; + } + st.doc = it.doc.clone(); + if selection_changed { + st.selection.clone_from(&it.selection); + } + // Guides locked and slices locked or hidden during the interaction stay deselected, + // even when the preview fails. + deselect_hidden(&menu, &st.doc, &mut st.selection); + cmd::globaledit::follow_history(st); + } + // The previous preview's paint is undone with it. + self.pending_paint = None; + let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params).or_else(|| cmd::bounds_rewrite(self, cmd, params)); + let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); + let outer = std::mem::replace(&mut self.previewing, true); + let r = self.execute(cmd, params); + self.previewing = outer; + let params = self.noted(params); + let st = self.doc_mut()?; + if let Some(it) = &mut st.interaction { + // A failed preview leaves the document at the snapshot, so nothing is left to commit + // (not even an earlier successful preview, which the reset above already undid). + it.preview = r.is_ok().then(|| (cmd.to_string(), params)); + } + r + } + + pub fn commit_interaction(&mut self) -> Result<()> { + self.clear_deferred_overlay_cache(); + self.remember_pending_paint(); + let st = self.doc_mut()?; + let Some(mut it) = st.interaction.take() else { return Ok(()) }; + let Some(preview) = it.preview.take() else { return Ok(()) }; + let perspective_again = it.perspective_again.take(); + let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); + // Alt pressed or released during a move drag turns it into a copy or back: name it for + // what it did in the end. + match (it.label.as_str(), copy) { + ("Move", true) => it.label = "Copy".into(), + ("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(), + _ => {} + } + // A preview that left the document as it was records no Transform Again either (as + // `keep_interaction` records no undo step): the last real transformation stands. + let changed = !Arc::ptr_eq(&it.doc, &st.doc); + st.keep_interaction(it); + if preview.0 == "object.transform" { + let m = cmd::matrix_param(&preview.1, "matrix"); + if let Some(m) = m { + // The options are the last preview's params with the values it noted, without the + // matrix and the ids. + let mut options = preview.1.clone(); + if let Some(o) = options.as_object_mut() { + o.remove("matrix"); + o.remove("ids"); + } + st.last_transform = Some(LastTransform::Matrix(m, options)); + st.last_perspective = None; + } + } else if preview.0 == "object.transformEach" && changed { + // Transform Again repeats it as it stood when OK was pressed. + st.last_transform = Some(LastTransform::Each(preview.1.clone())); + st.last_perspective = None; + } + if perspective_again.is_some() { + st.last_perspective = perspective_again; + } + let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); + self.journal.push(preview); + self.journal_in_undo_groups(doc, &grouped, journal); + Ok(()) + } + + /// Remember the paint a live preview applied (as its interaction is kept). + pub(crate) fn remember_pending_paint(&mut self) { + if let Some(p) = self.pending_paint.take() { + self.remember_paint_now(&p); + } + } + + pub fn cancel_interaction(&mut self) -> Result<()> { + self.clear_deferred_overlay_cache(); + self.pending_paint = None; + let menu = self.menu.clone(); + let st = self.doc_mut()?; + st.undo_interaction(); + // Guides locked and slices locked or hidden during the interaction stay deselected. + deselect_hidden(&menu, &st.doc, &mut st.selection); + Ok(()) + } + + pub fn in_interaction(&self) -> bool { + self.active().is_some_and(|d| d.interaction.is_some()) + } +} diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs new file mode 100644 index 000000000..45ede81e5 --- /dev/null +++ b/crates/engine/src/session/execute.rs @@ -0,0 +1,229 @@ +use super::*; + +impl Session { + /// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back. + fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec) -> std::result::Result<(), String> { + let Some(d) = self.active_mut() else { return (spec.enabled)(self) }; + let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() }); + let r = (spec.enabled)(self); + if let Some(d) = self.active_mut() { + d.selection = old; + } + r + } + + /// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or + /// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects. + fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> { + let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) }; + let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) }; + for value in given { + let node = cmd::checked_id(self, value, id)?; + if st.doc.node(node).is_some_and(|n| n.is_layer()) { + return Err(if id.starts_with("object.arrange.") { + cmd::bad(id, format!("id {value} is a layer, not an object; move layers with layer.move")) + } else { + cmd::bad(id, format!("id {value} is a layer, not an object")) + }); + } + } + Ok(()) + } + + /// Execute a command by id. This is THE entry point for every frontend. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + // The crate's own tests are the audit of the params descriptions (#1442): a key a test + // passes that its command's description doesn't name fails the test. + #[cfg(test)] + self.check_params(id, params)?; + // Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked + // before anything else: a malformed one, or one naming no object, is an error under this + // command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}` + // without `effect` names the effect (its older key), not an object. + let effect_named = id == "effect.apply" + && params.get("effect").is_none() + && params.get("ids").is_none_or(Value::is_null) + && params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$')); + if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() { + cmd::given_objects(self, params, id)?; + } + if SELECTS_GIVEN_IDS.contains(&id) { + self.check_given_ids(id, params)?; + } + // Clear's menu needs a selection; an agent supplying explicit targets only needs a + // document. The command validates those targets before making any edit. + let given = given_ids(spec.params, params); + let enabled = if id == "edit.clear" && params.get("ids").is_some() { + cmd::has_doc(self) + } else if let Some(ids) = given.clone() { + // Objects given as `ids` (or `id`) stand in for the selection: the command is enabled + // as it is with them selected (#1575). + self.enabled_with_selection(spec, ids) + } else { + (spec.enabled)(self) + }; + if let Err(why) = enabled { + return Err(EngineError::Disabled(id.to_string(), why)); + } + // Only top-level commands are journaled (commands that call other commands would otherwise + // be recorded twice and replay differently). + if self.depth == 0 { + self.journal_note.clear(); + self.note_depth = 1; + self.journal_query = false; + self.batch_stash = None; + self.batch_steps = None; + } + // A selection-only command given `ids` (or `id`) selects them first, in the same undo step + // (#2130); when the command then fails, the selection (and the current layer selecting + // them made current) goes back to how it was, as when the ids are refused. + let select_first = SELECTS_GIVEN_IDS + .contains(&id) + .then(|| { + params.get("ids").filter(|v| !v.is_null()).cloned().or_else(|| given.map(|ids| ids.iter().map(|i| i.0).collect::>().into())) + }) + .flatten(); + let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer)); + let run = |s: &mut Session| { + if let Some(ids) = &select_first { + s.execute("select.set", &serde_json::json!({ "ids": ids }))?; + } + let r = (spec.run)(s, params); + if r.is_err() + && let Some((uid, selection, layer)) = selected_before + && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) + { + st.selection = selection; + st.active_layer = layer; + st.revision += 1; + } + r + }; + // The entries the command journals belong to the undo groups of the documents it edits, and to + // the group of the document it starts in when it ends there too. + let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); + let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::>()); + let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; + let r = r?; + if self.depth == 0 { + self.inherit_new_art(); + } + if let Some(open) = open { + self.open_undo_groups_in_new_documents(&open); + } + let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); + if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { + let p = self.noted(params); + self.journal.push((id.to_string(), p)); + } + if self.depth == 0 { + self.journal_in_undo_groups(doc, &grouped, journal); + self.after_command(); + } + Ok(r) + } + + /// Is the interface in Japanese? Then new type starts with em box top-to-top leading and em + /// box centre character alignment (#432). + pub fn japanese_interface(&self) -> bool { + self.ui_language.as_deref().unwrap_or(&self.prefs.interface_language).eq_ignore_ascii_case("ja") + } + + /// Record `key: value` in the running top-level command's journal entry (or its interaction's + /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the + /// preferences or the clock. + pub fn note_journal(&mut self, key: &str, value: Value) { + if self.depth == self.note_depth { + self.journal_note.insert(key.to_string(), value); + } + } + + /// Journal the running query command when it is the top-level command: it changed the + /// selection (`document.find {select: true}`), so an action recorded around it repeats that. + pub(crate) fn journal_query(&mut self) { + if self.depth == 1 { + self.journal_query = true; + } + } + + /// Run `id` as a step of the running command, which journals its steps (`command.batch`): + /// → the step's result and its params with what it noted ([`Session::note_journal`]), for the + /// step in the running command's journal entry. + pub(crate) fn execute_step(&mut self, id: &str, params: &Value) -> Result<(Value, Value)> { + let outer = (std::mem::take(&mut self.journal_note), self.note_depth); + self.note_depth = self.depth + 1; + let r = self.execute(id, params); + let noted = self.noted(params); + (self.journal_note, self.note_depth) = outer; + Ok((r?, noted)) + } + + /// The journal from entry `start` on, as an action records it: without the params that only + /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. + pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { + self.journal + .since(start) + .iter() + .cloned() + .map(|(id, mut p)| { + strip_replay_only(&id, &mut p); + (id, p) + }) + .collect() + } + + /// `params` with the noted values added (see [`Session::note_journal`]). + pub(super) fn noted(&mut self, params: &Value) -> Value { + let mut note = std::mem::take(&mut self.journal_note); + match params { + // The params' own values win. + Value::Object(m) if !note.is_empty() => { + note.extend(m.clone()); + Value::Object(note) + } + Value::Null if !note.is_empty() => Value::Object(note), + _ => params.clone(), + } + } + + pub(crate) fn run_nested(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { + self.depth += 1; + let r = f(self); + self.depth -= 1; + r + } + + /// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active + /// document goes back to how it was before the command, instead of crashing the frontend. + pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { + let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone())); + let active = self.active; + let r = match guard::catch_panic(|| self.run_nested(f)) { + Ok(r) => r, + Err(msg) => { + self.depth = 0; + self.journal_note.clear(); + if let Some((uid, doc, selection, interaction)) = snapshot + && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) + { + st.doc = doc; + st.selection = selection; + st.interaction = interaction; + cmd::globaledit::follow_history(st); + st.revision += 1; + } + if active.is_some_and(|i| i < self.docs.len()) { + self.active = active; + } + Err(EngineError::Internal { cmd: id.to_string(), msg }) + } + }; + // Undo, Redo and a failed batch bring back documents with their selections: each document + // gets its selection's new art on a locked artboard. + for st in &mut self.docs { + st.sync_new_art(); + } + r + } +} diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs new file mode 100644 index 000000000..f9f06135f --- /dev/null +++ b/crates/engine/src/session/undo.rs @@ -0,0 +1,243 @@ +use super::*; + +impl Session { + // ---------- undo groups (scrubbed numeric fields) ---------- + + /// Open an undo group in the active document: until [`Session::end_undo_group`], the edits + /// made there (commands, committed interactions) are one undo step. A scrubbed numeric field + /// applies each value it passes as its own command, as a typed value is applied; the drag is + /// one step. + pub fn begin_undo_group(&mut self) { + self.begin_named_undo_group(None); + } + + /// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit). + pub fn begin_named_undo_group(&mut self, label: Option) { + if let Some(st) = self.active_mut() + && st.undo_group.is_none() + { + st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label }); + } + } + + /// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, open an undo group with the begun + /// group's name in each document that the top-level command just run opened or created (its + /// [`DocState::uid`] is not in `open`). The group takes the edits after that command: the + /// command's own edits there stay its undo step, and a canceled group keeps them, as the journal + /// entry that made the document stays. + pub(super) fn open_undo_groups_in_new_documents(&mut self, open: &[u64]) { + let Some(label) = &self.explicit_undo_group else { return }; + for st in &mut self.docs { + if !open.contains(&st.uid) && st.undo_group.is_none() { + st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label: label.clone() }); + } + } + } + + /// The documents with an undo group open ([`DocState::uid`]), each with the document it holds. + pub(super) fn undo_group_docs(&self) -> Vec<(u64, *const Document)> { + self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect() + } + + /// Note the journal entries from number `from` on in the undo groups they belong to: the group of + /// each document in `grouped` ([`Session::undo_group_docs`] before the entries) that they changed, + /// each group they opened and edited, and the group of the document with [`DocState::uid`] `doc` + /// when they started in it and it is still the active one. A cancel drops them. The last entry of a command that changed the + /// active document keeps the index of the document it left active. A `command.batch` entry also + /// keeps, in each group, the steps that belong to it ([`Session::batch_steps`]). + pub(super) fn journal_in_undo_groups(&mut self, doc: Option, grouped: &[(u64, *const Document)], from: usize) { + let to = self.journal.len(); + let active = self.active().map(|d| d.uid); + let switched = if active == doc { None } else { self.active }; + let batch = self.batch_steps.take().filter(|_| to > from && self.journal.get(to - 1).is_some_and(|(id, _)| id == "command.batch")); + for st in &mut self.docs { + let Some(g) = &mut st.undo_group else { continue }; + let started = doc == Some(st.uid) && active == doc; + // A group the entries opened has an edit of theirs once it has a first edit. + let edited = match grouped.iter().find(|&&(uid, _)| uid == st.uid) { + Some(&(_, d)) => !std::ptr::eq(d, Arc::as_ptr(&st.doc)), + None => g.first.is_some(), + }; + if started || edited { + // A committed interaction inside the command noted its entry already. + let noted = g.journal.partition_point(|&(n, _)| n < from); + g.journal.truncate(noted); + g.journal.extend((from..to).map(|n| (n, switched.filter(|_| n + 1 == to)))); + g.steps.retain(|&(n, _)| n < from); + if let Some(steps) = &batch { + let own = steps.iter().enumerate().filter(|(_, (uids, _))| uids.contains(&st.uid)); + g.steps.push((to - 1, own.map(|(i, &(_, switched))| (i, switched)).collect())); + } + } + } + } + + /// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are + /// undone and dropped from the journal. A group begun with `edit.undoGroup.begin` ends too, so + /// documents opened after this open no undo group. + pub fn end_undo_group(&mut self, cancel: bool) { + self.explicit_undo_group = None; + self.close_undo_groups(cancel, None); + } + + /// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`, for the + /// document with [`DocState::uid`] `only` alone when given. The groups that stay open follow the + /// journal entries and batch steps of theirs that a cancel renumbers. + fn close_undo_groups(&mut self, cancel: bool, only: Option) { + let mut journal: Vec<(usize, Option)> = vec![]; + let mut steps: Vec = vec![]; + for st in &mut self.docs { + if only.is_some_and(|uid| uid != st.uid) { + continue; + } + let Some(g) = st.undo_group.take() else { continue }; + // Only while the group's step is still the newest one. + if cancel + && let Some(first) = g.first + && st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, &first)) + && let Some(e) = st.history.undo.pop() + { + st.doc = e.doc; + st.selection = Arc::unwrap_or_clone(e.selection); + deselect_hidden(&self.menu, &st.doc, &mut st.selection); + st.sync_new_art(); + cmd::globaledit::follow_history(st); + st.revision += 1; + // The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile. + journal.extend(g.journal); + steps.extend(g.steps); + } + } + // A batch that edited two of the documents is in both groups. + journal.sort_unstable(); + journal.dedup_by_key(|&mut (n, _)| n); + // A batch with steps outside the canceled groups keeps those steps. + let mut rewritten: Vec<(usize, Vec>)> = vec![]; + journal.retain(|&(n, _)| match self.keep_batch_steps(n, &steps) { + Some(kept) => { + rewritten.push((n, kept)); + false + } + None => true, + }); + // An entry that changed the active document becomes that change, so the entries after it run + // in the same document when the journal is replayed. + for &(n, index) in &journal { + if let Some(index) = index { + self.journal.replace(n, ("document.activate".to_string(), serde_json::json!({ "index": index }))); + } + } + let gone: Vec = journal.into_iter().filter(|(_, index)| index.is_none()).map(|(n, _)| n).collect(); + self.journal.remove(&gone); + let below = |n: usize| gone.partition_point(|&m| m < n); + for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) { + g.journal.retain(|(n, _)| gone.binary_search(n).is_err()); + for (n, _) in &mut g.journal { + *n -= below(*n); + } + g.steps.retain(|(n, _)| gone.binary_search(n).is_err()); + for (n, own) in &mut g.steps { + if let Some((_, kept)) = rewritten.iter().find(|(r, _)| r == n) { + own.retain_mut(|(i, _)| kept.get(*i).copied().flatten().map(|k| *i = k).is_some()); + } + *n -= below(*n); + } + } + } + + /// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and + /// other steps that are not `document.activate`: rewrite its `commands` without the noted steps + /// (a noted step that changed the active document becomes that change) → each step's number in + /// the rewritten `commands`, `None` for a noted step. `None` leaves the entry to be dropped as a + /// whole. + fn keep_batch_steps(&mut self, n: usize, steps: &[BatchSteps]) -> Option>> { + let noted: Vec<(usize, Option)> = steps.iter().filter(|(e, _)| *e == n).flat_map(|(_, s)| s.iter().copied()).collect(); + let (id, entry) = self.journal.get(n).filter(|(id, _)| id == "command.batch" && steps.iter().any(|(e, _)| *e == n))?; + let commands = entry.get("commands").and_then(Value::as_array)?; + let kept = |i: usize| !noted.iter().any(|&(s, _)| s == i); + let activate = |c: &Value| c.get("command").and_then(Value::as_str) == Some("document.activate"); + if commands.iter().enumerate().all(|(i, c)| !kept(i) || activate(c)) { + return None; + } + let (mut rewritten, mut numbers) = (Vec::with_capacity(commands.len()), Vec::with_capacity(commands.len())); + for (i, c) in commands.iter().enumerate() { + if kept(i) { + numbers.push(Some(rewritten.len())); + rewritten.push(c.clone()); + } else { + numbers.push(None); + if let Some(index) = noted.iter().find(|&&(s, _)| s == i).and_then(|&(_, index)| index) { + rewritten.push(serde_json::json!({ "command": "document.activate", "params": { "index": index } })); + } + } + } + let (id, mut entry) = (id.clone(), entry.clone()); + if let Some(o) = entry.as_object_mut() { + o.insert("commands".into(), Value::Array(rewritten)); + } + self.journal.replace(n, (id, entry)); + Some(numbers) + } + + /// [`Session::end_undo_group`] for the open document with [`DocState::uid`] `uid` only (it may + /// have closed since): its open undo group closes, and undo groups open in other documents stay + /// open. + fn end_document_undo_group(&mut self, uid: u64, cancel: bool) { + self.close_undo_groups(cancel, Some(uid)); + } + + /// Make the edits that follow in the active document one undo step, until + /// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it, + /// so that the edits are part of its step. `None` without a document. + pub fn begin_undo_scope(&mut self) -> Option { + let journal = self.journal.len(); + let st = self.active()?; + let uid = st.uid; + let joined = st.undo_group.as_ref().map(|g| (st.doc.clone(), st.selection.clone(), g.first.clone())); + if joined.is_none() { + self.begin_undo_group(); + } + Some(UndoScope { uid, journal, joined }) + } + + /// End `scope` in its own document, whichever document is active now; undo groups open in other + /// documents stay open. The undo group the scope opened closes as [`Session::end_undo_group`] + /// closes it. A scope that joined an open group leaves the group open; with `cancel`, it puts the + /// document and its selection back as they were when the scope began and cuts the journal back to + /// where the scope began (only while the group's step is still the newest one), and an undo step + /// that the scope's first edit recorded goes too. The open undo groups drop the cut entries. + pub fn end_undo_scope(&mut self, scope: UndoScope, cancel: bool) { + let Some((doc, selection, first)) = scope.joined else { + self.end_document_undo_group(scope.uid, cancel); + return; + }; + let menu = self.menu.clone(); + let Some(st) = self.document_mut(scope.uid) else { return }; + let Some(g) = &mut st.undo_group else { return }; + let newest = g.first.as_ref().is_some_and(|f| st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, f))); + if !cancel || !newest { + return; + } + match &first { + // The scope's first edit recorded the group's step. + None => { + st.history.undo.pop(); + g.first = None; + } + Some(f) if g.first.as_ref().is_some_and(|now| Arc::ptr_eq(now, f)) => {} + Some(_) => return, + } + st.doc = doc; + st.selection = selection; + cmd::globaledit::follow_history(st); + deselect_hidden(&menu, &st.doc, &mut st.selection); + st.history.recost_last(&st.doc); + st.revision += 1; + self.journal.truncate(scope.journal); + // The next commands take the cut entries' numbers, so no open undo group keeps them. + for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) { + g.journal.retain(|&(n, _)| n < scope.journal); + g.steps.retain(|&(n, _)| n < scope.journal); + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9313c3023..bb115d752 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4,6 +4,175 @@ use super::*; use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; +pub(super) mod adjust; +mod appearance; +mod areafit; +mod assets; +mod attributes; +mod bboxrotate; +mod blendfidelity; +mod blendopts; +mod blendspine; +mod brushlib; +mod brushsym; +mod build; +mod cancel; +mod charstroke; +pub(super) mod clip; +mod clipboard; +mod clipflavours; +mod clippaint; +mod cmdsplit; +mod cmykflatten; +mod colorguide; +mod colorguidelib; +pub(super) mod colormgmt; +mod colorthemes; +mod colourfonts; +mod compoundpaint; +mod constrain_new; +mod constrain_tools; +mod containers; +mod css; +mod cut; +mod dashalign; +mod distort; +mod docsetup; +mod draw2; +mod editcolors; +mod effectedit; +mod emptytype; +mod envelope; +mod envelope_distort; +mod envelope_edit; +pub(super) mod expand; +mod eyedropper; +mod file; +mod fileinfo; +mod flatpresets; +mod flatpreview; +mod flatten; +mod focal; +mod fontfiles; +mod fontlist; +mod freeform; +mod globaledit; +mod gradient; +mod gradpanel; +mod gradspan; +mod halftone; +mod history_mem; +mod ids_without_selection; +mod inline; +mod interactivity; +mod intertwine; +mod journal; +mod keyguide; +mod knockout; +mod labspots; +pub(super) mod large_edits; +mod layerclip; +mod layers; +mod legacy_ai; +mod library; +mod link_paths; +mod linked_stops; +mod linkfiles; +pub(super) mod links; +pub(super) mod linkspanel; +mod linkwatch; +mod liquify; +mod live; +mod livecorners; +mod maskview; +pub(super) mod menucmds; +mod nativefile; +mod newart; +mod newdoc; +mod objects_on_path; +mod objexpand; +mod opacitymask; +mod outlineobject; +pub(super) mod outlinestroke; +mod overprint; +mod package; +mod paintproxy; +mod panelcmds; +mod paragraphs; +mod params; +mod pathops; +mod pathtype; +mod pattern; +mod pdffidelity; +mod pdfmarks; +mod pdfpresets; +mod pdfraster; +mod persp_planes; +pub(super) mod persp_select; +mod persp_text; +mod perspgrid; +mod place; +mod placed_document; +mod plugins; +mod prefs; +mod previewbounds; +mod print; +mod printadvanced; +mod printpresets; +mod printpreview; +mod printps; +mod printtiling; +mod proxyitems; +mod puppetwarp; +mod rasterfilters; +mod rastersettings; +mod recolor; +mod recovery; +mod reflecthit; +mod registration; +mod save; +mod saveoptions; +mod scalestrokes; +mod scripts; +mod select_ids_first; +mod shaper; +mod shortcutset; +mod simplify_part; +mod slices; +mod smartguides; +mod store; +mod strokegeom; +mod strokegradient; +mod strokereach; +mod strokeux; +mod stylelib; +mod stylepanel; +mod styles; +mod swatchcmds; +mod swatches; +mod swatchlib; +mod symbolbounds; +mod symbollib; +mod targeting; +mod textcombos; +mod textedit; +pub(super) mod textimport; +mod tileedge; +mod tints; +mod toolsettings; +mod transformfx; +mod transparencygrid; +mod trap; +mod typearea; +mod typescale; +mod units; +mod variables; +mod webexport; +mod widthpoints; +mod widthprofiles; +mod widthtool; +mod xform; + fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests/adjust.rs similarity index 99% rename from crates/engine/src/tests_adjust.rs rename to crates/engine/src/tests/adjust.rs index fc444ba95..be4dd6a2d 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests/adjust.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::{Node, NodeKind}; -use super::*; +use crate::*; pub(crate) fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests/appearance.rs similarity index 85% rename from crates/engine/src/tests_appearance.rs rename to crates/engine/src/tests/appearance.rs index d2045f7e1..d6336b86d 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, Node}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -425,7 +425,7 @@ fn appearance_commands_that_change_nothing_record_no_undo_step() { /// objects and the undo history stay as they were. Numbers, true, false and null still work. #[test] fn appearance_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let a = rect(&mut s, 50.0); let b = rect(&mut s, 200.0); @@ -523,3 +523,78 @@ fn clear_and_reduce_keep_a_groups_stack_empty() { // The members keep their own appearance. assert_eq!(node(&s, a).appearance, members); } + +/// #4174: two circles with different fills, the same 11 pt blue stroke and the same Feather, +/// both selected. Their default stacks are `[Fill, Stroke]`; the second gets a second fill under +/// its stroke when `extra_fill`, so the shared stroke sits at another index there. +fn mixed_circles(s: &mut Session, extra_fill: bool) -> (NodeId, NodeId) { + let mut circle = |x: f64, color: &str| { + let id = NodeId(run(s, "shape.ellipse", json!({"x": x, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"ids": [id.0], "color": color})); + run(s, "appearance.setItem", json!({"ids": [id.0], "index": 1, "color": "#0000ff", "weight": 11})); + run(s, "effect.apply", json!({"ids": [id.0], "effect": "stylize.feather", "item": null})); + id + }; + let (a, b) = (circle(0.0, "#ffff00"), circle(150.0, "#ff0000")); + if extra_fill { + // Another fill on top of b's fill and moved under its stroke: [Fill, Fill, Stroke]. + run(s, "appearance.addFill", json!({"ids": [b.0]})); + run(s, "appearance.moveItem", json!({"ids": [b.0], "from": 2, "to": 1})); + run(s, "appearance.setItem", json!({"ids": [b.0], "index": 1, "color": "#00ff00"})); + } + run(s, "select.set", json!({"ids": [a.0, b.0]})); + (a, b) +} + +/// The shared stroke row edits the stroke of both circles, whatever its index in each, as one undo +/// step, leaving their fills alone; undo restores both. +#[test] +fn a_shared_row_edits_every_selected_object() { + for extra in [false, true] { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, extra); + let b_stroke = if extra { 2 } else { 1 }; + let fills = (node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()); + let steps = s.doc().unwrap().history.undo.len(); + // The panel lists the first object's rows: the stroke is a's item 1. + run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 4, "aligned": true})); + assert_eq!(stroke(&node(&s, a), 1).width, 4.0); + assert_eq!(stroke(&node(&s, b), b_stroke).width, 4.0, "extra fill: {extra}"); + assert_eq!(node(&s, a).appearance.items[0], fills.0[0], "a's fill untouched"); + assert_eq!(node(&s, b).appearance.items.len(), fills.1.len()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!((node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()), fills, "undo restores both"); + // The active row (a's item 1) carries paint edits from other panels to both strokes too. + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "stroke.set", json!({"weight": 7})); + assert_eq!(stroke(&node(&s, b), b_stroke).width, 7.0, "extra fill: {extra}"); + } +} + +/// Removing the shared effect removes it from both; adding a fill adds one to both. +#[test] +fn shared_effects_and_new_fills_reach_every_selected_object() { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, true); + run(&mut s, "effect.remove", json!({"index": 0, "item": null, "aligned": true})); + assert!(node(&s, a).appearance.effects.is_empty() && node(&s, b).appearance.effects.is_empty()); + let (na, nb) = (node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()); + run(&mut s, "appearance.addFill", json!({})); + assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (na + 1, nb + 1)); + // Rows of different stacks aren't reordered together. + assert!(s.execute("appearance.moveItem", &json!({"from": 1, "to": 0, "aligned": true})).is_err()); +} + +/// Rows deleted together, one shared (the stroke) and one not (a's fill): each object loses its +/// own copy of the shared row, and the other object keeps the items at those indices of a's stack. +#[test] +fn removing_shared_and_unshared_rows_removes_only_the_shared_ones_elsewhere() { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, true); + // b is [Fill, Fill, Stroke]; a's rows 0 (its fill) and 1 (the shared stroke). + let fills_b: Vec<_> = node(&s, b).appearance.items.iter().take(2).cloned().collect(); + run(&mut s, "appearance.removeItem", json!({"indices": [0, 1], "aligned": true})); + assert!(node(&s, a).appearance.items.is_empty(), "a loses both"); + assert_eq!(node(&s, b).appearance.items, fills_b, "b loses only its stroke, keeping both fills"); +} diff --git a/crates/engine/src/tests_areafit.rs b/crates/engine/src/tests/areafit.rs similarity index 99% rename from crates/engine/src/tests_areafit.rs rename to crates/engine/src/tests/areafit.rs index dd4dadb37..57182baf3 100644 --- a/crates/engine/src/tests_areafit.rs +++ b/crates/engine/src/tests/areafit.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{AreaFit, NodeKind, TextKind, TextObject}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests/assets.rs similarity index 99% rename from crates/engine/src/tests_assets.rs rename to crates/engine/src/tests/assets.rs index 17fd41b65..58a4c3564 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests/assets.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests/attributes.rs similarity index 99% rename from crates/engine/src/tests_attributes.rs rename to crates/engine/src/tests/attributes.rs index b354e9fdb..4dea45940 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{ImageMap, NodeKind}; use vectorcraft_geom::{Affine, FillRule}; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs similarity index 99% rename from crates/engine/src/tests_bboxrotate.rs rename to crates/engine/src/tests/bboxrotate.rs index 902ed629a..17c6aac3c 100644 --- a/crates/engine/src/tests_bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests/blendfidelity.rs similarity index 99% rename from crates/engine/src/tests_blendfidelity.rs rename to crates/engine/src/tests/blendfidelity.rs index a3c06c95a..e73dc9242 100644 --- a/crates/engine/src/tests_blendfidelity.rs +++ b/crates/engine/src/tests/blendfidelity.rs @@ -7,7 +7,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Knockout, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests/blendopts.rs similarity index 99% rename from crates/engine/src/tests_blendopts.rs rename to crates/engine/src/tests/blendopts.rs index 4682ba1ea..f2bc3f279 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests/blendspine.rs similarity index 99% rename from crates/engine/src/tests_blendspine.rs rename to crates/engine/src/tests/blendspine.rs index 3a9009a18..78e9335ac 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests/blendspine.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::live::BlendSpec; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_brushlib.rs b/crates/engine/src/tests/brushlib.rs similarity index 99% rename from crates/engine/src/tests_brushlib.rs rename to crates/engine/src/tests/brushlib.rs index 1146a4083..f5ac96d50 100644 --- a/crates/engine/src/tests_brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_brush::BrushKind; use vectorcraft_doc::{NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests/brushsym.rs similarity index 99% rename from crates/engine/src/tests_brushsym.rs rename to crates/engine/src/tests/brushsym.rs index 43df06f14..c6a33ffc6 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -1074,7 +1074,7 @@ fn symbol_stainer_refuses_a_keep_model_given_as_text() { /// document and the undo history stay as they were. Numbers, true, false and null still work. #[test] fn symbol_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let (_, name, b) = make_symbol(&mut s); let at = json!([[b.center().x, b.center().y]]); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests/build.rs similarity index 99% rename from crates/engine/src/tests_build.rs rename to crates/engine/src/tests/build.rs index fb16cd5e9..a3f841b4a 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests/build.rs @@ -9,7 +9,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_testkit::pdf::{PdfPage, pdf}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -1734,7 +1734,7 @@ fn live_paint_stroke_edge() { /// and the undo history stay as they were. True, false and null still work. #[test] fn shape_builder_refuses_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); two(&mut s); let merge = |extra: Value| { @@ -1761,7 +1761,7 @@ fn shape_builder_refuses_a_number_or_boolean_given_as_text() { /// key; the document and the undo history stay as they were. Numbers and null still work. #[test] fn live_paint_and_image_trace_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let g = live(&mut s); let calls = [ diff --git a/crates/engine/src/tests_cancel.rs b/crates/engine/src/tests/cancel.rs similarity index 100% rename from crates/engine/src/tests_cancel.rs rename to crates/engine/src/tests/cancel.rs diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests/charstroke.rs similarity index 99% rename from crates/engine/src/tests_charstroke.rs rename to crates/engine/src/tests/charstroke.rs index 5ccdedce4..ba948cb66 100644 --- a/crates/engine/src/tests_charstroke.rs +++ b/crates/engine/src/tests/charstroke.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, StrokeAlign, TextObject}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clip.rs b/crates/engine/src/tests/clip.rs similarity index 99% rename from crates/engine/src/tests_clip.rs rename to crates/engine/src/tests/clip.rs index 2cc16daee..cbba2f36c 100644 --- a/crates/engine/src/tests_clip.rs +++ b/crates/engine/src/tests/clip.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_render::Rendered; -use super::*; +use crate::*; pub(crate) fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests/clipboard.rs similarity index 99% rename from crates/engine/src/tests_clipboard.rs rename to crates/engine/src/tests/clipboard.rs index b32b1c44f..410a828b9 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clipflavours.rs b/crates/engine/src/tests/clipflavours.rs similarity index 99% rename from crates/engine/src/tests_clipflavours.rs rename to crates/engine/src/tests/clipflavours.rs index 173bb4353..a72e87c1c 100644 --- a/crates/engine/src/tests_clipflavours.rs +++ b/crates/engine/src/tests/clipflavours.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::{NodeKind, TextObject}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; use crate::cmd::clipboard::{AI, BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clippaint.rs b/crates/engine/src/tests/clippaint.rs similarity index 99% rename from crates/engine/src/tests_clippaint.rs rename to crates/engine/src/tests/clippaint.rs index 673466d37..efcb1df70 100644 --- a/crates/engine/src/tests_clippaint.rs +++ b/crates/engine/src/tests/clippaint.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 50, "width": w, "height": 100})).unwrap()["id"].as_u64().unwrap()); diff --git a/crates/engine/src/tests_cmdsplit.rs b/crates/engine/src/tests/cmdsplit.rs similarity index 99% rename from crates/engine/src/tests_cmdsplit.rs rename to crates/engine/src/tests/cmdsplit.rs index 767b82b4c..af94ea3f3 100644 --- a/crates/engine/src/tests_cmdsplit.rs +++ b/crates/engine/src/tests/cmdsplit.rs @@ -3,8 +3,8 @@ use serde_json::json; -use super::*; use crate::cmd::{leaf_targets, paint_targets, unique_name}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_cmykflatten.rs b/crates/engine/src/tests/cmykflatten.rs similarity index 99% rename from crates/engine/src/tests_cmykflatten.rs rename to crates/engine/src/tests/cmykflatten.rs index fb2c8d961..c28ea0f06 100644 --- a/crates/engine/src/tests_cmykflatten.rs +++ b/crates/engine/src/tests/cmykflatten.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{Point, Rect, Shape}; use vectorcraft_render::Renderer; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() diff --git a/crates/engine/src/tests_colorguide.rs b/crates/engine/src/tests/colorguide.rs similarity index 99% rename from crates/engine/src/tests_colorguide.rs rename to crates/engine/src/tests/colorguide.rs index e5b979717..d96c4f414 100644 --- a/crates/engine/src/tests_colorguide.rs +++ b/crates/engine/src/tests/colorguide.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_colorguidelib.rs b/crates/engine/src/tests/colorguidelib.rs similarity index 99% rename from crates/engine/src/tests_colorguidelib.rs rename to crates/engine/src/tests/colorguidelib.rs index 41b592b87..986d8c469 100644 --- a/crates/engine/src/tests_colorguidelib.rs +++ b/crates/engine/src/tests/colorguidelib.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; -use super::*; +use crate::*; fn hexes(v: &Value) -> Vec { v.as_array().unwrap().iter().map(|h| h.as_str().unwrap().to_string()).collect() diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests/colormgmt.rs similarity index 99% rename from crates/engine/src/tests_colormgmt.rs rename to crates/engine/src/tests/colormgmt.rs index 0f370de4e..d1df8f5a1 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::Affine; use vectorcraft_render::proof::{ProofSetup, ProofTarget}; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. /// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs` diff --git a/crates/engine/src/tests_colorthemes.rs b/crates/engine/src/tests/colorthemes.rs similarity index 99% rename from crates/engine/src/tests_colorthemes.rs rename to crates/engine/src/tests/colorthemes.rs index 83551861c..8f531e20b 100644 --- a/crates/engine/src/tests_colorthemes.rs +++ b/crates/engine/src/tests/colorthemes.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; use vectorcraft_color::harmony::Harmony; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) diff --git a/crates/engine/src/tests_colourfonts.rs b/crates/engine/src/tests/colourfonts.rs similarity index 99% rename from crates/engine/src/tests_colourfonts.rs rename to crates/engine/src/tests/colourfonts.rs index 1e62fac02..a61f9f38b 100644 --- a/crates/engine/src/tests_colourfonts.rs +++ b/crates/engine/src/tests/colourfonts.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_text::test_fonts::{SVG_CHAR, source_sans_gid, svg_font}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_compoundpaint.rs b/crates/engine/src/tests/compoundpaint.rs similarity index 99% rename from crates/engine/src/tests_compoundpaint.rs rename to crates/engine/src/tests/compoundpaint.rs index 8c81ef2ce..4e22e31d3 100644 --- a/crates/engine/src/tests_compoundpaint.rs +++ b/crates/engine/src/tests/compoundpaint.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() diff --git a/crates/engine/src/tests_constrain_new.rs b/crates/engine/src/tests/constrain_new.rs similarity index 99% rename from crates/engine/src/tests_constrain_new.rs rename to crates/engine/src/tests/constrain_new.rs index 3a53333c5..4184cb3c9 100644 --- a/crates/engine/src/tests_constrain_new.rs +++ b/crates/engine/src/tests/constrain_new.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{LiveShape, NodeKind, TextKind}; use vectorcraft_geom::{PathData, Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; use PointerKind::{Down, Drag, Up}; diff --git a/crates/engine/src/tests_constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs similarity index 99% rename from crates/engine/src/tests_constrain_tools.rs rename to crates/engine/src/tests/constrain_tools.rs index 357f588fd..6a8317cfe 100644 --- a/crates/engine/src/tests_constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{LiveShape, NodeKind}; use vectorcraft_geom::{Point, Vec2}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn new_doc() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_containers.rs b/crates/engine/src/tests/containers.rs similarity index 99% rename from crates/engine/src/tests_containers.rs rename to crates/engine/src/tests/containers.rs index 003c8a0f3..8b218d9df 100644 --- a/crates/engine/src/tests_containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_css.rs b/crates/engine/src/tests/css.rs similarity index 99% rename from crates/engine/src/tests_css.rs rename to crates/engine/src/tests/css.rs index 8a39eb059..764990b10 100644 --- a/crates/engine/src/tests_css.rs +++ b/crates/engine/src/tests/css.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests/cut.rs similarity index 99% rename from crates/engine/src/tests_cut.rs rename to crates/engine/src/tests/cut.rs index f599203fd..b87070bd8 100644 --- a/crates/engine/src/tests_cut.rs +++ b/crates/engine/src/tests/cut.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_geom::{FillRule, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_dashalign.rs b/crates/engine/src/tests/dashalign.rs similarity index 98% rename from crates/engine/src/tests_dashalign.rs rename to crates/engine/src/tests/dashalign.rs index 3c6a9018a..079bfe899 100644 --- a/crates/engine/src/tests_dashalign.rs +++ b/crates/engine/src/tests/dashalign.rs @@ -5,8 +5,8 @@ use kurbo::Shape; use serde_json::json; use vectorcraft_doc::Dash; -use super::tests_outlinestroke::{outline, path_of}; -use super::*; +use crate::tests_outlinestroke::{outline, path_of}; +use crate::*; fn session_with_rect() -> (Session, NodeId) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests/distort.rs similarity index 99% rename from crates/engine/src/tests_distort.rs rename to crates/engine/src/tests/distort.rs index 7f326fc4b..d033fadee 100644 --- a/crates/engine/src/tests_distort.rs +++ b/crates/engine/src/tests/distort.rs @@ -5,7 +5,7 @@ use vectorcraft_geom::{PathData, Point, Rect, Shape}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_docsetup.rs b/crates/engine/src/tests/docsetup.rs similarity index 99% rename from crates/engine/src/tests_docsetup.rs rename to crates/engine/src/tests/docsetup.rs index c4a4fa3e3..e485af2ef 100644 --- a/crates/engine/src/tests_docsetup.rs +++ b/crates/engine/src/tests/docsetup.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CharPosition, DocSetup, ExportText, GridSize, NodeId, NodeKind, ScriptMetrics, TextObject, TextStyleDef, Unit}; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests/draw2.rs similarity index 80% rename from crates/engine/src/tests_draw2.rs rename to crates/engine/src/tests/draw2.rs index b14e29979..7bb0a3edf 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -5,8 +5,8 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Point}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; use crate::tooling::{UiRequest, ViewInfo}; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -1518,8 +1518,8 @@ fn path_commands_refuse_a_boolean_given_as_text() { ("path.blob", json!({"points": stroke, "mergeOnlySelection": 1}), "mergeOnlySelection"), ("path.blob", json!({"points": stroke, "merge": "false"}), "merge"), ] - .map(|(cmd, p, key)| super::tests::not_a_switch(cmd, p, key)); - let wrong = super::tests::not_refused(&mut s, &calls); + .map(|(cmd, p, key)| crate::tests::not_a_switch(cmd, p, key)); + let wrong = crate::tests::not_refused(&mut s, &calls); assert!(wrong.is_empty(), "not refused: {wrong:#?}"); s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": true})).unwrap(); s.execute("path.close", &json!({"id": id.0, "independent": null})).unwrap(); @@ -1943,25 +1943,6 @@ fn direct_segment_delete_keeps_the_other_rectangle_edges_and_one_undo() { } } -/// A segment pick's endpoints are hollow: Shift-clicking or Shift-marqueeing one selects that -/// anchor, rather than toggling it off and leaving the other endpoint selected. -#[test] -fn selecting_an_anchor_after_a_segment_pick_starts_from_no_anchors() { - let mut s = session(); - let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0); - let segment = json!({"id": id.0, "segments": [[0, 0]]}); - s.execute("select.segments", &segment).unwrap(); - s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"})).unwrap(); - let sel = &s.doc().unwrap().selection; - assert_eq!(sel.partial(id), Some(&[(0, 0)].into())); - assert!(sel.segments.is_empty()); - s.execute("select.segments", &segment).unwrap(); - s.execute("select.anchorsMany", &json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "add"})).unwrap(); - let sel = &s.doc().unwrap().selection; - assert_eq!(sel.partial(id), Some(&[(0, 1)].into())); - assert!(sel.segments.is_empty()); -} - #[test] fn segment_delete_preserves_curves_appearance_and_unrelated_contours() { let data = "M100 100C110 70 150 130 170 100C180 75 230 125 250 100C270 140 280 80 310 100C320 120 140 140 100 100Z M400 100L500 100L500 200Z"; @@ -2400,6 +2381,124 @@ fn join_takes_the_paths_and_the_ends_to_join() { } } +/// `path.join {ends}` on selected paths honors the requested ends, not the nearest pair. +#[test] +fn join_selected_paths_honors_ends_parameter() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + s.select(|_, sel| sel.set([a, b])).unwrap(); + // The far ends: a's first to b's last (nearest pair would be a's last and b's first). + s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]); +} + +/// An explicit `ends` wins over a direct-selected end pair when `ids` is omitted, as on `main`. +#[test] +fn join_explicit_ends_win_over_direct_selected_end_points() { + let pts = |s: &Session, id: NodeId| path(s, id).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::>(); + // (picked anchor of a, picked anchor of b, the ends asked for, the result they ask for) + let cases = [ + // Picks say a's last to b's first (also the nearest pair); the ends ask for the far ends. + (1, 0, ["first", "last"], [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]), + // Picks say a's first to b's first; the ends ask for a's last to b's first. + (0, 0, ["last", "first"], [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]), + ]; + for (pick_a, pick_b, ends, want) in cases { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, pick_a]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, pick_b]], "mode": "add"})).unwrap(); + let sel_before = s.doc().unwrap().selection.anchors.clone(); + s.execute("path.join", &json!({"ends": ends})).unwrap(); + assert_eq!(pts(&s, a), want, "ends {ends:?} with a:{pick_a} b:{pick_b} picked"); + assert!(s.doc().unwrap().doc.node(b).is_none(), "b was joined into a"); + // One undo step puts both lines, and the selection they had, back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pts(&s, a), [(0.0, 0.0), (100.0, 0.0)]); + assert_eq!(pts(&s, b), [(110.0, 0.0), (200.0, 0.0)]); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b], "the selection came back"); + assert_eq!(s.doc().unwrap().selection.anchors, sel_before, "with its direct-selected anchors"); + } +} + +/// The ends asked for apply to the selection order: with b selected first, both lines reverse and b keeps its id. +#[test] +fn join_explicit_ends_follow_the_selection_order() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + // In selection order the picks (b's first, a's last) match the ends asked for; the head reads the + // pick map in id order (a before b), so it joins the other pair, which this pins. + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, b).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(200.0, 0.0), (110.0, 0.0), (100.0, 0.0), (0.0, 0.0)], "b keeps its id and runs 200 to 0"); + assert!(s.doc().unwrap().doc.node(a).is_none(), "a was joined into b"); +} + +/// Two open subpaths of one path take the ends asked for, not the picked pair. +#[test] +fn join_explicit_ends_on_two_subpaths_of_one_path() { + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M200 0 L110 0"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [1, 0]], "mode": "set"})).unwrap(); + // The picks say subpath 0's last to subpath 1's first; the ends ask for subpath 1's last. + s.execute("path.join", &json!({"ends": ["last", "last"]})).unwrap(); + let pd = path(&s, id); + assert_eq!(pd.subpaths.len(), 1, "the two subpaths became one"); + let pts: Vec<(f64, f64)> = pd.subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]); +} + +/// `ends` names two paths: three open subpaths are refused, with and without a picked pair, and nothing changes. +#[test] +fn join_explicit_ends_refuses_more_than_two_open_subpaths() { + for picks in [false, true] { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + let c = line(&mut s, 210.0, 0.0, 300.0, 0.0); + s.select(|_, sel| sel.set([a, b, c])).unwrap(); + if picks { + // A picked pair cannot narrow a three-path request: two paths is what `ends` names. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + let sel_before = s.doc().unwrap().selection.objects.clone(); + let err = s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap_err(); + assert!(err.to_string().contains("exactly two paths"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing was joined"); + assert_eq!(s.doc().unwrap().selection.objects, sel_before, "the selection is unchanged"); + } +} + +/// Controls: explicit `ids` take `ends` over the picks, and without `ends` the picks still decide. +#[test] +fn join_direct_selected_end_points_controls() { + let pts = |s: &Session, id: NodeId| path(s, id).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::>(); + let pick = |s: &mut Session, a: NodeId, b: NodeId, pa: usize, pb: usize| { + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, pa]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, pb]], "mode": "add"})).unwrap(); + }; + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + pick(&mut s, a, b, 1, 0); + s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["first", "last"]})).unwrap(); + assert_eq!(pts(&s, a), [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)], "ids: the ends win"); + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + pick(&mut s, a, b, 0, 0); + s.execute("path.join", &json!({})).unwrap(); + assert_eq!(pts(&s, a), [(100.0, 0.0), (0.0, 0.0), (110.0, 0.0), (200.0, 0.0)], "no ends: the picks decide"); +} + /// Without `ends`, the end points picked with the direct selection are the ones joined. #[test] fn join_uses_the_selected_end_points() { @@ -2414,6 +2513,18 @@ fn join_uses_the_selected_end_points() { assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]); } +/// `path.join {ids, subpaths}` without `ends` computes the nearest pair within those subpaths. +#[test] +fn join_subpaths_without_ends_computes_nearest_pair() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 200.0, 0.0, 110.0, 0.0); + // Nearest ends between a (0..100) and b (200..110) are 100 and 110. + s.execute("path.join", &json!({"ids": [a.0, b.0], "subpaths": [0, 0]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]); +} + /// `path.join {to}` makes the joined anchor a corner or smooth point. #[test] fn join_to_corner_or_smooth_sets_the_merged_anchor() { @@ -2673,6 +2784,565 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() { } } +/// Pen tool can continue drawing an open path inside a compound path and close it. +#[test] +fn compound_path_pen_continue_and_close() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let r = rect(&mut s, 50.0, 50.0, 200.0, 200.0); + let p = line(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.set", &json!({"ids": [r.0, p.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.select_tool("pen", v).unwrap(); + // Hovering over the last anchor of the open member path shows PenContinue. + assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue); + // Click on the last anchor to continue drawing. + gesture(&mut s, &[(200.0, 100.0)], none); + // Rubber band is active, cursor over the start of the path shows PenClose. + assert_eq!(s.cursor(Point::new(100.0, 100.0), none, v), Cursor::PenClose); + // Add an intermediate anchor. + gesture(&mut s, &[(200.0, 200.0)], none); + assert_eq!(s.cursor(Point::new(100.0, 100.0), none, v), Cursor::PenClose); + // Click on the first anchor to close the path. + gesture(&mut s, &[(100.0, 100.0)], none); + + // Verify member path is now closed inside the compound path. + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 2); + for child in ch { + let pd = child.path_data().unwrap(); + assert!(pd.is_closed(), "all member paths should be closed"); + } + // Selection should be on the compound path. + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); +} + +/// Pen tool can continue an open path inside a compound path and join another open path inside it. +#[test] +fn compound_path_pen_continue_and_join() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.select_tool("pen", v).unwrap(); + assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue); + // Continue from a's end. + gesture(&mut s, &[(200.0, 100.0)], none); + // Hover over b's start shows PenJoin. + assert_eq!(s.cursor(Point::new(300.0, 100.0), none, v), Cursor::PenJoin); + // Click on b's start to join them. + assert_eq!(gesture(&mut s, &[(300.0, 100.0)], none), 1); + + // The two open paths are now joined into one path inside the compound path. + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1, "joined into one path"); + let pd = ch[0].path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); +} + +/// Path join (Cmd+J) works on compound paths with open paths. +#[test] +fn compound_path_join_command() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Join with compound path selected joins its 2 open paths. + s.execute("path.join", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + let pd = ch[0].path_data().unwrap(); + assert!(!pd.is_closed()); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); + + // Joining again closes the single open path. + s.execute("path.join", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + assert!(ch[0].path_data().unwrap().is_closed()); +} + +/// Path join with direct-selected endpoints of a compound path connects those endpoints. +#[test] +fn compound_path_join_direct_selected_endpoints() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + let ch = s.doc().unwrap().doc.node(cid).unwrap().children().unwrap().clone(); + let ca = ch[0].id; + let cb = ch[1].id; + + // Direct select endpoints: ca's first (100, 100) and cb's start (300, 100). + s.execute("select.anchors", &json!({"id": ca.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": cb.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + let pd = ch[0].path_data().unwrap(); + let pts: Vec<(f64, f64)> = pd.subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + // ca's first was connected to cb's last. + assert_eq!(pts, [(200.0, 100.0), (100.0, 100.0), (300.0, 100.0), (400.0, 100.0)]); +} + +/// Explicit ids in path.join validate compound expansion and allow joining three or more paths. +#[test] +fn compound_path_join_explicit_ids_validation() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + let c = line(&mut s, 500.0, 100.0, 600.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Multi-id join with compound containing != 1 open path (has 2) errors. + let err = s.execute("path.join", &json!({"ids": [cid.0, c.0]})).unwrap_err(); + assert!(err.to_string().contains("must contain exactly one open path to join")); + + // Single compound id with 2 open paths resolves to 2 paths and succeeds. + assert!(s.execute("path.join", &json!({"ids": [cid.0]})).is_ok()); + + // Multi-id join with compound now containing 1 open path + another path succeeds. + assert!(s.execute("path.join", &json!({"ids": [cid.0, c.0]})).is_ok()); + + // Joining with explicit ends and > 2 paths errors. + let x = line(&mut s, 0.0, 0.0, 10.0, 10.0); + let y = line(&mut s, 20.0, 0.0, 30.0, 10.0); + let z = line(&mut s, 40.0, 0.0, 50.0, 10.0); + let err = s.execute("path.join", &json!({"ids": [x.0, y.0, z.0], "ends": ["first", "last"]})).unwrap_err(); + assert!(err.to_string().contains("`ends` applies to joining exactly two paths")); + + // Joining three or more paths joins them all. + s.execute("path.join", &json!({"ids": [x.0, y.0, z.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + let xnode = d.node(x).unwrap(); + let pd = xnode.path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 6); + assert!(d.node(y).is_none()); + assert!(d.node(z).is_none()); +} + +/// Joining the only member of a compound path into another path removes the empty compound parent. +#[test] +fn join_removes_empty_compound_parent() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 12.0, 0.0, 20.0, 0.0); + // Make b into a compound path (single open path child) + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Join a and the compound path + s.execute("select.set", &json!({"ids": [a.0, cid.0]})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + // Compound path container should be removed since its only member was joined into a. + assert!(d.node(cid).is_none()); + assert!(d.node(a).is_some()); + let pd = d.node(a).unwrap().path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 4); +} + +/// Join with three or more open paths selected joins them all into a single path (Illustrator parity). +#[test] +fn join_three_or_more_paths() { + let mut s = session(); + // Path 1: (0, 0) -> (10, 0) + let p1 = line(&mut s, 0.0, 0.0, 10.0, 0.0); + // Path 2: (12, 0) -> (20, 0) + let p2 = line(&mut s, 12.0, 0.0, 20.0, 0.0); + // Path 3: (22, 0) -> (30, 0) + let p3 = line(&mut s, 22.0, 0.0, 30.0, 0.0); + // Path 4: (32, 0) -> (40, 0) + let p4 = line(&mut s, 32.0, 0.0, 40.0, 0.0); + + s.execute("select.set", &json!({"ids": [p1.0, p2.0, p3.0, p4.0]})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let remaining_id = s.doc().unwrap().selection.objects[0]; + let pd = d.node(remaining_id).unwrap().path_data().unwrap(); + assert_eq!(pd.subpaths.len(), 1); + assert!(!pd.is_closed()); + assert_eq!(pd.subpaths[0].anchors.len(), 8); + let xs: Vec = pd.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, vec![0.0, 10.0, 12.0, 20.0, 22.0, 30.0, 32.0, 40.0]); +} + +/// Join with compound path containing three open member paths joins them all into one. +#[test] +fn compound_path_join_three_open_members() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 15.0, 0.0, 25.0, 0.0); + let c = line(&mut s, 30.0, 0.0, 40.0, 0.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + let pd = ch[0].path_data().unwrap(); + assert!(!pd.is_closed()); + assert_eq!(pd.subpaths[0].anchors.len(), 6); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); +} + +/// Joining one subpath of a multi-subpath node retains its other subpaths. +#[test] +fn join_multi_subpath_retains_unjoined_subpaths() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 200.0, 0.0, 300.0, 0.0); + // Add a second open subpath to b. + let second_sub = vectorcraft_geom::SubPath::polyline(&[Point::new(400.0, 50.0), Point::new(500.0, 50.0)], false); + s.edit("Add Subpath", |d, _| { + if let NodeKind::Path { path, .. } = &mut d.node_mut(b).unwrap().kind { + path.subpaths.push(second_sub); + } + Ok(()) + }) + .unwrap(); + + // Direct-select an endpoint on a and an endpoint on b's second subpath. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[1, 0]], "mode": "add"})).unwrap(); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + // Node b still exists because its first subpath was not consumed. + assert!(d.node(b).is_some()); + let pb = d.node(b).unwrap().path_data().unwrap(); + assert_eq!(pb.subpaths.len(), 1); + assert_eq!(pb.subpaths[0].anchors[0].p, Point::new(200.0, 0.0)); + + // Node a now has the joined subpath. + let pa = d.node(a).unwrap().path_data().unwrap(); + assert_eq!(pa.subpaths.len(), 1); + assert_eq!(pa.subpaths[0].anchors.len(), 4); +} + +/// The Pen tool does not continue or join guide paths. +#[test] +fn pen_does_not_continue_or_join_guide_paths() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let g = line(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + + s.select_tool("pen", v).unwrap(); + // Hovering over the guide endpoint does not offer PenContinue. + assert_ne!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue); +} + +/// Joining two open subpaths within the same path node merges them into one subpath. +#[test] +fn join_two_subpaths_of_same_path_node() { + let mut s = session(); + let p = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let sub2 = vectorcraft_geom::SubPath::polyline(&[Point::new(150.0, 0.0), Point::new(250.0, 0.0)], false); + s.edit("Add Subpath", |d, _| { + if let NodeKind::Path { path, .. } = &mut d.node_mut(p).unwrap().kind { + path.subpaths.push(sub2); + } + Ok(()) + }) + .unwrap(); + + // Direct-select anchor (0, 1) and anchor (1, 0) of the same node. + s.execute("select.anchors", &json!({"id": p.0, "anchors": [[0, 1], [1, 0]], "mode": "set"})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let pd = d.node(p).unwrap().path_data().unwrap(); + assert_eq!(pd.subpaths.len(), 1); + assert!(!pd.subpaths[0].closed); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(0.0, 0.0)); + assert_eq!(pd.subpaths[0].anchors[1].p, Point::new(100.0, 0.0)); + assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(150.0, 0.0)); + assert_eq!(pd.subpaths[0].anchors[3].p, Point::new(250.0, 0.0)); +} + +/// Direct-selecting 2 open endpoints in a complex compound path with multiple open segments joins only those 2. +#[test] +fn join_complex_compound_path_direct_endpoints() { + let mut s = session(); + let c1 = line(&mut s, 0.0, 0.0, 50.0, 0.0); + let c2 = line(&mut s, 100.0, 0.0, 150.0, 0.0); + let c3 = line(&mut s, 200.0, 0.0, 250.0, 0.0); + + s.execute("select.set", &json!({"ids": [c1.0, c2.0, c3.0]})).unwrap(); + let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Direct-select the end of c1 and the start of c3. + s.execute("select.anchors", &json!({"id": c1.0, "anchors": [[0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": c3.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let comp_node = d.node(comp).unwrap(); + let children = comp_node.children().unwrap(); + // c3 was joined into c1, c2 remains untouched. Total 2 children in compound. + assert_eq!(children.len(), 2); + let joined = children.iter().find(|c| c.id == c1).unwrap().path_data().unwrap(); + assert_eq!(joined.subpaths.len(), 1); + assert_eq!(joined.subpaths[0].anchors.len(), 4); + assert_eq!(joined.subpaths[0].anchors[0].p, Point::new(0.0, 0.0)); + assert_eq!(joined.subpaths[0].anchors[1].p, Point::new(50.0, 0.0)); + assert_eq!(joined.subpaths[0].anchors[2].p, Point::new(200.0, 0.0)); + assert_eq!(joined.subpaths[0].anchors[3].p, Point::new(250.0, 0.0)); + + let intact = children.iter().find(|c| c.id == c2).unwrap().path_data().unwrap(); + assert_eq!(intact.subpaths.len(), 1); + assert_eq!(intact.subpaths[0].anchors.len(), 2); +} + +/// Continuing a complex compound path with the Pen tool begins at the exact clicked anchor and joins to another open segment. +#[test] +fn pen_continue_and_join_specific_open_subpath_in_compound() { + let v = view(); + let mut s = session(); + let c1 = line(&mut s, 10.0, 100.0, 60.0, 100.0); + let c2 = line(&mut s, 10.0, 200.0, 60.0, 200.0); + s.execute("select.set", &json!({"ids": [c1.0, c2.0]})).unwrap(); + let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.select_tool("pen", v).unwrap(); + // Click at the end of c1 (60, 100) to continue drawing c1. + s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 100.0), v).unwrap(); + + // Now click at the start of c2 (10, 200) to join c1 and c2. + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 200.0), v).unwrap(); + + let d = &s.doc().unwrap().doc; + let comp_node = d.node(comp).unwrap(); + let children = comp_node.children().unwrap(); + assert_eq!(children.len(), 1); + let pd = children[0].path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(10.0, 100.0)); + assert_eq!(pd.subpaths[0].anchors[1].p, Point::new(60.0, 100.0)); + assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(10.0, 200.0)); + assert_eq!(pd.subpaths[0].anchors[3].p, Point::new(60.0, 200.0)); +} + +#[test] +fn join_rejects_ends_with_more_than_two_paths() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let err = s.execute("path.join", &json!({"ids": [a.0, b.0, c.0], "ends": ["first", "last"]})).unwrap_err(); + assert!(err.to_string().contains("`ends` applies to joining exactly two paths")); +} + +#[test] +fn join_removes_empty_compound_when_last_member_joined() { + let mut s = session(); + let p1 = NodeId(s.execute("path.create", &json!({"d": "M10 10 L50 10"})).unwrap()["id"].as_u64().unwrap()); + let p2 = NodeId(s.execute("path.create", &json!({"d": "M60 10 L100 10"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [p2.0]})).unwrap(); + let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.node(comp).unwrap().kind, NodeKind::Compound { .. })); + // Join p1 and compound containing p2 + s.execute("path.join", &json!({"ids": [p1.0, comp.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(comp).is_none(), "empty compound container was removed"); +} + +#[test] +fn test_join_direct_selected_two_anchors() { + for (pick_a, pick_b, exp_junction) in [(0, 0, (0.0, 105.0)), (0, 1, (0.0, 200.0)), (1, 0, (100.0, 105.0)), (1, 1, (100.0, 200.0))] { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 105.0, 0.0, 200.0, 0.0); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, pick_a]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, pick_b]], "mode": "toggle"})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let pd = path(&s, a); + let pts: Vec = pd.subpaths[0].anchors.iter().map(|x| x.p.x).collect(); + // Check junction: there must be consecutive points matching exp_junction + let connected = + pts.windows(2).any(|w| (w[0] == exp_junction.0 && w[1] == exp_junction.1) || (w[0] == exp_junction.1 && w[1] == exp_junction.0)); + assert!(connected, "pick_a={pick_a}, pick_b={pick_b}, exp={exp_junction:?}, got pts={pts:?}"); + } +} + +#[test] +fn test_join_direct_selected_after_whole_selection() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 105.0, 0.0, 200.0, 0.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + // User wants to join (0, 0) and (200, 0): pick a's anchor 0 and b's anchor 1 + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "toggle"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "toggle"})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let pd = path(&s, a); + let pts: Vec = pd.subpaths[0].anchors.iter().map(|x| x.p.x).collect(); + let connected = pts.windows(2).any(|w| (w[0] == 0.0 && w[1] == 200.0) || (w[0] == 200.0 && w[1] == 0.0)); + assert!(connected, "expected 0.0 and 200.0 to be connected, got pts={pts:?}"); +} + +/// Review #2: the Pen sends the subpath it continues even when that is subpath 0, so the engine +/// never falls back to the last open subpath (append, close) or to every subpath (reverse). +#[test] +fn pen_continues_and_reverses_subpath_zero_of_a_path_with_two_open_subpaths() { + let v = view(); + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M0 50 L100 50"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("pen", v).unwrap(); + + // Continue from the end of subpath 0: the new anchor must land on subpath 0, not on subpath 1. + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 0.0), v).unwrap(); + let pd = path(&s, id); + assert_eq!(pd.subpaths[0].anchors.len(), 3, "the new anchor extends subpath 0"); + assert_eq!(pd.subpaths[1].anchors.len(), 2, "subpath 1 is left alone"); + assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(200.0, 0.0)); + + // Continue from the start of subpath 0: only subpath 0 reverses, the other keeps its direction. + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M0 50 L100 50"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("pen", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 0.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 0.0, 0.0), v).unwrap(); + let pd = path(&s, id); + assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(100.0, 0.0), "subpath 0 was reversed"); + assert_eq!(pd.subpaths[1].anchors[0].p, Point::new(0.0, 50.0), "subpath 1 keeps its direction"); +} + +/// Review #5: Join closes the first open subpath, not subpath 0, so a closed subpath 0 doesn't +/// make it fail with "path is already closed". +#[test] +fn join_closes_the_open_subpath_when_subpath_zero_is_closed() { + for explicit in [false, true] { + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M50 0 L90 0 L90 40"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let params = if explicit { json!({"ids": [id.0]}) } else { json!({}) }; + s.execute("path.join", ¶ms).unwrap(); + let pd = path(&s, id); + assert!(pd.subpaths[0].closed && pd.subpaths[1].closed, "both subpaths are closed now: {params}"); + assert_eq!(pd.subpaths[0].anchors.len(), 3, "the closed subpath is untouched"); + } +} + +/// Review #7: more than two selected path ends can't name one join, so it is refused rather than +/// silently joining every selected path. +#[test] +fn join_refuses_more_than_two_selected_ends() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 20.0, 0.0, 30.0, 0.0); + let c = line(&mut s, 40.0, 0.0, 50.0, 0.0); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("path.join", &json!({})).unwrap_err(); + assert!(err.to_string().contains("exactly two"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing was joined"); +} + +/// Review #7: joining a path to one subpath of a node that has other subpaths leaves that node and +/// its remaining subpaths in place, also when more paths follow. +#[test] +fn join_keeps_the_other_subpaths_of_a_joined_node() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = NodeId(s.execute("path.create", &json!({"d": "M10 0 L20 0 M100 100 L110 100"})).unwrap()["id"].as_u64().unwrap()); + let c = line(&mut s, 20.0, 0.0, 30.0, 0.0); + s.execute("path.join", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + assert_eq!(path(&s, a).subpaths[0].anchors.len(), 4, "a, b's first subpath and c became one"); + let rest = path(&s, b); + assert_eq!(rest.subpaths.len(), 1, "b keeps its other subpath"); + assert_eq!(rest.subpaths[0].anchors[0].p, Point::new(100.0, 100.0)); + assert!(s.doc().unwrap().doc.node(c).is_none(), "c was consumed"); +} + +/// Review #7: an empty subpath is refused with an error, not an underflow. +#[test] +fn join_two_refuses_an_empty_subpath() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 20.0, 0.0, 30.0, 0.0); + s.edit("Empty subpath", |d, _| { + if let NodeKind::Path { path, .. } = &mut d.node_mut(a).unwrap().kind { + path.subpaths[0] = vectorcraft_geom::SubPath::new(vec![], false); + } + Ok(()) + }) + .unwrap(); + assert!(s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["last", "first"], "subpaths": [0, 0]})).is_err()); +} + +/// Continuing a compound member at its first anchor reverses that subpath only. The Pen puts the +/// compound back in the selection before it reverses, so the reverse has to name the member path: +/// addressed through the selection it would walk the compound and flip every member's subpath. +#[test] +fn pen_continue_at_a_first_anchor_in_a_compound_reverses_only_that_subpath() { + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let a = NodeId(s.execute("path.create", &json!({"d": "M100 100 L200 100 M100 150 L200 150"})).unwrap()["id"].as_u64().unwrap()); + let b = NodeId(s.execute("path.create", &json!({"d": "M300 100 L400 100 M300 150 L400 150"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + // The member path itself is the whole selection, as a direct selection inside the compound + // leaves it: continuing it selects the compound again, which must not widen the reverse. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let (before_a, before_b) = (path(&s, a), path(&s, b)); + + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(100.0, 100.0)], none); + + let after_a = path(&s, a); + assert_eq!(after_a.subpaths[0].anchors.iter().map(|x| x.p).collect::>(), vec![Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!(after_a.subpaths[1], before_a.subpaths[1], "the member's other subpath keeps its anchors and handles"); + assert_eq!(path(&s, b), before_b, "the compound's other member keeps its anchors, handles and closed flags"); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid], "the compound is selected again"); +} + /// The anchors of a closed, slightly wobbly blob drawn at `fidelity`: samples half a point apart, /// with a small tremor on top of the outline. fn blob_anchors(s: &mut Session, fidelity: f64) -> usize { @@ -2812,3 +3482,33 @@ fn pencil_with_live_preview_off_draws_the_filled_path_on_release() { assert!(n.path_data().is_some() && !n.appearance.fill_paint().is_none(), "{:?}", n.appearance); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); } + +fn shape_anchors(cmd: &str, params: &serde_json::Value) -> usize { + let mut s = session(); + let r = s.execute(cmd, params).unwrap(); + let doc = &s.doc().unwrap().doc; + doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap().path_data().unwrap().anchors().count() +} + +#[test] +fn shape_counts_written_as_whole_floats_are_honoured() { + let poly = |v: serde_json::Value| shape_anchors("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 50, "sides": v})); + assert_eq!(poly(json!(5.0)), 5); + assert_eq!(poly(json!(5)), 5); + let star = |v: serde_json::Value| shape_anchors("shape.star", &json!({"cx": 100, "cy": 100, "radius1": 50, "points": v})); + assert_eq!(star(json!(7.0)), star(json!(7))); + assert_ne!(star(json!(7.0)), star(json!(5))); + let spiral = |v: serde_json::Value| shape_anchors("shape.spiral", &json!({"cx": 100, "cy": 100, "radius": 50, "segments": v})); + assert_eq!(spiral(json!(4.0)), spiral(json!(4))); + assert_ne!(spiral(json!(4.0)), spiral(json!(10))); +} + +#[test] +fn shape_counts_that_are_not_whole_numbers_are_errors() { + for bad in [json!(5.5), json!(-3), json!(-3.0), json!("5")] { + let mut s = session(); + assert!(s.execute("shape.polygon", &json!({"cx": 1, "cy": 1, "radius": 5, "sides": bad})).is_err(), "{bad}"); + assert!(s.execute("shape.star", &json!({"cx": 1, "cy": 1, "radius1": 5, "points": bad})).is_err(), "{bad}"); + assert!(s.execute("shape.spiral", &json!({"cx": 1, "cy": 1, "radius": 5, "segments": bad})).is_err(), "{bad}"); + } +} diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests/editcolors.rs similarity index 99% rename from crates/engine/src/tests_editcolors.rs rename to crates/engine/src/tests/editcolors.rs index ee4c050a8..d17ded2a7 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -5,8 +5,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Node; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; use crate::tooling::ViewInfo; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_effectedit.rs b/crates/engine/src/tests/effectedit.rs similarity index 99% rename from crates/engine/src/tests_effectedit.rs rename to crates/engine/src/tests/effectedit.rs index 042425895..b64005b6d 100644 --- a/crates/engine/src/tests_effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Node; -use super::*; +use crate::*; /// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`. fn session_with_rect() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_emptytype.rs b/crates/engine/src/tests/emptytype.rs similarity index 99% rename from crates/engine/src/tests_emptytype.rs rename to crates/engine/src/tests/emptytype.rs index 47e8b0310..0ae0184e0 100644 --- a/crates/engine/src/tests_emptytype.rs +++ b/crates/engine/src/tests/emptytype.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind, TextKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, SurroundingUnit, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { // Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off). diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests/envelope.rs similarity index 99% rename from crates/engine/src/tests_envelope.rs rename to crates/engine/src/tests/envelope.rs index a6e917d08..0180acb31 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::live::{self, EnvelopeKind, EnvelopeOptions, PreserveShape}; use vectorcraft_doc::{Knockout, Node, NodeKind, OpacityMask}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs similarity index 99% rename from crates/engine/src/tests_envelope_distort.rs rename to crates/engine/src/tests/envelope_distort.rs index 00b2bf6e9..c6727d866 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_render::effects; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests/envelope_edit.rs similarity index 99% rename from crates/engine/src/tests_envelope_edit.rs rename to crates/engine/src/tests/envelope_edit.rs index 5a1186dc1..7b87bef2c 100644 --- a/crates/engine/src/tests_envelope_edit.rs +++ b/crates/engine/src/tests/envelope_edit.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{EnvelopeKind, Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Vec2}; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_expand.rs b/crates/engine/src/tests/expand.rs similarity index 99% rename from crates/engine/src/tests_expand.rs rename to crates/engine/src/tests/expand.rs index 3d7e0baea..015439cbe 100644 --- a/crates/engine/src/tests_expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::{Node, NodeKind}; -use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; -use super::*; +use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests/eyedropper.rs similarity index 99% rename from crates/engine/src/tests_eyedropper.rs rename to crates/engine/src/tests/eyedropper.rs index 4ef5773ca..5f5e550b1 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, Justify, LineCap, Node, NodeKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests/file.rs similarity index 99% rename from crates/engine/src/tests_file.rs rename to crates/engine/src/tests/file.rs index 01837b0c9..78bf7381e 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests/file.rs @@ -1,6 +1,6 @@ use serde_json::json; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests/fileinfo.rs similarity index 99% rename from crates/engine/src/tests_fileinfo.rs rename to crates/engine/src/tests/fileinfo.rs index bbe8eca66..c37cbfe90 100644 --- a/crates/engine/src/tests_fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CopyrightStatus, DocMetadata}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_flatpresets.rs b/crates/engine/src/tests/flatpresets.rs similarity index 99% rename from crates/engine/src/tests_flatpresets.rs rename to crates/engine/src/tests/flatpresets.rs index df5b2af67..3eef893bb 100644 --- a/crates/engine/src/tests_flatpresets.rs +++ b/crates/engine/src/tests/flatpresets.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; -use super::*; use crate::cmd::{FlattenOptions, FlattenerPreset}; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() diff --git a/crates/engine/src/tests_flatpreview.rs b/crates/engine/src/tests/flatpreview.rs similarity index 99% rename from crates/engine/src/tests_flatpreview.rs rename to crates/engine/src/tests/flatpreview.rs index cd7033dde..9487da133 100644 --- a/crates/engine/src/tests_flatpreview.rs +++ b/crates/engine/src/tests/flatpreview.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use super::*; use crate::cmd::flatten::Highlight; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests/flatten.rs similarity index 99% rename from crates/engine/src/tests_flatten.rs rename to crates/engine/src/tests/flatten.rs index 69882a32b..e2b9b4f6a 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -7,9 +7,9 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_render::{Rendered, Renderer}; -use super::tests_expand::{RANDOM_CASES, random_transforms}; -use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; -use super::*; +use crate::tests_expand::{RANDOM_CASES, random_transforms}; +use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; +use crate::*; /// Colours here go through the process-wide colour settings, which the colour management tests /// change: one at a time with them. diff --git a/crates/engine/src/tests_focal.rs b/crates/engine/src/tests/focal.rs similarity index 99% rename from crates/engine/src/tests_focal.rs rename to crates/engine/src/tests/focal.rs index 214fe774b..166600a86 100644 --- a/crates/engine/src/tests_focal.rs +++ b/crates/engine/src/tests/focal.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{GradientGeom, Paint}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; /// A 200 pt square at (100, 100) with a radial fill centred on it, radius 100. fn radial() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_fontfiles.rs b/crates/engine/src/tests/fontfiles.rs similarity index 100% rename from crates/engine/src/tests_fontfiles.rs rename to crates/engine/src/tests/fontfiles.rs diff --git a/crates/engine/src/tests_fontlist.rs b/crates/engine/src/tests/fontlist.rs similarity index 99% rename from crates/engine/src/tests_fontlist.rs rename to crates/engine/src/tests/fontlist.rs index e66bb1297..d0804b7d0 100644 --- a/crates/engine/src/tests_fontlist.rs +++ b/crates/engine/src/tests/fontlist.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_text::{FontDb, system_font_dirs}; -use super::*; +use crate::*; fn names(v: &Value) -> Vec { serde_json::from_value(v.clone()).unwrap() diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests/freeform.rs similarity index 99% rename from crates/engine/src/tests_freeform.rs rename to crates/engine/src/tests/freeform.rs index e64a9a805..df9ed3e4b 100644 --- a/crates/engine/src/tests_freeform.rs +++ b/crates/engine/src/tests/freeform.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint}; use vectorcraft_geom::Point; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -243,7 +243,7 @@ fn lines_join_split_and_follow_the_selection() { /// the undo history stay as they were. Numbers, false and null still work. #[test] fn freeform_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let (mut s, id) = freeform_rect(); run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]})); let calls = [ diff --git a/crates/engine/src/tests_globaledit.rs b/crates/engine/src/tests/globaledit.rs similarity index 99% rename from crates/engine/src/tests_globaledit.rs rename to crates/engine/src/tests/globaledit.rs index c928d1712..e6ad7fac9 100644 --- a/crates/engine/src/tests_globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Affine, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests/gradient.rs similarity index 99% rename from crates/engine/src/tests_gradient.rs rename to crates/engine/src/tests/gradient.rs index c2e1c0e6a..c36addcbc 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPai use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -101,7 +101,7 @@ fn bad_gradients_are_rejected_without_editing() { /// the object and the undo history stay as they were. True, false, numbers and null still work. #[test] fn gradient_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests/gradpanel.rs similarity index 99% rename from crates/engine/src/tests_gradpanel.rs rename to crates/engine/src/tests/gradpanel.rs index b01592ab2..85fd4cf02 100644 --- a/crates/engine/src/tests_gradpanel.rs +++ b/crates/engine/src/tests/gradpanel.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_color::{GradientGeom, Paint}; use vectorcraft_geom::Point; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_gradspan.rs b/crates/engine/src/tests/gradspan.rs similarity index 99% rename from crates/engine/src/tests_gradspan.rs rename to crates/engine/src/tests/gradspan.rs index 59f29a361..967420464 100644 --- a/crates/engine/src/tests_gradspan.rs +++ b/crates/engine/src/tests/gradspan.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{GradientPaint, Paint}; use vectorcraft_geom::{Affine, Point}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_halftone.rs b/crates/engine/src/tests/halftone.rs similarity index 99% rename from crates/engine/src/tests_halftone.rs rename to crates/engine/src/tests/halftone.rs index 134626344..caef57ddb 100644 --- a/crates/engine/src/tests_halftone.rs +++ b/crates/engine/src/tests/halftone.rs @@ -2,9 +2,9 @@ use serde_json::json; -use super::*; use crate::tests_adjust::{fill_hex, node, red_rect, session}; use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; +use crate::*; #[test] fn halftone_turns_art_into_dots_by_tone() { diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests/history_mem.rs similarity index 99% rename from crates/engine/src/tests_history_mem.rs rename to crates/engine/src/tests/history_mem.rs index 7728672cc..af5d49552 100644 --- a/crates/engine/src/tests_history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; -use super::*; +use crate::*; /// A session with one document holding `n` rectangles, all selected. fn session_with_rects(n: usize) -> Session { diff --git a/crates/engine/src/tests_ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs similarity index 99% rename from crates/engine/src/tests_ids_without_selection.rs rename to crates/engine/src/tests/ids_without_selection.rs index 1660a7630..f8449c104 100644 --- a/crates/engine/src/tests_ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -3,7 +3,7 @@ use serde_json::json; -use super::*; +use crate::*; fn doc_with_rect() -> (Session, u64) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_inline.rs b/crates/engine/src/tests/inline.rs similarity index 99% rename from crates/engine/src/tests_inline.rs rename to crates/engine/src/tests/inline.rs index 6ed8533b7..4b14b74be 100644 --- a/crates/engine/src/tests_inline.rs +++ b/crates/engine/src/tests/inline.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{NodeId, NodeKind, TextObject}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_interactivity.rs b/crates/engine/src/tests/interactivity.rs similarity index 99% rename from crates/engine/src/tests_interactivity.rs rename to crates/engine/src/tests/interactivity.rs index a3fe01286..36a2855df 100644 --- a/crates/engine/src/tests_interactivity.rs +++ b/crates/engine/src/tests/interactivity.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_intertwine.rs b/crates/engine/src/tests/intertwine.rs similarity index 99% rename from crates/engine/src/tests_intertwine.rs rename to crates/engine/src/tests/intertwine.rs index 515f5a9c9..02a279447 100644 --- a/crates/engine/src/tests_intertwine.rs +++ b/crates/engine/src/tests/intertwine.rs @@ -6,8 +6,8 @@ use serde_json::{Value, json}; use vectorcraft_render::Rendered; -use super::tests_clip::{hit, id_of, render, select, session}; -use super::*; +use crate::tests_clip::{hit, id_of, render, select, session}; +use crate::*; /// Ring A (red, centre 35,50) and ring B (blue, centre 65,50), radius 25, 8 pt strokes, no fill: /// B is in front. The rings cross at (50, 30) and (50, 70). diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests/journal.rs similarity index 99% rename from crates/engine/src/tests_journal.rs rename to crates/engine/src/tests/journal.rs index 311700598..92feb107f 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -4,7 +4,7 @@ use serde_json::json; -use super::*; +use crate::*; fn created(s: &Session) -> Option { s.doc().unwrap().doc.metadata.created diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests/keyguide.rs similarity index 99% rename from crates/engine/src/tests_keyguide.rs rename to crates/engine/src/tests/keyguide.rs index 9e45ae0f5..09acdd726 100644 --- a/crates/engine/src/tests_keyguide.rs +++ b/crates/engine/src/tests/keyguide.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests/knockout.rs similarity index 99% rename from crates/engine/src/tests_knockout.rs rename to crates/engine/src/tests/knockout.rs index 972dd9f74..723ef66dd 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests/knockout.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Knockout; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_labspots.rs b/crates/engine/src/tests/labspots.rs similarity index 99% rename from crates/engine/src/tests_labspots.rs rename to crates/engine/src/tests/labspots.rs index 24b1a5c92..206411888 100644 --- a/crates/engine/src/tests_labspots.rs +++ b/crates/engine/src/tests/labspots.rs @@ -7,8 +7,8 @@ use vectorcraft_color::cms::{self, Model}; use vectorcraft_color::{Color, Paint}; use vectorcraft_render::proof; -use super::tests_colormgmt::GLOBAL; -use super::*; +use crate::tests_colormgmt::GLOBAL; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_large_edits.rs b/crates/engine/src/tests/large_edits.rs similarity index 99% rename from crates/engine/src/tests_large_edits.rs rename to crates/engine/src/tests/large_edits.rs index 0347ebdf3..97405d6f6 100644 --- a/crates/engine/src/tests_large_edits.rs +++ b/crates/engine/src/tests/large_edits.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{Appearance, Document, LAYER_COLORS, LayerColor, Node, Node use vectorcraft_geom::{FillRule, Rect, shapes}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; /// Squares in a row of [`square_at`]. const ROW: usize = 200; diff --git a/crates/engine/src/tests_layerclip.rs b/crates/engine/src/tests/layerclip.rs similarity index 98% rename from crates/engine/src/tests_layerclip.rs rename to crates/engine/src/tests/layerclip.rs index 21c694d3e..b52437a69 100644 --- a/crates/engine/src/tests_layerclip.rs +++ b/crates/engine/src/tests/layerclip.rs @@ -3,8 +3,8 @@ use serde_json::json; -use super::tests_clip::{assert_outputs_agree, hit, id_of, opaque, render, select, session}; -use super::*; +use crate::tests_clip::{assert_outputs_agree, hit, id_of, opaque, render, select, session}; +use crate::*; fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests/layers.rs similarity index 99% rename from crates/engine/src/tests_layers.rs rename to crates/engine/src/tests/layers.rs index d78645e82..3a8e6403c 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests/layers.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs similarity index 99% rename from crates/engine/src/tests_legacy_ai.rs rename to crates/engine/src/tests/legacy_ai.rs index 381ea16ae..52706abbd 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -3,9 +3,9 @@ use serde_json::{Value, json}; -use super::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; -use super::*; use crate::tests_links::Folder; +use crate::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; +use crate::*; /// Three artboards (named A, B and C) with a rectangle on each, on two layers: the first /// rectangle's group (with a second one) on `Layer 1`, the others on `Top`. diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests/library.rs similarity index 99% rename from crates/engine/src/tests_library.rs rename to crates/engine/src/tests/library.rs index c82f471d6..f85eb93a3 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests/library.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_link_paths.rs b/crates/engine/src/tests/link_paths.rs similarity index 99% rename from crates/engine/src/tests_link_paths.rs rename to crates/engine/src/tests/link_paths.rs index b851d94ac..2c96be935 100644 --- a/crates/engine/src/tests_link_paths.rs +++ b/crates/engine/src/tests/link_paths.rs @@ -5,9 +5,9 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; -use super::*; use crate::cmd::fileio::touched; use crate::tests_links::{Folder, RED, place, png, session}; +use crate::*; /// Paths to other computers (and, on Unix, a local file written as one: `//tmp/…`, which Linux /// reads as `/tmp/…`). diff --git a/crates/engine/src/tests_linked_stops.rs b/crates/engine/src/tests/linked_stops.rs similarity index 99% rename from crates/engine/src/tests_linked_stops.rs rename to crates/engine/src/tests/linked_stops.rs index 2b0cae9e7..2f25cf83c 100644 --- a/crates/engine/src/tests_linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, GradientPaint, GradientStop, Paint}; use vectorcraft_render::proof; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_linkfiles.rs b/crates/engine/src/tests/linkfiles.rs similarity index 100% rename from crates/engine/src/tests_linkfiles.rs rename to crates/engine/src/tests/linkfiles.rs diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests/links.rs similarity index 99% rename from crates/engine/src/tests_links.rs rename to crates/engine/src/tests/links.rs index 95ab45349..2ac7d5972 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests/links.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; /// A fresh folder for one test (removed when dropped). pub(crate) struct Folder(pub(crate) PathBuf); diff --git a/crates/engine/src/tests_linkspanel.rs b/crates/engine/src/tests/linkspanel.rs similarity index 99% rename from crates/engine/src/tests_linkspanel.rs rename to crates/engine/src/tests/linkspanel.rs index e00b53b8f..56c6f904e 100644 --- a/crates/engine/src/tests_linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeKind, PlacementOptions}; -use super::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write}; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_linkwatch.rs b/crates/engine/src/tests/linkwatch.rs similarity index 99% rename from crates/engine/src/tests_linkwatch.rs rename to crates/engine/src/tests/linkwatch.rs index 26b9252de..8c866b8d9 100644 --- a/crates/engine/src/tests_linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use super::*; use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session}; +use crate::*; /// Write `bytes` to `path` so its modification time moves even on coarse file systems. fn save(path: &str, bytes: &[u8]) { diff --git a/crates/engine/src/tests_liquify.rs b/crates/engine/src/tests/liquify.rs similarity index 99% rename from crates/engine/src/tests_liquify.rs rename to crates/engine/src/tests/liquify.rs index d868d61c4..d5935bd6f 100644 --- a/crates/engine/src/tests_liquify.rs +++ b/crates/engine/src/tests/liquify.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{PathData, Point}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests/live.rs similarity index 99% rename from crates/engine/src/tests_live.rs rename to crates/engine/src/tests/live.rs index 146148dc9..408838566 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests/live.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests/livecorners.rs similarity index 99% rename from crates/engine/src/tests_livecorners.rs rename to crates/engine/src/tests/livecorners.rs index a1658be2e..ff6ca5f87 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests/livecorners.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{AnchorRef, LiveCorners, LiveShape, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_geom::shapes::CornerKind; -use super::*; +use crate::*; /// A 100 × 60 pt live rectangle at (10, 10), selected. fn session() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_maskview.rs b/crates/engine/src/tests/maskview.rs similarity index 99% rename from crates/engine/src/tests_maskview.rs rename to crates/engine/src/tests/maskview.rs index 9f9c58fd9..e222a136d 100644 --- a/crates/engine/src/tests_maskview.rs +++ b/crates/engine/src/tests/maskview.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap()["id"].as_u64().unwrap()); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests/menucmds.rs similarity index 99% rename from crates/engine/src/tests_menucmds.rs rename to crates/engine/src/tests/menucmds.rs index 8b16e00da..383fe2143 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -5,8 +5,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind}; use vectorcraft_geom::{Rect, shapes}; -use super::tests_large_edits::{layer, run_soon, square, undo_gives_back}; -use super::*; +use crate::tests_large_edits::{layer, run_soon, square, undo_gives_back}; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -224,7 +224,7 @@ fn tiles_under_squares() -> (Session, Vec, Vec) { let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0); Rect::new(x, y, x + 100.0, y + 200.0) }; - let s = super::tests_large_edits::session(51_000, |d, k| { + let s = crate::tests_large_edits::session(51_000, |d, k| { if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) } }); let mut tiles = layer(&s, 0); @@ -3142,7 +3142,7 @@ fn crop_image_trim_checks_the_rgba_size_before_decoding() { // 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA // (4 bytes a pixel) they are just over or just within the 2 GB allowance. let trim = |w: u32, h: u32| { - let png = super::tests_linkspanel::png_header(w, h); + let png = crate::tests_linkspanel::png_header(w, h); let mut s = Session::new(); s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); s.execute("select.all", &json!({})).unwrap(); diff --git a/crates/engine/src/tests_nativefile.rs b/crates/engine/src/tests/nativefile.rs similarity index 99% rename from crates/engine/src/tests_nativefile.rs rename to crates/engine/src/tests/nativefile.rs index de547d6a4..4f67890b1 100644 --- a/crates/engine/src/tests_nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use serde_json::{Value, json}; use vectorcraft_format::{is_compressed, sniff}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_newart.rs b/crates/engine/src/tests/newart.rs similarity index 99% rename from crates/engine/src/tests_newart.rs rename to crates/engine/src/tests/newart.rs index cc1ced4f7..6bc5eb48b 100644 --- a/crates/engine/src/tests_newart.rs +++ b/crates/engine/src/tests/newart.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, Dash, LineCap, Node}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests/newdoc.rs similarity index 99% rename from crates/engine/src/tests_newdoc.rs rename to crates/engine/src/tests/newdoc.rs index a2f840c67..067631eec 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Background, ColorMode, Unit}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn new_doc(p: Value) -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs similarity index 99% rename from crates/engine/src/tests_objects_on_path.rs rename to crates/engine/src/tests/objects_on_path.rs index 4cd740dab..a6015b158 100644 --- a/crates/engine/src/tests_objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect}; -use super::tests_clip::{id_of, select}; -use super::*; +use crate::tests_clip::{id_of, select}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_objexpand.rs b/crates/engine/src/tests/objexpand.rs similarity index 99% rename from crates/engine/src/tests_objexpand.rs rename to crates/engine/src/tests/objexpand.rs index 48435adbb..9d0c9d272 100644 --- a/crates/engine/src/tests_objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests/opacitymask.rs similarity index 99% rename from crates/engine/src/tests_opacitymask.rs rename to crates/engine/src/tests/opacitymask.rs index 96a418405..5f69f6607 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests/opacitymask.rs @@ -6,9 +6,9 @@ use serde_json::{Value, json}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; use crate::cmd::maskedit::MASK_EDIT_LAYER; use crate::tests::{history, select_with_a_step_to_redo}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_outlineobject.rs b/crates/engine/src/tests/outlineobject.rs similarity index 99% rename from crates/engine/src/tests_outlineobject.rs rename to crates/engine/src/tests/outlineobject.rs index c1ff06b53..7a411e4aa 100644 --- a/crates/engine/src/tests_outlineobject.rs +++ b/crates/engine/src/tests/outlineobject.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::{Node, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs similarity index 99% rename from crates/engine/src/tests_outlinestroke.rs rename to crates/engine/src/tests/outlinestroke.rs index 170f31acd..17a6b5a09 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -7,7 +7,7 @@ use vectorcraft_color::BlendMode; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_overprint.rs b/crates/engine/src/tests/overprint.rs similarity index 99% rename from crates/engine/src/tests_overprint.rs rename to crates/engine/src/tests/overprint.rs index 78950a838..cade55db9 100644 --- a/crates/engine/src/tests_overprint.rs +++ b/crates/engine/src/tests/overprint.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests/package.rs similarity index 99% rename from crates/engine/src/tests_package.rs rename to crates/engine/src/tests/package.rs index e1eacfe0a..dcf3c6217 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests/package.rs @@ -6,8 +6,8 @@ use std::io::Read; use serde_json::{Value, json}; -use super::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write}; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests/paintproxy.rs similarity index 99% rename from crates/engine/src/tests_paintproxy.rs rename to crates/engine/src/tests/paintproxy.rs index 8893ff18c..9ddbc288e 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests/paintproxy.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::NodeKind; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests/panelcmds.rs similarity index 99% rename from crates/engine/src/tests_panelcmds.rs rename to crates/engine/src/tests/panelcmds.rs index 389cfc142..725bdd0b9 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -272,7 +272,7 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { /// option left out. #[test] fn artboard_commands_refuse_a_boolean_or_number_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let r = rect(&mut s); s.execute("artboard.new", &json!({})).unwrap(); diff --git a/crates/engine/src/tests_paragraphs.rs b/crates/engine/src/tests/paragraphs.rs similarity index 99% rename from crates/engine/src/tests_paragraphs.rs rename to crates/engine/src/tests/paragraphs.rs index 3e4f24907..885806cfc 100644 --- a/crates/engine/src/tests_paragraphs.rs +++ b/crates/engine/src/tests/paragraphs.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_params.rs b/crates/engine/src/tests/params.rs similarity index 100% rename from crates/engine/src/tests_params.rs rename to crates/engine/src/tests/params.rs diff --git a/crates/engine/src/tests_pathops.rs b/crates/engine/src/tests/pathops.rs similarity index 99% rename from crates/engine/src/tests_pathops.rs rename to crates/engine/src/tests/pathops.rs index f85b77a06..bb62d1459 100644 --- a/crates/engine/src/tests_pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pathtype.rs b/crates/engine/src/tests/pathtype.rs similarity index 99% rename from crates/engine/src/tests_pathtype.rs rename to crates/engine/src/tests/pathtype.rs index f48d63fd7..c80ca3236 100644 --- a/crates/engine/src/tests_pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{NodeKind, PathAlign, TextKind, TextObject}; use vectorcraft_geom::{Point, Rect, Shape}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests/pattern.rs similarity index 99% rename from crates/engine/src/tests_pattern.rs rename to crates/engine/src/tests/pattern.rs index 787c6ceb2..2b72f6602 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::tests_menucmds::place_undecodable_png; -use super::*; +use crate::tests_menucmds::place_undecodable_png; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -827,12 +827,12 @@ fn mirror_axis_drag_previews_once_and_undo_redo_preserve_source() { let axis = mirror_axis(&s); let v = ViewInfo::default(); s.select_tool("selection", v).unwrap(); - let h = super::tests::history(&s); + let h = crate::tests::history(&s); for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 20.0), (PointerKind::Drag, 30.0), (PointerKind::Up, 40.0)] { s.pointer(&PointerEvent::new(kind, axis.center.x + dx, axis.center.y), v).unwrap(); assert!(close(mirror_axis(&s).center, axis.center + vectorcraft_geom::Vec2::new(dx, 0.0))); } - assert_eq!(super::tests::history(&s).0, h.0 + 1); + assert_eq!(crate::tests::history(&s).0, h.0 + 1); let after = node(&s, id); let (NodeKind::Repeat(a), NodeKind::Repeat(b)) = (&before.kind, &after.kind) else { panic!() }; assert_eq!(a.source, b.source, "moving the mirror axis leaves the source artwork in place"); @@ -852,7 +852,7 @@ fn mirror_axis_rotation_snaps_and_escape_restores_document_and_history() { let v = ViewInfo::default(); s.select_tool("selection", v).unwrap(); let before = node(&s, id); - let h = super::tests::history(&s); + let h = crate::tests::history(&s); let axis = mirror_axis(&s); let end = axis.ends[1]; let destination = axis.center + vectorcraft_geom::Vec2::new(70.0, 60.0); @@ -865,15 +865,15 @@ fn mirror_axis_rotation_snaps_and_escape_restores_document_and_history() { assert!(!s.tool_busy()); s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y), v).unwrap(); assert_eq!(node(&s, id), before); - assert_eq!(super::tests::history(&s), h); + assert_eq!(crate::tests::history(&s), h); // A click is not an edit, and a subsequent real drag still works after cancellation. s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, end.x, end.y), v).unwrap(); - assert_eq!(super::tests::history(&s), h); + assert_eq!(crate::tests::history(&s), h); s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y).with_mods(shift), v).unwrap(); assert!((mirror_axis(&s).angle - 45.0).abs() < 1e-6); - assert_eq!(super::tests::history(&s).0, h.0 + 1); + assert_eq!(crate::tests::history(&s).0, h.0 + 1); } #[test] diff --git a/crates/engine/src/tests_pdffidelity.rs b/crates/engine/src/tests/pdffidelity.rs similarity index 99% rename from crates/engine/src/tests_pdffidelity.rs rename to crates/engine/src/tests/pdffidelity.rs index 35fe314a2..f30e3fe42 100644 --- a/crates/engine/src/tests_pdffidelity.rs +++ b/crates/engine/src/tests/pdffidelity.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{AppearanceItem, Document, Node}; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_pdfmarks.rs b/crates/engine/src/tests/pdfmarks.rs similarity index 99% rename from crates/engine/src/tests_pdfmarks.rs rename to crates/engine/src/tests/pdfmarks.rs index a9266b788..382ffd461 100644 --- a/crates/engine/src/tests_pdfmarks.rs +++ b/crates/engine/src/tests/pdfmarks.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_pdf::CropTo; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs similarity index 100% rename from crates/engine/src/tests_pdfpresets.rs rename to crates/engine/src/tests/pdfpresets.rs diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests/pdfraster.rs similarity index 99% rename from crates/engine/src/tests_pdfraster.rs rename to crates/engine/src/tests/pdfraster.rs index 896db4eef..f6e54bd0c 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests/pdfraster.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, ImageBlob, ImageO use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; fn shadow() -> Effect { Effect { id: "stylize.dropShadow".into(), params: json!({"x": 6.0, "y": 6.0, "blur": 3.0, "opacity": 75.0}), visible: true } diff --git a/crates/engine/src/tests_persp_planes.rs b/crates/engine/src/tests/persp_planes.rs similarity index 99% rename from crates/engine/src/tests_persp_planes.rs rename to crates/engine/src/tests/persp_planes.rs index 669fdf81e..b605b35f1 100644 --- a/crates/engine/src/tests_persp_planes.rs +++ b/crates/engine/src/tests/persp_planes.rs @@ -6,8 +6,8 @@ use vectorcraft_geom::{Point, Vec2}; use vectorcraft_tools::Mods; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; -use super::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len}; -use super::*; +use crate::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len}; +use crate::*; fn path(s: &Session, id: NodeId) -> vectorcraft_geom::PathData { s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() diff --git a/crates/engine/src/tests_persp_select.rs b/crates/engine/src/tests/persp_select.rs similarity index 99% rename from crates/engine/src/tests_persp_select.rs rename to crates/engine/src/tests/persp_select.rs index 3c0c3de5b..b76964791 100644 --- a/crates/engine/src/tests_persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -6,7 +6,7 @@ use vectorcraft_geom::{Point, Rect, Vec2}; use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; /// An 800 × 600 document with a two-point grid and a rectangle attached to the right plane. pub(crate) fn persp_session() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests/persp_text.rs similarity index 99% rename from crates/engine/src/tests_persp_text.rs rename to crates/engine/src/tests/persp_text.rs index 846306fc2..91d7b70eb 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests/persp_text.rs @@ -8,8 +8,8 @@ use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::Mods; use vectorcraft_tools::distort::perspective::{self as persp, Plane}; -use super::tests_persp_select::{attached, drag, grid, persp_session, undo_len}; -use super::*; +use crate::tests_persp_select::{attached, drag, grid, persp_session, undo_len}; +use crate::*; /// Point type "HELLO" (48 pt) at (430, 400), attached to the right plane. fn persp_text(s: &mut Session) -> NodeId { diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests/perspgrid.rs similarity index 99% rename from crates/engine/src/tests_perspgrid.rs rename to crates/engine/src/tests/perspgrid.rs index 69c9c9556..b2cb7cba9 100644 --- a/crates/engine/src/tests_perspgrid.rs +++ b/crates/engine/src/tests/perspgrid.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Rgb}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests/place.rs similarity index 99% rename from crates/engine/src/tests_place.rs rename to crates/engine/src/tests/place.rs index bb7ac1999..1d3558eaa 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests/place.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -250,7 +250,7 @@ fn a_file_read_apart_from_the_session_places_as_file_place_does() { p.to_string_lossy().into_owned() }; let (pdf_path, native_path) = (saved("page.pdf", &pdf), saved("art.vectorcraft", &native)); - let words = super::tests_textimport::docx(r#"Placed words"#, super::tests_textimport::STYLES); + let words = crate::tests_textimport::docx(r#"Placed words"#, crate::tests_textimport::STYLES); let cases = [ with(file("red.png", &png(30, 20, [255, 0, 0, 255], Some(144.0))), json!({"at": [150, 100]})), with(file("notes.txt", b"Two lines\nof text"), json!({"text": {}})), diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests/placed_document.rs similarity index 99% rename from crates/engine/src/tests_placed_document.rs rename to crates/engine/src/tests/placed_document.rs index dd9e47462..eb3164db8 100644 --- a/crates/engine/src/tests_placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -8,8 +8,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument}; use vectorcraft_geom::{Rect, shapes}; -use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; +use crate::*; /// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more /// rectangles, which change the file's size). @@ -75,7 +75,7 @@ fn decode(v: &Value) -> Vec { /// Images and paths in `bytes`, a PDF, read back. fn pdf_contents(bytes: &[u8]) -> (usize, usize) { // The page as drawn (not the VectorCraft document the PDF carries). - let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); + let (doc, _) = crate::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); let (mut images, mut paths) = (0, 0); doc.walk(|n| match n.kind { NodeKind::Image(_) => images += 1, diff --git a/crates/engine/src/tests_plugins.rs b/crates/engine/src/tests/plugins.rs similarity index 99% rename from crates/engine/src/tests_plugins.rs rename to crates/engine/src/tests/plugins.rs index 685e5c3f4..e2bc451d9 100644 --- a/crates/engine/src/tests_plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -7,10 +7,10 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{PathData, Point, SubPath}; use vectorcraft_plugins::wat as tpl; -use super::*; +use crate::*; /// The example Desaturate plug-in (built from `crates/plugins/example`). -const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; fn run(s: &mut Session, cmd: &str, p: Value) -> Value { diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests/prefs.rs similarity index 99% rename from crates/engine/src/tests_prefs.rs rename to crates/engine/src/tests/prefs.rs index 9d2355ad2..c1b5c73ce 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; +use crate::*; #[test] fn every_spec_matches_a_prefs_field_and_back() { diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests/previewbounds.rs similarity index 99% rename from crates/engine/src/tests_previewbounds.rs rename to crates/engine/src/tests/previewbounds.rs index 95440f2ae..998f7fd14 100644 --- a/crates/engine/src/tests_previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symb use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests/print.rs similarity index 99% rename from crates/engine/src/tests_print.rs rename to crates/engine/src/tests/print.rs index 4ba587d45..f685e76af 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests/print.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// A 300 × 200 RGB document with a rectangle; and the rectangle's id. fn session() -> (Session, u64) { diff --git a/crates/engine/src/tests_printadvanced.rs b/crates/engine/src/tests/printadvanced.rs similarity index 95% rename from crates/engine/src/tests_printadvanced.rs rename to crates/engine/src/tests/printadvanced.rs index e716d627b..99cfcd373 100644 --- a/crates/engine/src/tests_printadvanced.rs +++ b/crates/engine/src/tests/printadvanced.rs @@ -7,7 +7,7 @@ use vectorcraft_geom::Rect; use vectorcraft_pdf::PrintSettings; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; /// A 200 × 100 CMYK document: a cyan square under a yellow one whose fill overprints, and a /// half-transparent magenta one; their ids. @@ -84,11 +84,11 @@ fn a_flattener_preset_flattens_transparency_before_printing() { any }; assert!(transparent(doc(&s))); - let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved, false).unwrap(); + let none = crate::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved, false).unwrap(); assert!(none.is_none(), "transparency prints live without a preset"); for name in ["High Resolution", "low"] { let set = settings(json!({"advanced": {"flattenerPreset": name}})); - let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); + let flat = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); assert!(!transparent(&flat), "{name}: flattened"); } // A saved preset by name; an unknown one is refused by preview and print alike. @@ -105,7 +105,7 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() { s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 100})).unwrap(); let saved = s.prefs.flattener_presets.clone(); let set = settings(json!({"advanced": {"printAsBitmap": true}})); - let bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); + let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); let mut kinds = vec![]; bitmap.walk(|n| kinds.push(matches!(n.kind, NodeKind::Image(_)))); assert_eq!(kinds.iter().filter(|k| **k).count(), 1, "one image: the empty artboard gets none"); @@ -127,7 +127,7 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() { let pv = s.execute("print.preview", &seps).unwrap(); assert!(pv["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("Print as Bitmap"))); let set = settings(seps["settings"].clone()); - assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none()); + assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none()); } /// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB @@ -152,8 +152,8 @@ fn print_as_bitmap_honours_rich_black() { let img = image::load_from_memory(bytes).unwrap().to_rgba8(); img.get_pixel(img.width() / 2, img.height() / 2).0 }; - let accurate = centre(&super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap()); + let accurate = centre(&crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap()); assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); - let rich = centre(&super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, true).unwrap().unwrap()); + let rich = centre(&crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, true).unwrap().unwrap()); assert_eq!(rich, [0, 0, 0, 255], "rich: RGB black"); } diff --git a/crates/engine/src/tests_printpresets.rs b/crates/engine/src/tests/printpresets.rs similarity index 99% rename from crates/engine/src/tests_printpresets.rs rename to crates/engine/src/tests/printpresets.rs index fafa25cb5..92fe5ab0f 100644 --- a/crates/engine/src/tests_printpresets.rs +++ b/crates/engine/src/tests/printpresets.rs @@ -3,8 +3,8 @@ use serde_json::json; -use super::*; use crate::cmd::printpresets::DEFAULT_PRESET; +use crate::*; fn is_bad_params(r: Result) -> bool { matches!(r, Err(EngineError::BadParams { .. })) diff --git a/crates/engine/src/tests_printpreview.rs b/crates/engine/src/tests/printpreview.rs similarity index 99% rename from crates/engine/src/tests_printpreview.rs rename to crates/engine/src/tests/printpreview.rs index 88ebdb627..d6a4482b3 100644 --- a/crates/engine/src/tests_printpreview.rs +++ b/crates/engine/src/tests/printpreview.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// `print.preview {settings}`'s first page. fn first_sheet(s: &mut Session, settings: Value) -> Value { diff --git a/crates/engine/src/tests_printps.rs b/crates/engine/src/tests/printps.rs similarity index 99% rename from crates/engine/src/tests_printps.rs rename to crates/engine/src/tests/printps.rs index 57cfbc901..ad83ade90 100644 --- a/crates/engine/src/tests_printps.rs +++ b/crates/engine/src/tests/printps.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// A document of `artboards` 300 × 200 artboards with a rectangle on the first. fn session(artboards: usize) -> Session { diff --git a/crates/engine/src/tests_printtiling.rs b/crates/engine/src/tests/printtiling.rs similarity index 99% rename from crates/engine/src/tests_printtiling.rs rename to crates/engine/src/tests/printtiling.rs index 3df7c1298..9d8282053 100644 --- a/crates/engine/src/tests_printtiling.rs +++ b/crates/engine/src/tests/printtiling.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; use crate::tooling::ViewInfo; +use crate::*; /// A 300 × 200 document with a rectangle. fn session() -> Session { diff --git a/crates/engine/src/tests_proxyitems.rs b/crates/engine/src/tests/proxyitems.rs similarity index 99% rename from crates/engine/src/tests_proxyitems.rs rename to crates/engine/src/tests/proxyitems.rs index 4ce73602f..22c4d0bca 100644 --- a/crates/engine/src/tests_proxyitems.rs +++ b/crates/engine/src/tests/proxyitems.rs @@ -7,7 +7,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::PathData; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_puppetwarp.rs b/crates/engine/src/tests/puppetwarp.rs similarity index 99% rename from crates/engine/src/tests_puppetwarp.rs rename to crates/engine/src/tests/puppetwarp.rs index 6730b5464..54223dda7 100644 --- a/crates/engine/src/tests_puppetwarp.rs +++ b/crates/engine/src/tests/puppetwarp.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs similarity index 99% rename from crates/engine/src/tests_rasterfilters.rs rename to crates/engine/src/tests/rasterfilters.rs index 45ead1054..236580d5b 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::NodeKind; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; /// The new effects with parameters that change a striped square visibly. const EFFECTS: [(&str, &str); 3] = [ diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests/rastersettings.rs similarity index 99% rename from crates/engine/src/tests_rastersettings.rs rename to crates/engine/src/tests/rastersettings.rs index 7b0d61b32..b3f1cf50b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; -use super::*; +use crate::*; const C: &str = "document.rasterEffectsSettings"; diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests/recolor.rs similarity index 99% rename from crates/engine/src/tests_recolor.rs rename to crates/engine/src/tests/recolor.rs index af53f15ec..fb9959ded 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; -use super::*; use crate::cmd::color_value; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests/recovery.rs similarity index 99% rename from crates/engine/src/tests_recovery.rs rename to crates/engine/src/tests/recovery.rs index 2826e250c..cfd72aedf 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use serde_json::json; -use super::*; use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore}; +use crate::*; /// A session keeping its copies in `store`, with one modified document. fn session_in(store: &Arc) -> Session { diff --git a/crates/engine/src/tests_reflecthit.rs b/crates/engine/src/tests/reflecthit.rs similarity index 99% rename from crates/engine/src/tests_reflecthit.rs rename to crates/engine/src/tests/reflecthit.rs index 89d1c3272..5f12a00d4 100644 --- a/crates/engine/src/tests_reflecthit.rs +++ b/crates/engine/src/tests/reflecthit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_registration.rs b/crates/engine/src/tests/registration.rs similarity index 99% rename from crates/engine/src/tests_registration.rs rename to crates/engine/src/tests/registration.rs index 9b539f473..87d8cbc9b 100644 --- a/crates/engine/src/tests_registration.rs +++ b/crates/engine/src/tests/registration.rs @@ -9,8 +9,8 @@ use vectorcraft_geom::Affine; use vectorcraft_render::proof::ProofSetup; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; use crate::tests_colormgmt::GLOBAL; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests/save.rs similarity index 99% rename from crates/engine/src/tests_save.rs rename to crates/engine/src/tests/save.rs index 12fc74136..a24ee0f2f 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests/save.rs @@ -7,8 +7,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::SavedView; use vectorcraft_geom::Point; -use super::*; use crate::cmd::fileio::{SaveMode, save_plan}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests/saveoptions.rs similarity index 99% rename from crates/engine/src/tests_saveoptions.rs rename to crates/engine/src/tests/saveoptions.rs index ff14cf698..ff17ef34e 100644 --- a/crates/engine/src/tests_saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms; -use super::*; use crate::tests_links::{Folder, png}; +use crate::*; /// Three artboards (named A, B and C) with a rectangle on each. fn three_boards() -> Session { diff --git a/crates/engine/src/tests_scalestrokes.rs b/crates/engine/src/tests/scalestrokes.rs similarity index 99% rename from crates/engine/src/tests_scalestrokes.rs rename to crates/engine/src/tests/scalestrokes.rs index 361636001..d03a9ebf8 100644 --- a/crates/engine/src/tests_scalestrokes.rs +++ b/crates/engine/src/tests/scalestrokes.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{LiveShape, Node, NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_scripts.rs b/crates/engine/src/tests/scripts.rs similarity index 99% rename from crates/engine/src/tests_scripts.rs rename to crates/engine/src/tests/scripts.rs index 44341f2bc..5f620b251 100644 --- a/crates/engine/src/tests_scripts.rs +++ b/crates/engine/src/tests/scripts.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn check(p: Value) -> Value { Session::new().execute("script.check", &p).unwrap_or_else(|e| panic!("script.check {p}: {e}")) diff --git a/crates/engine/src/tests_select_ids_first.rs b/crates/engine/src/tests/select_ids_first.rs similarity index 99% rename from crates/engine/src/tests_select_ids_first.rs rename to crates/engine/src/tests/select_ids_first.rs index 3df8d37e6..442b1c0f3 100644 --- a/crates/engine/src/tests_select_ids_first.rs +++ b/crates/engine/src/tests/select_ids_first.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// Three rectangles and an ellipse, nothing selected → the session and their ids. fn doc() -> (Session, Vec) { diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests/shaper.rs similarity index 99% rename from crates/engine/src/tests_shaper.rs rename to crates/engine/src/tests/shaper.rs index 16c7c8b96..c0b4ad888 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::*; use serde_json::json; use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX}; use vectorcraft_doc::{LiveShape, Node}; diff --git a/crates/engine/src/tests_shortcutset.rs b/crates/engine/src/tests/shortcutset.rs similarity index 99% rename from crates/engine/src/tests_shortcutset.rs rename to crates/engine/src/tests/shortcutset.rs index 7768baa88..8f216b8c0 100644 --- a/crates/engine/src/tests_shortcutset.rs +++ b/crates/engine/src/tests/shortcutset.rs @@ -4,9 +4,9 @@ use serde_json::{Value, json}; -use super::*; use crate::cmd::importmatches; use crate::cmd::shortcutset::{KysEntry, NOT_READABLE, read_kys}; +use crate::*; fn read(text: &str) -> Value { Session::new().execute("shortcutSet.read", &json!({ "data": text, "name": "My Keys.kys" })).unwrap() diff --git a/crates/engine/src/tests_simplify_part.rs b/crates/engine/src/tests/simplify_part.rs similarity index 99% rename from crates/engine/src/tests_simplify_part.rs rename to crates/engine/src/tests/simplify_part.rs index 4f600f68a..12f333059 100644 --- a/crates/engine/src/tests_simplify_part.rs +++ b/crates/engine/src/tests/simplify_part.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::NodeKind; use vectorcraft_geom::kurbo::ParamCurveNearest; use vectorcraft_geom::{ParamCurve, PathData, SubPath}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests/slices.rs similarity index 99% rename from crates/engine/src/tests_slices.rs rename to crates/engine/src/tests/slices.rs index 227eb011a..f14e878d3 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests/slices.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::SliceKind; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_smartguides.rs b/crates/engine/src/tests/smartguides.rs similarity index 99% rename from crates/engine/src/tests_smartguides.rs rename to crates/engine/src/tests/smartguides.rs index b06b33be6..8bb6a0dfb 100644 --- a/crates/engine/src/tests_smartguides.rs +++ b/crates/engine/src/tests/smartguides.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::NodeId; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; -use super::*; use crate::tooling::ViewInfo; +use crate::*; /// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected. fn session() -> Session { diff --git a/crates/engine/src/tests_store.rs b/crates/engine/src/tests/store.rs similarity index 100% rename from crates/engine/src/tests_store.rs rename to crates/engine/src/tests/store.rs diff --git a/crates/engine/src/tests_strokegeom.rs b/crates/engine/src/tests/strokegeom.rs similarity index 99% rename from crates/engine/src/tests_strokegeom.rs rename to crates/engine/src/tests/strokegeom.rs index ee43644c4..f7500af35 100644 --- a/crates/engine/src/tests_strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::{ArrowAlign, StrokeLayer, WidthProfile}; -use super::*; +use crate::*; fn session_with_line() -> (Session, NodeId) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_strokegradient.rs b/crates/engine/src/tests/strokegradient.rs similarity index 99% rename from crates/engine/src/tests_strokegradient.rs rename to crates/engine/src/tests/strokegradient.rs index c9c0a4e1d..e147c387f 100644 --- a/crates/engine/src/tests_strokegradient.rs +++ b/crates/engine/src/tests/strokegradient.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, NodeKind, StrokeGradientMode}; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_strokereach.rs b/crates/engine/src/tests/strokereach.rs similarity index 99% rename from crates/engine/src/tests_strokereach.rs rename to crates/engine/src/tests/strokereach.rs index 9db74845c..1154073a3 100644 --- a/crates/engine/src/tests_strokereach.rs +++ b/crates/engine/src/tests/strokereach.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_strokeux.rs b/crates/engine/src/tests/strokeux.rs similarity index 99% rename from crates/engine/src/tests_strokeux.rs rename to crates/engine/src/tests/strokeux.rs index 8effa2844..82d3aae69 100644 --- a/crates/engine/src/tests_strokeux.rs +++ b/crates/engine/src/tests/strokeux.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, Unit}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests/stylelib.rs similarity index 99% rename from crates/engine/src/tests_stylelib.rs rename to crates/engine/src/tests/stylelib.rs index 072668a68..2c3a4c6fb 100644 --- a/crates/engine/src/tests_stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -8,7 +8,7 @@ use vectorcraft_color::{Paint, Swatch}; use vectorcraft_doc::{GraphicStyle, Node, NodeId, PatternDef}; use vectorcraft_geom::{Point, Rect, shapes}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_stylepanel.rs b/crates/engine/src/tests/stylepanel.rs similarity index 99% rename from crates/engine/src/tests_stylepanel.rs rename to crates/engine/src/tests/stylepanel.rs index 81a165d1e..d414dcbb6 100644 --- a/crates/engine/src/tests_stylepanel.rs +++ b/crates/engine/src/tests/stylepanel.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{GraphicStyle, NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests/styles.rs similarity index 99% rename from crates/engine/src/tests_styles.rs rename to crates/engine/src/tests/styles.rs index 2a8a379c8..679186332 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{GraphicStyle, Knockout, Node}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs similarity index 99% rename from crates/engine/src/tests_swatchcmds.rs rename to crates/engine/src/tests/swatchcmds.rs index d62543173..b0d0edf86 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -315,7 +315,7 @@ fn the_swatch_commands_refuse_a_keep_model_given_as_text() { /// undo history stay as they were. True, false, numbers and null still work. #[test] fn the_swatch_commands_refuse_a_boolean_or_number_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})); run(&mut s, "paint.setFill", json!({"color": "#3366cc"})); diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests/swatches.rs similarity index 99% rename from crates/engine/src/tests_swatches.rs rename to crates/engine/src/tests/swatches.rs index 27b949aa1..031b5026d 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests/swatches.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests/swatchlib.rs similarity index 99% rename from crates/engine/src/tests_swatchlib.rs rename to crates/engine/src/tests/swatchlib.rs index 7b17fa586..90425c9dd 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_symbolbounds.rs b/crates/engine/src/tests/symbolbounds.rs similarity index 99% rename from crates/engine/src/tests_symbolbounds.rs rename to crates/engine/src/tests/symbolbounds.rs index 12dfb03a3..5b1c3f2c5 100644 --- a/crates/engine/src/tests_symbolbounds.rs +++ b/crates/engine/src/tests/symbolbounds.rs @@ -11,7 +11,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symbol}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; -use super::*; +use crate::*; /// The symbol of the SVG import tests: a circle of radius 4 around (5, 5) with a 2 pt stroke, and /// an instance of it at x 80. diff --git a/crates/engine/src/tests_symbollib.rs b/crates/engine/src/tests/symbollib.rs similarity index 99% rename from crates/engine/src/tests_symbollib.rs rename to crates/engine/src/tests/symbollib.rs index eed7229ef..226fe830a 100644 --- a/crates/engine/src/tests_symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, SymbolRegistration}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_targeting.rs b/crates/engine/src/tests/targeting.rs similarity index 99% rename from crates/engine/src/tests_targeting.rs rename to crates/engine/src/tests/targeting.rs index 82abd73ee..74a9ca795 100644 --- a/crates/engine/src/tests_targeting.rs +++ b/crates/engine/src/tests/targeting.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::Node; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_textcombos.rs b/crates/engine/src/tests/textcombos.rs similarity index 99% rename from crates/engine/src/tests_textcombos.rs rename to crates/engine/src/tests/textcombos.rs index 5b3c89b47..c0f3ad5ff 100644 --- a/crates/engine/src/tests_textcombos.rs +++ b/crates/engine/src/tests/textcombos.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{AreaFit, Composer, Justify, NodeKind, TextKind, TextObject, VerticalAlign}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests/textedit.rs similarity index 99% rename from crates/engine/src/tests_textedit.rs rename to crates/engine/src/tests/textedit.rs index 0b8843a31..26efb53f9 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { // Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off). @@ -117,7 +117,7 @@ fn set_range_style_defaults_to_all_text_and_get_range() { /// false, numbers and null still work. #[test] fn type_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let id = text(&mut s, "abc"); let frame = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 100, "height": 50})).unwrap()["id"].clone(); diff --git a/crates/engine/src/tests_textimport.rs b/crates/engine/src/tests/textimport.rs similarity index 99% rename from crates/engine/src/tests_textimport.rs rename to crates/engine/src/tests/textimport.rs index 90d95df15..d87909bba 100644 --- a/crates/engine/src/tests_textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeKind, TextKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_tileedge.rs b/crates/engine/src/tests/tileedge.rs similarity index 99% rename from crates/engine/src/tests_tileedge.rs rename to crates/engine/src/tests/tileedge.rs index c68650522..eab7018cc 100644 --- a/crates/engine/src/tests_tileedge.rs +++ b/crates/engine/src/tests/tileedge.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::pattern::PatternDef; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_tints.rs b/crates/engine/src/tests/tints.rs similarity index 99% rename from crates/engine/src/tests_tints.rs rename to crates/engine/src/tests/tints.rs index caf99043f..25d4cb0d4 100644 --- a/crates/engine/src/tests_tints.rs +++ b/crates/engine/src/tests/tints.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_render::proof; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_toolsettings.rs b/crates/engine/src/tests/toolsettings.rs similarity index 99% rename from crates/engine/src/tests_toolsettings.rs rename to crates/engine/src/tests/toolsettings.rs index 49b930af4..7584aa9cb 100644 --- a/crates/engine/src/tests_toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -2,7 +2,7 @@ use serde_json::json; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_transformfx.rs b/crates/engine/src/tests/transformfx.rs similarity index 99% rename from crates/engine/src/tests_transformfx.rs rename to crates/engine/src/tests/transformfx.rs index 7fdda3928..29ecbffcd 100644 --- a/crates/engine/src/tests_transformfx.rs +++ b/crates/engine/src/tests/transformfx.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Node; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_transparencygrid.rs b/crates/engine/src/tests/transparencygrid.rs similarity index 98% rename from crates/engine/src/tests_transparencygrid.rs rename to crates/engine/src/tests/transparencygrid.rs index 80e104910..974fb0343 100644 --- a/crates/engine/src/tests_transparencygrid.rs +++ b/crates/engine/src/tests/transparencygrid.rs @@ -2,7 +2,7 @@ use serde_json::json; -use super::*; +use crate::*; #[test] fn transparency_grid_is_per_document_view_state() { diff --git a/crates/engine/src/tests_trap.rs b/crates/engine/src/tests/trap.rs similarity index 99% rename from crates/engine/src/tests_trap.rs rename to crates/engine/src/tests/trap.rs index 87fb92a6f..b2ae09908 100644 --- a/crates/engine/src/tests_trap.rs +++ b/crates/engine/src/tests/trap.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; const YELLOW: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 1.0, k: 0.0 }; const BLUE: Color = Color::Cmyk { c: 1.0, m: 0.6, y: 0.0, k: 0.0 }; diff --git a/crates/engine/src/tests_typearea.rs b/crates/engine/src/tests/typearea.rs similarity index 99% rename from crates/engine/src/tests_typearea.rs rename to crates/engine/src/tests/typearea.rs index cf17edc67..370a7bc2b 100644 --- a/crates/engine/src/tests_typearea.rs +++ b/crates/engine/src/tests/typearea.rs @@ -9,7 +9,7 @@ use vectorcraft_text::TextLayout; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests/typescale.rs similarity index 99% rename from crates/engine/src/tests_typescale.rs rename to crates/engine/src/tests/typescale.rs index c05326861..ad2bd1eaa 100644 --- a/crates/engine/src/tests_typescale.rs +++ b/crates/engine/src/tests/typescale.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, TextObject}; use vectorcraft_geom::Point; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_units.rs b/crates/engine/src/tests/units.rs similarity index 99% rename from crates/engine/src/tests_units.rs rename to crates/engine/src/tests/units.rs index b0df220a4..655501c69 100644 --- a/crates/engine/src/tests_units.rs +++ b/crates/engine/src/tests/units.rs @@ -6,9 +6,9 @@ use serde_json::json; use vectorcraft_doc::Unit; use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; -use super::*; use crate::cmd::prefscmds::{PREF_SPECS, PrefKind, UNITS}; use crate::units::Measure; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests/variables.rs similarity index 99% rename from crates/engine/src/tests_variables.rs rename to crates/engine/src/tests/variables.rs index cae174ce5..281d57515 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; -use super::tests_links::{BLUE, Folder, RED, bounds, image, place, png, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, bounds, image, place, png, write}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_webexport.rs b/crates/engine/src/tests/webexport.rs similarity index 99% rename from crates/engine/src/tests_webexport.rs rename to crates/engine/src/tests/webexport.rs index bbf4db2e1..761d5d42e 100644 --- a/crates/engine/src/tests_webexport.rs +++ b/crates/engine/src/tests/webexport.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// The default settings as JSON. fn defaults() -> Value { diff --git a/crates/engine/src/tests_widthpoints.rs b/crates/engine/src/tests/widthpoints.rs similarity index 99% rename from crates/engine/src/tests_widthpoints.rs rename to crates/engine/src/tests/widthpoints.rs index e9f50328b..786f3b539 100644 --- a/crates/engine/src/tests_widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; /// A horizontal line from (20, 100) to (180, 100), 10 pt black stroke. fn line_session() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_widthprofiles.rs b/crates/engine/src/tests/widthprofiles.rs similarity index 99% rename from crates/engine/src/tests_widthprofiles.rs rename to crates/engine/src/tests/widthprofiles.rs index 65af9c6fe..fe041dae4 100644 --- a/crates/engine/src/tests_widthprofiles.rs +++ b/crates/engine/src/tests/widthprofiles.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::WidthProfile; -use super::*; +use crate::*; fn session_with_line() -> (Session, NodeId) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_widthtool.rs b/crates/engine/src/tests/widthtool.rs similarity index 99% rename from crates/engine/src/tests_widthtool.rs rename to crates/engine/src/tests/widthtool.rs index 53be26b35..fda761de6 100644 --- a/crates/engine/src/tests_widthtool.rs +++ b/crates/engine/src/tests/widthtool.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; /// Two 200 pt lines made into a compound path with a 10 pt stroke → (compound, members). fn compound() -> (Session, NodeId, Vec) { diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests/xform.rs similarity index 99% rename from crates/engine/src/tests_xform.rs rename to crates/engine/src/tests/xform.rs index e97d261bd..eb503cce9 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -7,9 +7,9 @@ use vectorcraft_geom::{Point, Rect, Vec2, shapes}; use vectorcraft_tools::distort::perspective::{self as persp, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::tests_large_edits::{soon, undo_gives_back}; -use super::tests_persp_select::{bounds_of, grid, persp_session, plane_pts}; -use super::*; +use crate::tests_large_edits::{soon, undo_gives_back}; +use crate::tests_persp_select::{bounds_of, grid, persp_session, plane_pts}; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -415,7 +415,7 @@ fn alt_drag_in_perspective_of_30000_objects_finishes_within_20_s() { Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 6.0, y + 6.0)), Appearance::default_art()) }; let in_perspective = || { - let mut s = super::tests_large_edits::session(30_000, on_the_left); + let mut s = crate::tests_large_edits::session(30_000, on_the_left); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); s.execute("perspective.attach", &json!({"plane": "left"})).unwrap(); s diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import.rs similarity index 100% rename from crates/eps/src/import/mod.rs rename to crates/eps/src/import.rs diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai.rs similarity index 100% rename from crates/eps/src/import/ai/mod.rs rename to crates/eps/src/import/ai.rs diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b037ca2af..0b1000f67 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -66,47 +66,25 @@ use super::obj::{self, Obj, V}; use super::paint::{self, GradientDef, Instance, Named}; use crate::ps; +mod art; +mod containers; +mod directives; +mod document; +mod ops; +mod types; + +use art::preview_png; +pub use document::{VariableLibrary, artboards, variables}; +pub(crate) use document::{slot_frame, slot_of}; +pub use types::IMAGE_TOO_LARGE; +use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; +#[cfg(test)] +pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD}; +pub(super) use types::{blend_mode, blend_name}; + #[path = "legacy_text.rs"] mod legacy_text; -/// Deepest nesting of layers, groups and dictionaries. -const MAX_DEPTH: usize = 256; -/// Most operands on the stack. -const MAX_STACK: usize = 1 << 20; -/// Most objects read. -const MAX_NODES: usize = 4_000_000; -/// The most objects all the blends of one import may lay out to be checked (each twice) and made -/// live; past it, blends keep their drawn steps. -const BLEND_BUDGET: usize = 50_000; -/// Most points in one path. -const MAX_POINTS: usize = 4_000_000; -/// Largest image read (pixels). -const MAX_PIXELS: u64 = 1 << 25; -/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads -/// larger images (#1479). -pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; -/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). -const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; -/// Largest side of the document and its artboards (points). -const MAX_SIDE: f64 = 1e6; - -/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). -fn ruler_unit(v: u8) -> Option { - Some(match v { - 0 => Unit::Inches, - 1 => Unit::Millimeters, - 2 => Unit::Points, - 3 => Unit::Picas, - 4 => Unit::Centimeters, - 6 => Unit::Pixels, - _ => return None, - }) -} -/// Art space (y up) → a pattern's space in the document's terms (y down). -const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); -/// Most patterns read (others can't fill art). -const MAX_PATTERNS: usize = 4096; - /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] pub struct Structure { @@ -131,20 +109,6 @@ pub struct Structure { pub warnings: Vec, } -/// The name of the empty group that stands for a text object, followed by the number of its -/// story in the text document when the file says. -pub(crate) const TEXT_SLOT: &str = "\u{0}text"; - -/// Is `name` that of a text slot, and which story is it of? -pub(crate) fn slot_of(name: Option<&str>) -> Option> { - name?.strip_prefix(TEXT_SLOT).map(|rest| rest.split(':').next().and_then(|s| s.parse().ok())) -} - -/// Which of its story's frames a text slot named `name` is (0 when it doesn't say). -pub(crate) fn slot_frame(name: Option<&str>) -> usize { - name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0) -} - /// Read the structure of the editing data `data`, applying the file's `%AI24_LargeCanvasScale` only /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may /// keep the reduced one, which the PDF import reads as it is (#2031). @@ -155,307 +119,6 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result { r.finish() } -/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't -/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to -/// call them (#1068). -pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { - let found = artboards_read(data); - if !found.is_empty() { - return found; - } - // Reading can stop before the artboards (art kept in the document's data comes first): read - // their array on its own. - artboard_array(data).map_or(found, |array| { - let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); - alone.extend_from_slice(array); - alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); - artboards_read(&alone) - }) -} - -/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found -/// from the key back, counting the dictionaries its artboards open and close. -fn artboard_array(data: &[u8]) -> Option<&[u8]> { - const KEY: &[u8] = b"%_; (ArtboardArray) ,"; - let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len(); - let head = data.get(..end)?; - let mut depth = 0usize; - let mut line_end = head.len(); - loop { - let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); - if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") { - if text.starts_with(b";") || text.starts_with(b"/Def ;") { - depth += 1; - } else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") { - depth = depth.checked_sub(1)?; - if depth == 0 { - return head.get(line_start..); - } - } - } - line_end = line_start.checked_sub(1)?; - } -} - -/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and -/// those of a `/Document` still open when it stopped (its art can be what stopped it). -fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { - let mut r = Reader::new(data); - let _ = r.run(); - let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); - for o in &open { - if r.artboards.is_empty() { - r.document(o); - } - } - r.artboards -} - -/// How many variables and data sets the variable library of the editing data has: XML nodes -/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each -/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements). -/// VectorCraft doesn't read them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct VariableLibrary { - pub variables: usize, - pub data_sets: usize, -} - -impl VariableLibrary { - /// The import's note that the library wasn't read, when it has a variable or a data set. - pub fn note(self) -> Option { - let Self { variables, data_sets } = self; - // Counts after a colon need no plural, so every language can translate the note whole. - (variables + data_sets > 0).then(|| { - format!("the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them") - }) - } -} - -/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers -/// [`read`] can't read. Data without a `variableSets` element isn't read. -pub fn variables(data: &[u8]) -> VariableLibrary { - if super::find(data, b"variableSets").is_none() { - return VariableLibrary::default(); - } - let mut r = Reader::new(data); - let _ = r.run(); - // A `/Document` still open when reading stopped. - let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); - for o in open.iter().filter(|o| o.ty == "Document") { - variable_library(o, 0, &mut r.variables); - } - if r.variables != VariableLibrary::default() { - return r.variables; - } - // Reading can stop before the library: read its node on its own. - variable_sets_node(data).map_or(r.variables, |node| { - let mut alone = b"%_/Document :\n%_/Dictionary :\n%_/Array :\n".to_vec(); - alone.extend_from_slice(node); - alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n"); - let mut r = Reader::new(&alone); - let _ = r.run(); - r.variables - }) -} - -/// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line -/// that closes it: found from its name back, counting the dictionaries and arrays it holds. -fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { - const NAME: &[u8] = b"variableSets) /String (xmlnode-nodename) ,"; - let at = data.windows(NAME.len()).position(|w| w == NAME)?; - let opens = |text: &[u8]| text.starts_with(b"/") && text.windows(2).any(|w| w == b" :"); - let closes = |text: &[u8]| text.starts_with(b";") || text.starts_with(b"/Def ;"); - let mut depth = 0usize; - let mut line_end = data.get(..at)?.iter().rposition(|&b| b == b'\n' || b == b'\r')?; - let start = loop { - let line_start = data.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); - if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { - if closes(text) { - depth += 1; - } else if opens(text) { - match depth.checked_sub(1) { - Some(d) => depth = d, - None => break line_start, - } - } - } - line_end = line_start.checked_sub(1)?; - }; - let mut line_start = start; - loop { - let rest = data.get(line_start..)?; - let line_end = line_start + rest.iter().position(|&b| b == b'\n' || b == b'\r').unwrap_or(rest.len()); - if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { - if closes(text) { - depth = depth.checked_sub(1)?; - if depth == 0 { - return data.get(start..line_end); - } - } else if opens(text) { - depth += 1; - } - } - if line_end >= data.len() { - return None; - } - line_start = line_end + 1; - } -} - -#[derive(Clone, Debug)] -struct GState { - fill: Paint, - stroke: Paint, - width: f64, - cap: LineCap, - join: LineJoin, - miter: f64, - dash: Option, - rule: FillRule, - opacity: f32, - blend: BlendMode, - isolate: bool, - knockout: Knockout, - knockout_shape: bool, - overprint_fill: bool, - overprint_stroke: bool, - locked: bool, - hidden: bool, -} - -impl Default for GState { - fn default() -> Self { - let black = Paint::solid(vectorcraft_color::Color::BLACK); - Self { - fill: black.clone(), - stroke: black, - width: 1.0, - cap: LineCap::Butt, - join: LineJoin::Miter, - miter: 10.0, - dash: None, - rule: FillRule::NonZero, - opacity: 1.0, - blend: BlendMode::Normal, - isolate: false, - knockout: Knockout::Off, - knockout_shape: false, - overprint_fill: false, - overprint_stroke: false, - locked: false, - hidden: false, - } - } -} - -/// The blend modes by their number in `Xy`. -const BLENDS: [BlendMode; 16] = [ - BlendMode::Normal, - BlendMode::Multiply, - BlendMode::Screen, - BlendMode::Overlay, - BlendMode::SoftLight, - BlendMode::HardLight, - BlendMode::ColorDodge, - BlendMode::ColorBurn, - BlendMode::Darken, - BlendMode::Lighten, - BlendMode::Difference, - BlendMode::Exclusion, - BlendMode::Hue, - BlendMode::Saturation, - BlendMode::Color, - BlendMode::Luminosity, -]; - -/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`). -pub(super) fn blend_name(n: usize) -> String { - format!("{:?}", blend_mode(n)) -} - -/// Blend mode `n` of `Xy` (Normal when it is out of range). -pub(super) fn blend_mode(n: usize) -> BlendMode { - BLENDS.get(n).copied().unwrap_or(BlendMode::Normal) -} - -#[derive(Debug, Default)] -struct LayerAttrs { - name: String, - visible: bool, - preview: bool, - locked: bool, - printable: bool, - dim: Option, - color: Option, -} - -#[derive(Debug)] -enum Kind { - Layer(Box), - Group, - Compound, - Clip, - Mask, - Obj(Box), -} - -#[derive(Debug)] -struct Frame { - kind: Kind, - children: Vec, - /// Opened on a `%_` line. - hidden: bool, - /// [`Reader::after_object`] when it opened. - after: bool, - /// The state when it opened (a container's lock and visibility; a dictionary's state to go - /// back to). - gs: GState, - /// The operand stack's height when it opened. - base: usize, - /// The clipping paths' indexes among the children. - clips: Vec, - /// A compound member group explicitly declares its subpaths' winding with `D`. - compound_members: bool, -} - -/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands -/// before its first operator. -#[derive(Debug, Default)] -struct PatternHead { - taken: bool, - name: String, - /// The tile in the pattern's space (y up). - tile: Option, -} - -/// An image's operands, from `XN` and before `XI`. -#[derive(Debug, Default)] -struct Raster { - space: Option, - /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to - /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). - operands: Option>, - /// Those hex digits. - hex: Vec, -} - -/// A placed file's operands, from `` ` `` and its preview, until `~`. -#[derive(Debug, Default)] -struct Place { - /// The canvas, where the file's pixels are → art space. - matrix: Option, - /// The file's box (`llx lly urx ury`): its size is the file's, in pixels. - bounds: Option, - path: String, - /// The preview as a PNG, and its size in pixels. - preview: Option<(Vec, u32, u32)>, -} - -/// What the open result says of placed files: they show the preview the file keeps of them. -const LINKED_PREVIEWS: &str = - "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel"; - struct Reader<'a> { lex: Lexer<'a>, stack: Vec, @@ -562,71 +225,6 @@ struct Reader<'a> { type_state: legacy_text::TypeState, } -/// Operators that change nothing the reader keeps. -const IGNORED: &[&str] = &[ - "Ae", - "AE", - "Ap", - "As", - "Xd", - "Xr", - "XG", - // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. - "XC", - "Xh", - "XH", - "XF", - "Bc", - "XP", - "Np", - // Flatness (`i`): how finely curves print. - "i", - // Path resolution (`Ar`, §12): how finely curves print. - "Ar", - "TZ", - "Xs", - "Xt", - // A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path. - "XD", - "Xi", - "XI", - "`", - "LB2", - "Lc", - "Xv", - "XV", - "Xq", - "XQ", - "Xg", - "Xn", - "Bn", - "gsave", - "grestore", - "showpage", - "annotatepage", -]; - -/// Sections skipped whole, by their markers without the `AI_` prefix: begin → end. -const SKIPPED: &[(&str, &str)] = &[ - ("BeginPattern", "EndPattern"), - ("BeginBrushPattern", "EndBrushPattern"), - ("BeginSVGFilter", "EndSVGFilter"), - ("BeginSymbol", "EndSymbol"), - ("BeginPluginObject", "EndPluginObject"), - ("BeginArtStyles", "EndArtStyles"), - ("BeginArtStyleList", "EndArtStyleList"), - ("BeginSymbolList", "EndSymbolList"), - ("BeginTextDocument", "EndTextDocument"), - ("BeginEncoding", "EndEncoding"), - ("Alternate_Content", "End_Versioned_Content"), -]; - -/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor -/// shows but doesn't edit) whose PDF can't be read. -pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out"; -/// The name of the group a `/ForeignObject`'s art comes in as. -pub const NON_NATIVE_ART: &str = "Non-native art"; - /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → /// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`. fn marker(c: &str) -> &str { @@ -733,2143 +331,4 @@ impl<'a> Reader<'a> { } Ok(()) } - - fn push(&mut self, v: V) -> Result<(), String> { - if self.stack.len() >= MAX_STACK { - return Err("it has too many values in a row".into()); - } - self.stack.push(v); - Ok(()) - } - - /// The operands of the operator being run (above the innermost dictionary's), taken off. - fn operands(&mut self) -> Vec { - let base = self.frames.last().map_or(0, |f| f.base).min(self.stack.len()); - self.stack.split_off(base) - } - - fn nums(vals: &[V]) -> Vec { - vals.iter().filter_map(V::num).collect() - } - - // ---- comments -------------------------------------------------------------------------- - - fn comment(&mut self, c: &[u8]) -> Result<(), String> { - let line = String::from_utf8_lossy(c); - let line = line.trim_end(); - if let Some((len, hex)) = self.blend_record.as_mut() { - if !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { - // As many digits as the record says it has, no more. - let wanted = len.saturating_mul(2); - let take = wanted.saturating_sub(hex.len()).min(line.len()); - hex.extend_from_slice(line.as_bytes().get(..take).unwrap_or_default()); - if hex.len() >= wanted { - self.end_blend(); - } - return Ok(()); - } - self.end_blend(); - } - if let Some(r) = self.raster.as_mut() - && r.operands.is_some() - && !line.is_empty() - && line.bytes().all(|b| b.is_ascii_hexdigit()) - { - if r.hex.len() < MAX_HEX { - r.hex.extend_from_slice(line.as_bytes()); - } - return Ok(()); - } - let m = marker(line); - // A pattern's definition (one inside another's isn't read). - if m == "BeginPattern" && self.pattern.is_none() { - let section = self.section("EndPattern")?; - self.define_pattern(section); - return Ok(()); - } - if m == "BeginArtStyles" { - let section = self.section("EndArtStyles")?; - self.styles = super::style::art_styles(section); - return Ok(()); - } - if m == "BeginPalette" { - let section = self.section("EndPalette")?; - self.palette = super::palette::palette(section); - return Ok(()); - } - if m == "BeginEncoding" { - self.type_state.encoding_comment(line); - } - if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { - return self.skip_to(end); - } - match m { - "%BeginProlog" => return self.skip_to("%EndProlog"), - "%BeginResource" => return self.skip_to("%EndResource"), - // The art ends with the page. - "%PageTrailer" | "%Trailer" => { - self.done = true; - return Ok(()); - } - _ => {} - } - if line.starts_with("AI5_BeginLayer") { - return self.open(Kind::Layer(Box::default()), false); - } - if line.starts_with("AI5_BeginRaster") { - self.raster = Some(Raster::default()); - } else if line.starts_with("AI5_EndRaster") { - if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { - return self.image_from(vals, &hex); - } - } else if line.starts_with("AI5_BeginPlace") { - self.place = Some(Place::default()); - } else if line.starts_with("AI5_EndPlace") { - // One that `~` didn't end isn't read. - if self.place.take().is_some() { - self.unreadable("placed files"); - } - } else if self.frames.is_empty() - && let Some(v) = line.strip_prefix("%HiResBoundingBox:") - { - self.hires = four(v).or(self.hires); - } else if self.frames.is_empty() - && let Some(v) = line.strip_prefix("%BoundingBox:") - { - self.bbox = four(v).or(self.bbox); - } else if let Some(v) = line.strip_prefix("AI3_Cropmarks:") { - let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).collect(); - if let [x0, y0, x1, y1] = n.as_slice() { - let r = Rect::new(*x0, *y0, *x1, *y1); - if sane(r) { - self.cropmarks = Some(r); - } - } - } else if self.frames.is_empty() - && let Some(v) = line.strip_prefix("AI5_RulerUnits:") - && let Ok(n) = v.trim().parse::() - { - self.ruler_units = Some(n); - } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { - self.art_size = two(v); - } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { - self.template_center = two(v); - self.template = four(v).or(self.template); - } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { - // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N - // being this; the printed page applies the same factor, so the art reaches the page's - // size once it is applied to the art space ([`Self::ensure_space`]). A value outside - // the range Illustrator writes is left alone. - if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) - && (1.0..=100.0).contains(&n) - { - *scale = n; - } - } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { - self.color_model = Some(v.trim() == "2"); - } - Ok(()) - } - - /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and - /// versioned content in an alternate). - fn skip_to(&mut self, end: &str) -> Result<(), String> { - self.section(end).map(|_| ()) - } - - /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. - fn section(&mut self, end: &str) -> Result<&'a [u8], String> { - let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); - let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; - let start = self.lex.offset(); - let mut depth = 0usize; - loop { - let before = self.lex.offset(); - let Some(t) = self.lex.next_token() else { break }; - if let Tok::Comment(c) = t.tok { - let line = String::from_utf8_lossy(c); - let m = marker(&line); - if !begin.is_empty() && m == begin { - depth += 1; - } else if m == end { - if depth == 0 { - return Ok(self.lex.slice(start, before)); - } - depth -= 1; - } - } - } - Err(format!("its section ending with `%{end}` doesn't end")) - } - - /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its - /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't - /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art - /// filled with it can't be read. - fn define_pattern(&mut self, section: &[u8]) { - if self.pattern_tiles.len() >= MAX_PATTERNS { - return; - } - let mut sub = Reader::new(section); - sub.pattern = Some(PatternHead::default()); - // The gradients defined so far, lent (and those it defines kept). - sub.gradients = std::mem::take(&mut self.gradients); - sub.nodes = self.nodes; - sub.template = self.template; - let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); - self.nodes = sub.nodes; - self.gradients = std::mem::take(&mut sub.gradients); - let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; - let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; - let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { - return; - }; - let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); - let mut def = PatternDef::new(&head.name, art); - def.tile = FLIP.transform_rect_bbox(tile); - if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { - self.doc.patterns.retain(|p| p.name != def.name); - } - self.doc.patterns.push(def); - for (key, blob) in sub.doc.images { - self.doc.images.entry(key).or_insert(blob); - } - self.spots.extend(sub.spots); - self.stop_spots.extend(sub.stop_spots); - self.cmyk_colors += sub.cmyk_colors; - self.rgb_colors += sub.rgb_colors; - for w in &sub.warnings { - self.warn(w); - } - } - - /// In a pattern's definition, the operands of its first operator start with its head, - /// `(name) llx lly urx ury`: taken off them. - fn take_head(&mut self, mut vals: Vec) -> Vec { - let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; - head.taken = true; - let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; - let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); - let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; - head.name = name; - head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); - vals.split_off(at + 5) - } - - // ---- frames ---------------------------------------------------------------------------- - - fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { - if self.frames.len() >= MAX_DEPTH { - return Err("its objects are nested too deeply".into()); - } - if matches!(kind, Kind::Layer(_)) { - self.ensure_space(); - } - let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; - self.frames.push(Frame { - kind, - children: Vec::new(), - hidden, - after: self.after_object, - gs: self.gs.clone(), - base, - clips: Vec::new(), - compound_members: false, - }); - self.after_object = false; - Ok(()) - } - - /// Close the innermost frame, which must be of the kind `is` accepts. - fn close(&mut self, what: &str, is: impl Fn(&Kind) -> bool) -> Result { - match self.frames.last() { - Some(f) if is(&f.kind) => self.frames.pop().ok_or_else(|| format!("`{what}` closes nothing")), - _ => Err(format!("`{what}` closes something it didn't open")), - } - } - - /// Add a node to the innermost container. - fn add(&mut self, node: Node, hidden: bool) -> Result<(), String> { - self.nodes += 1; - if self.nodes > MAX_NODES { - return Err(format!("it has more than {MAX_NODES} objects")); - } - if hidden { - self.hidden_ids.insert(node.id); - } - match self.frames.last_mut() { - Some(f) => f.children.push(node), - None => return Err("it has art outside its layers".into()), - } - self.after_object = true; - Ok(()) - } - - /// The last object read, while nothing else started. - fn last_object(&mut self) -> Option<&mut Node> { - if !self.after_object { - return None; - } - self.frames.last_mut()?.children.last_mut() - } - - fn new_node(&mut self, kind: NodeKind, gs: &GState) -> Node { - let mut n = Node::new(self.doc.alloc_id(), kind); - n.visible = !gs.hidden; - n.locked = gs.locked; - n - } - - fn end_container(&mut self, what: &str, hidden: bool) -> Result<(), String> { - let f = match what { - "U" => self.close(what, |k| matches!(k, Kind::Group))?, - "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, - _ => self.close(what, |k| matches!(k, Kind::Clip))?, - }; - let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; - // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save - // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. - if matches!(kind, Kind::Group) - && !clips.is_empty() - && clips.len() == kids.len() - && matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. })) - { - for kid in kids { - if let Some(parent) = self.frames.last_mut() { - parent.clips.push(parent.children.len()); - } - self.add(kid, opened_hidden || hidden)?; - } - return Ok(()); - } - // A compound path of clipping paths clips its clipping group. - let clipping = matches!(kind, Kind::Compound) && !clips.is_empty(); - self.group_ended = !matches!(kind, Kind::Compound); - let mut painted = None; - let node = match kind { - // A compound path holding a group (its drawn look) is that look: its paths paint apart. - // Some files mark every group so, the plain one holding the subpaths too (#980): one - // paint for all its paths keeps it one compound path, its holes cut. - Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id) && paints_apart(k)) => { - let (children, clip) = self.clip_first(kids, &clips); - self.new_node(NodeKind::Group { children, clip }, &gs) - } - Kind::Compound => { - // A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths' - // painted copies make a compound of their own, under what it clips. - let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); - painted = self.compound(fills, &gs); - let Some(n) = self.compound(kids, &gs) else { return Ok(()) }; - n - } - // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. - _ => { - let (children, clip) = self.clip_first(kids, &clips); - self.new_node(NodeKind::Group { children, clip }, &gs) - } - }; - if clipping && let Some(f) = self.frames.last_mut() { - f.clips.push(f.children.len()); - } - if compound_members { - self.compound_groups.insert(node.id); - } - self.add(node, opened_hidden || hidden)?; - if let Some(p) = painted { - self.clip_fills.insert(p.id); - self.add(p, opened_hidden || hidden)?; - } - Ok(()) - } - - /// The compound path of `kids`, painted and made transparent as its subpaths say. - fn compound(&mut self, mut kids: Vec, gs: &GState) -> Option { - // The compound path's paint is its last path's (a gradient's instance wraps only - // that one); the last painted one, should the last have none. - let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?; - let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown)); - for k in &mut kids { - k.opacity = 1.0; - k.blend = BlendMode::Normal; - k.visible = true; - k.locked = false; - } - let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs); - n.appearance = appearance; - n.opacity = opacity; - n.blend = blend; - Some(n) - } - - /// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound - /// path → the children and whether they clip. - fn clip_first(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { - let clips: std::collections::HashSet = clips.iter().copied().collect(); - let (mut clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); - let clip_node = match clip.len() { - 0 => None, - 1 => clip.pop().map(|(_, n)| n), - _ => { - let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n)); - let children = clip.into_iter().map(|(_, n)| Arc::new(n)).collect(); - Some(Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })) - } - }; - let clips = clip_node.is_some(); - // A clipping path's own fill paints under the art it clips. - let (fills, rest): (Vec, Vec) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id)); - let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect(); - (children, clips) - } - - fn end_layer(&mut self) -> Result<(), String> { - let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; - let Kind::Layer(a) = f.kind else { return Ok(()) }; - let (children, clip) = self.clip_first(f.children, &f.clips); - let color = a.color.unwrap_or(LayerColor::Preset(0)); - let mut layer = Node::new( - self.doc.alloc_id(), - NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, - ); - layer.name = Some(a.name.clone()); - layer.visible = a.visible; - layer.locked = a.locked; - // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { - (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); - } - match self.frames.last_mut() { - Some(parent) => { - parent.children.push(layer); - self.after_object = true; - } - None => { - self.doc.layers.push(Arc::new(layer)); - self.after_object = false; - } - } - Ok(()) - } - - // ---- operators ------------------------------------------------------------------------- - - fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { - if self.blend_record.is_some() { - self.end_blend(); - } - match w { - // The writer's placeholder for a number the object doesn't have (a radial gradient's - // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. - "--" => return self.push(V::Num(f64::NAN)), - "[" => { - self.marks.push(self.stack.len()); - return self.push(V::Mark); - } - "]" => { - // The innermost `[` still on the stack (the stack is cut back by operators). - let mut at = self.stack.len(); - while let Some(m) = self.marks.pop() { - if matches!(self.stack.get(m), Some(V::Mark)) { - at = m; - break; - } - } - let items: Vec = self.stack.split_off(at).into_iter().skip(1).collect(); - return self.push(V::Arr(items)); - } - ":" => return self.begin_obj(hidden), - "," => { - if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { - obj::add_entry(o, &mut self.stack, *base); - // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's - // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s - // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line - // of it may start with `%_` and read as hidden tokens: a `(` there would open - // a string that swallows the rest of the layer. - let key = o.entries.last().and_then(|(k, _)| k.as_deref()); - if o.ty == "Binary" && key == Some("ASCII85Decode") { - self.lex.skip_past(b"~>"); - } else if o.ty == "ForeignObject" && key == Some("Data") { - self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); - } - } - return Ok(()); - } - ";" => return self.end_obj(), - "X=" => { - if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { - // `X=` also introduces legacy text frame art, read by `text_frames`. - return Ok(()); - } - self.end_path_quietly(); - return self.open(Kind::Mask, hidden); - } - "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), - "X+" => return Ok(()), - _ => {} - } - // What follows an object (its name, transparency and style) ends with anything else. The - // state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the `LB`. - if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { - self.after_object = false; - self.layer_state = None; - } - let group_ended = std::mem::take(&mut self.group_ended); - let vals = self.operands(); - let vals = self.take_head(vals); - // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so - // the operator is one the reader doesn't know, as before. - if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { - self.note_unknown("--"); - return Ok(()); - } - if let Some(d) = self.gradient_def.as_mut() { - match w { - "Bs" => d.stop(&vals, &mut self.stop_spots), - "BD" => { - if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { - self.gradients.insert(name, g); - } - } - _ => {} - } - return Ok(()); - } - if w == "X!" || self.mesh.is_some() { - let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None }); - match (w, name) { - ("X!", Some("Mesh")) => { - self.end_path_quietly(); - self.mesh = Some((super::mesh::MeshReader::default(), hidden)); - return Ok(()); - } - ("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(), - ("X#", _) => { - let to_doc = self.space(); - if let Some((m, _)) = self.mesh.as_mut() { - m.entry(&vals, to_doc); - } - return Ok(()); - } - _ if self.mesh.is_some() => return Ok(()), - _ => {} - } - } - match w { - // Containers. - "u" => { - self.end_path_quietly(); - self.open(Kind::Group, hidden)? - } - "*u" => { - self.end_path_quietly(); - self.open(Kind::Compound, hidden)? - } - "q" => { - self.end_path_quietly(); - self.open(Kind::Clip, hidden)? - } - "U" | "*U" | "Q" => self.end_container(w, hidden)?, - "Lb" => self.layer_attrs(&vals), - "Ln" => { - if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { - a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); - } - } - "LB" => self.end_layer()?, - // Paths. - "m" | "l" | "L" | "c" | "C" | "v" | "V" | "y" | "Y" => self.path_op(w, &vals)?, - "h" => close(&mut self.path), - // Ends a path that stays open. - "H" => {} - "W" => self.clip_next = true, - // A text object's frame or path. - "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(), - "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?, - "*" => { - let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into()); - self.paint(&op, hidden, true)?; - } - // Colours. - "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" | "Xs" | "XS" | "Xz" | "XZ" => self.color(w, &vals), - "p" | "P" => self.pattern_paint(w, &vals), - // A pattern's definition ends. - "E" if self.pattern.is_some() => self.done = true, - "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), - "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), - "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, - "w" => { - if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) { - self.gs.width = *v; - } - } - "J" => { - self.gs.cap = match Self::nums(&vals).last().map(|v| *v as i64) { - Some(1) => LineCap::Round, - Some(2) => LineCap::Square, - _ => LineCap::Butt, - } - } - "j" => { - self.gs.join = match Self::nums(&vals).last().map(|v| *v as i64) { - Some(1) => LineJoin::Round, - Some(2) => LineJoin::Bevel, - _ => LineJoin::Miter, - } - } - "M" => { - if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 1.0) { - self.gs.miter = v.min(500.0); - } - } - "d" => self.dash(&vals), - "D" => { - // The published Adobe Illustrator File Format Specification (version 3.0), §6.8, - // permits `u` groups of compound members. - // Their explicit winding declarations describe subpaths, not separate paints; - // modern streams can also end these groups with `9 () XW`. - let n = self.frames.len(); - if n >= 2 - && self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound)) - && let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group)) - { - f.compound_members = true; - } - } - // Transparency and state. - "Xy" => self.transparency(&vals), - // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as - // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. - "Xd" if group_ended => { - let gs = self.gs.clone(); - if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) { - (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); - } - } - "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), - "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => self.style_marker(&vals, hidden), - // Gradients. - "Bd" => self.gradient_def = GradientDef::begin(&vals), - "Bs" | "BD" => {} - "Bb" => self.instance = Some(Instance::begin(&vals)), - "Bg" => { - if let Some(i) = self.instance.as_mut() { - i.bg(&vals); - } - } - "Bm" => { - if let Some(i) = self.instance.as_mut() { - i.bm(&vals); - } - } - "Xm" => { - if let Some(i) = self.instance.as_mut() { - i.xm(&vals); - } - } - "Bh" => { - if let Some(i) = self.instance.as_mut() { - i.bh(&vals); - } - } - "BB" => { - // An instance that closes with no path to paint is an object's own note of its - // gradient, written after it: it paints nothing (not the next path either). - if let Some(i) = self.instance.take() - && !self.path.elements().is_empty() - { - self.set_instance(&i); - } - // `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3). - if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) { - self.stroke_last(); - } - } - // Images. - "XN" => { - if let Some(r) = self.raster.as_mut() { - r.space = vals.iter().rev().find_map(V::text); - } - } - // Placed files. - "`" if self.place.is_some() => { - let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }); - let path = vals.iter().rev().find_map(V::text).unwrap_or_default(); - if let Some(p) = self.place.as_mut() { - p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new); - p.bounds = - Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1)); - p.path = path; - } - } - "~" if self.place.is_some() => self.end_place(hidden)?, - // Type in the legacy format. - "To" => self.begin_text(&vals, hidden), - "Tp" => self.begin_text_path(&vals), - "TP" => self.end_text_path(), - "TO" => self.end_text(hidden)?, - "Tx" | "Tj" | "TX" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), - // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, - // Far-Eastern type) and final-form positions (§15.3). - "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" - | "Tu" | "Xu" => {} - // Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th` - // hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe` - // tab descriptions between them). - "Th" | "XL" | "Xb" | "XB" | "Xe" => {} - "TE" => self.te(&vals), - // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). - "XI" if self.raster.is_some() => { - if let Some(r) = self.raster.as_mut() { - r.operands = Some(vals); - } - } - // A plugin group ends; a blend's record follows on `%` lines. - "XP" => { - let name = vals.iter().find_map(V::text).unwrap_or_default(); - let len = Self::nums(&vals).last().copied().unwrap_or(0.0); - if name == super::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= super::blend::MAX_RECORD as f64 { - self.blend_record = Some((len as usize, vec![])); - } - } - _ if IGNORED.contains(&w) => {} - // Before the art, the setup runs procedures of the printing format. - _ if self.frames.is_empty() => {} - _ => self.note_unknown(w), - } - Ok(()) - } - - /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file - /// is read from its PDF part. - fn note_unknown(&mut self, w: &str) { - let op: String = w.chars().take(24).collect(); - if !self.shown() { - if self.hidden_unread.len() < 16 { - self.hidden_unread.insert(format!("`{op}`")); - } - } else if self.unknown.len() < 16 { - self.unknown.insert(op); - } - } - - /// `:` after `/Type`: a dictionary opens. - fn begin_obj(&mut self, hidden: bool) -> Result<(), String> { - let ty = match self.stack.pop() { - Some(V::Name(n)) => n, - Some(other) => { - self.stack.push(other); - String::new() - } - None => String::new(), - }; - self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?; - // A dictionary's state starts afresh (styles set theirs inside). - self.gs = GState::default(); - Ok(()) - } - - /// Capture mask art in document coordinates, without adding it to the owner's siblings. - fn end_mask(&mut self) -> Result<(), String> { - self.end_path_quietly(); - let f = self.close("X+", |k| matches!(k, Kind::Mask))?; - let (children, clip) = self.clip_first(f.children, &f.clips); - let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); - self.gs = f.gs; - self.after_object = f.after; - let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { - return Err("mask art ends outside a mask dictionary".into()); - }; - if o.art.replace(Arc::new(art)).is_some() { - return Err("a mask has more than one art block".into()); - } - Ok(()) - } - - /// `;`: the innermost dictionary closes. - fn end_obj(&mut self) -> Result<(), String> { - if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { - return Ok(()); - } - let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, .. } = f; - let Kind::Obj(mut o) = kind else { return Ok(()) }; - obj::close(&mut o, &mut self.stack, base); - self.obj_hidden = hidden; - // Art made inside a dictionary (type's paths) isn't kept. - self.gs = gs; - self.after_object = after; - if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { - return self.push(V::Obj(Rc::new(*o))); - } - match o.ty.as_str() { - "ArtDictionary" => { - let envelope = o.get("AIDeformOptionsKey").is_some(); - self.art_dictionary(&o); - if envelope { - let mut slots = vec![]; - for child in &children { - envelope_text_slots(child, &mut slots); - } - for mut slot in slots { - // The editing data keeps the source text inside the envelope's options; - // its visible result is the outlined art the PDF page carries. - slot.visible = false; - self.add(slot, false)?; - } - } - } - "Document" => self.document(&o), - "AI11Text" => self.text_slot(&o)?, - "SymbolInstance" => self.unreadable("symbols"), - "ForeignObject" => self.foreign_art(&o)?, - _ => {} - } - Ok(()) - } - - /// A text object (`/AI11Text`): an empty group standing for it, named after its story. - fn text_slot(&mut self, o: &Obj) -> Result<(), String> { - if self.pattern.is_some() { - self.unreadable("type in a pattern"); - } - // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its - // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its - // stories on its own; the object itself follows the look, on `%_` lines, and is the type. - if o.nums("FreeUndo").first() == Some(&1.0) { - return Ok(()); - } - let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); - let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); - let gs = self.gs.clone(); - let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs); - n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}"))); - // The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own. - n.opacity = gs.opacity; - n.blend = gs.blend; - let hidden = self.obj_hidden; - self.add(n, hidden) - } - - /// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but - /// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler), - /// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform` - /// maps that space onto the art (both read off files: the box lands on the clipping paths - /// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one - /// group. Without data, or with a PDF that can't be read, nothing is drawn. - fn foreign_art(&mut self, o: &Obj) -> Result<(), String> { - let Some(text) = self.foreign_data.take() else { return Ok(()) }; - // One `%` starts each line; the rest, `_` included, is data. - let mut encoded = String::with_capacity(text.len()); - for line in text.split(|b| matches!(b, b'\r' | b'\n')) { - encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line))); - } - let Some(pdf) = ps::ascii85_decode(&encoded) else { - self.warn(NON_NATIVE_ART_UNREAD); - return Ok(()); - }; - let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums)); - let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else { - self.warn(NON_NATIVE_ART_UNREAD); - return Ok(()); - }; - let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]); - let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1)); - if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 { - self.warn(NON_NATIVE_ART_UNREAD); - return Ok(()); - } - let target = self.space().transform_rect_bbox(art_box); - let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; - let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { - Ok(r) => r, - Err(e) => { - self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})")); - return Ok(()); - } - }; - let mut art: Vec = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect(); - let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) }; - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { - return Ok(()); - } - // The art's own box onto the object's box (its measure of the same art). The PDF importer - // hands the art back as the page shows it, and it keeps that orientation unless its - // placement mirrors it (a negative determinant): the usual `/RTransform` `1 0 0 -1 …` with - // the origin's flip doesn't, and the art then shows as the file's own PDF part draws it. - let fit = if place.determinant() < 0.0 { - Affine::translate((target.x0, target.y1)) * Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height()) - } else { - Affine::translate((target.x0, target.y0)) * Affine::scale_non_uniform(target.width() / bbox.width(), target.height() / bbox.height()) - } * Affine::translate((-bbox.x0, -bbox.y0)); - for n in &mut art { - n.transform(fit, true); - // The PDF's swatches stay with it: its colours keep their values, unlinked. - self.reid(n); - } - for (key, blob) in read.document.images { - self.doc.images.entry(key).or_insert(blob); - } - let gs = self.gs.clone(); - let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs); - group.name = Some(NON_NATIVE_ART.into()); - self.add(group, self.obj_hidden) - } - - /// Give `n` and its descendants ids of this document, and unlink their swatches. - fn reid(&mut self, n: &mut Node) { - n.id = self.doc.alloc_id(); - for it in &mut n.appearance.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p { - g.swatch = None; - } - p.map_links(&mut |_, link, _| { - *link = None; - true - }); - } - if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { - self.reid(Arc::make_mut(&mut m.art)); - } - if let Some(children) = n.children_mut() { - for c in children { - self.reid(Arc::make_mut(c)); - } - } - } - - /// Note something the data has that isn't read: an error on a layer that shows, left out of a - /// layer that doesn't. - fn unreadable(&mut self, what: &str) { - if self.shown() { - self.unsupported.insert(what.into()); - } else { - self.hidden_unread.insert(what.into()); - } - } - - /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding - /// only gradient meshes. Whether it was dropped. - fn drop_envelope_mesh(&mut self) -> bool { - fn only_meshes(n: &Node) -> bool { - match &n.kind { - NodeKind::Mesh(_) => true, - NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), - _ => false, - } - } - let Some(n) = self.last_object() else { return false }; - let id = n.id; - if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { - return false; - } - if let Some(f) = self.frames.last_mut() { - f.children.pop(); - } - self.hidden_ids.remove(&id); - self.after_object = false; - true - } - - /// Do the layers being read show? - fn shown(&self) -> bool { - self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) - } - - /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` - /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, - /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). - fn end_blend(&mut self) { - let Some((_, hex)) = self.blend_record.take() else { return }; - let ids = super::blend::hex_bytes(&hex).as_deref().and_then(super::blend::record_ids); - let roles = &self.blend_roles; - let Some(f) = self.frames.last_mut() else { return }; - let n = f.children.len(); - let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return }; - let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id))); - let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return }; - if !part_of_blend(edit) { - return; - } - if !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { - // The steps aren't a group of drawn steps: the blend's own art goes all the same, so - // its keys and spine aren't drawn over what the file draws. - f.children.remove(at); - self.warn(super::blend::DRAWN); - return; - } - let mut budget = self.blend_budget; - let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles, &mut budget)); - self.blend_budget = budget; - let Some(mut steps) = f.children.pop() else { return }; - let Some(_edit) = f.children.pop() else { return }; - match live { - Some(kind) => { - // The steps the blend lays out count as the objects they draw, as its drawn steps - // did (they were counted as read). - let mut blend = Node::new(self.doc.alloc_id(), kind); - (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); - (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); - f.children.push(blend); - } - None => { - f.children.push(steps); - self.warn(super::blend::DRAWN); - } - } - } - - /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the - /// opacity mask it may carry. - fn art_dictionary(&mut self, o: &Obj) { - // A blend's key object or spine. - if let Some(kind) = o.text("ShapeBlendPathType") - && let Some(id) = self.last_object().map(|n| n.id) - && self.blend_roles.len() < 100_000 - { - self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); - } - // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as - // a group holding the mesh that shapes it; the distorted art the file's PDF part draws - // follows on plain lines. That mesh isn't art, so it goes (#2405). - if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { - return; - } - if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { - if let Some(art) = &mask.art { - let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); - let value = - OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; - if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { - n.extras_mut().mask = Some(Box::new(value)); - } else if !on("Disabled") { - self.unreadable("opacity masks without an owning object"); - } - } else if mask.nums("Disabled").first().copied() != Some(1.0) { - self.unreadable("opacity masks without readable art"); - } - } - let name = match o.text("AIArtName") { - Some(name) => name, - None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { - Some(id) => obj::xml_name(&id), - None => return, - }, - }; - let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }) && !name.is_empty()) else { return }; - if slot_of(n.name.as_deref()).is_some() { - let id = n.id; - self.slot_names.insert(id, name); - } else { - n.name = Some(name); - } - } - - /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable - /// library has. - fn document(&mut self, o: &Obj) { - variable_library(o, 0, &mut self.variables); - let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; - for (i, ab) in list.items().enumerate().take(1000) { - let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); - let ([x0, y0, ..], [x1, y1, ..]) = (p1.as_slice(), p2.as_slice()) else { continue }; - let r = Rect::new(*x0, *y0, *x1, *y1).abs(); - if !sane(r) || r.area() <= 0.0 { - continue; - } - let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); - self.artboards.push((name, r)); - } - } - - /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place - /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). - fn layer_attrs(&mut self, vals: &[V]) { - let n = Self::nums(vals); - let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; - let flag = |i: usize, default: bool| n.get(i).map_or(default, |v| *v != 0.0); - a.visible = flag(0, true); - a.preview = flag(1, true); - a.locked = !flag(2, true); - a.printable = flag(3, true); - let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); - a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let at = if n.len() <= 10 { 6 } else { 7 }; - let index = n.get(at).copied().unwrap_or(0.0); - let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); - a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { - LayerColor::Preset(index as u8) - } else { - LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) - }); - } - - /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): - /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't - /// read can't be drawn. - fn pattern_paint(&mut self, w: &str, vals: &[V]) { - // In the setup it paints nothing (and the art's space isn't fixed yet). - if self.frames.is_empty() { - return; - } - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); - let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { - self.unreadable("pattern fills"); - return; - }; - let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); - let Some(xf) = pattern_xf(self.space(), matrix, tile) else { - self.unreadable("pattern fills"); - return; - }; - let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; - if w == "p" { - self.gs.fill = p; - } else { - self.gs.stroke = p; - } - } - - fn color(&mut self, w: &str, vals: &[V]) { - let Some((p, named)) = paint::color_op(w, vals) else { return }; - // Registration (`Xs`, `Xz`) says nothing of the document's colour model. - let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); - match p.color().filter(|_| !registration) { - Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, - Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, - _ => {} - } - if let Some(n) = named { - self.spots.push(n); - } - // A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no - // path) is the next object's paint, not the gradient. - if w.ends_with(|c: char| c.is_ascii_lowercase()) { - self.gs.fill = p; - self.pending_fill = None; - } else { - self.gs.stroke = p; - self.pending_stroke = None; - } - } - - /// `[dashes] phase d`. - fn dash(&mut self, vals: &[V]) { - let pattern: Vec = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }).unwrap_or_default(); - let offset = Self::nums(vals).last().copied().unwrap_or(0.0); - self.gs.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash { - pattern, - offset: if offset.is_finite() { offset } else { 0.0 }, - align_corners: false, - }); - } - - /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. - fn transparency(&mut self, vals: &[V]) { - let n = Self::nums(vals); - let [mode, opacity, rest @ ..] = n.as_slice() else { return }; - let gs = &mut self.gs; - gs.blend = BLENDS.get(*mode as usize).copied().filter(|_| *mode >= 0.0).unwrap_or(BlendMode::Normal); - gs.opacity = if opacity.is_finite() { opacity.clamp(0.0, 1.0) as f32 } else { 1.0 }; - gs.isolate = rest.first().is_some_and(|v| *v != 0.0); - gs.knockout = match rest.get(1).map(|v| *v as i64) { - Some(1) => Knockout::On, - Some(2) => Knockout::Neutral, - _ => Knockout::Off, - }; - gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); - let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { - if let Some(n) = self.last_object() { - n.opacity = gs.opacity; - n.blend = gs.blend; - n.isolate = gs.isolate; - n.knockout = gs.knockout; - } - self.layer_state = None; - } else { - // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until - // an operator that isn't part of the state block (or the `LB` that takes it) says the - // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = - matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); - } - } - - /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its - /// drawn look before it (all on `%_` lines inside another such object). Other markers end what - /// follows an object. - fn style_marker(&mut self, vals: &[V], hidden: bool) { - let code = Self::nums(vals).last().copied().unwrap_or(0.0); - let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); - let after = std::mem::take(&mut self.after_object); - if code == 9.0 - && let Some(look) = self - .frames - .last() - .and_then(|f| f.children.last()) - .filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id)) - { - self.looks.insert(look.id); - } - if code != 1.0 || style.is_empty() || !after { - return; - } - let space = self.space(); - let Some(f) = self.frames.last_mut() else { return }; - let n = f.children.len(); - let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { - return; - }; - // The look is a group of paths, or one image where an effect (a blur, a shadow) rasterizes it. - if !self.hidden_ids.contains(&object.id) - || self.hidden_ids.contains(&look.id) != hidden - || self.text_looks.contains(&look.id) - || !matches!(look.kind, NodeKind::Group { clip: false, .. } | NodeKind::Image(_)) - { - return; - } - // A style read here: the object, painted as it says. - if let Some(appearance) = self - .styles - .get(&style) - .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) - { - let Some(mut object) = f.children.pop() else { return }; - let Some(look) = f.children.pop() else { return }; - if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { - self.restyled.push(b); - } - object.appearance = appearance.into(); - // The object's transparency is written on its look, after the look's group ends. - (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); - // Now the object itself, not a copy an enclosing look's `strip` would remove. - self.hidden_ids.remove(&object.id); - f.children.push(object); - return; - } - let is_text = slot_of(object.name.as_deref()).is_some(); - let Some(mut object) = f.children.pop() else { return }; - let Some(look) = f.children.last_mut() else { return }; - // Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart. - if !hidden { - strip(look, &self.hidden_ids); - } - if is_text { - // A text object stays the type it is: over what else its look draws, or in place of a - // look that was only its type. Its transparency is written on its look. - let empty = look.children().is_some_and(|c| c.is_empty()); - if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) { - (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); - if empty { - *look = object; - } else { - f.children.push(object); - } - return; - } - // The look and the type go into a group that has the transparency. - let Some(mut look) = f.children.pop() else { return }; - let state = (look.opacity, look.blend, look.isolate, look.knockout); - (look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral); - (object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false); - let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]); - (group.opacity, group.blend, group.isolate, group.knockout) = state; - self.text_looks.insert(group.id); - if let Some(f) = self.frames.last_mut() { - f.children.push(group); - } - return; - } - look.name = object.name.clone().or(look.name.take()); - look.visible = object.visible; - look.locked = object.locked; - if !hidden { - self.drawn_looks += 1; - } - } - - // ---- paths ----------------------------------------------------------------------------- - - fn path_op(&mut self, w: &str, vals: &[V]) -> Result<(), String> { - let n = Self::nums(vals); - let p = |i: usize| -> Option { - let at = n.len().checked_sub(i)?; - Some(Point::new(*n.get(at)?, *n.get(at + 1)?)) - }; - self.points += 1; - if self.points > MAX_POINTS { - return Err("a path has too many points".into()); - } - let current = self.path.elements().last().and_then(|e| e.end_point()); - match w { - "m" => { - let Some(a) = p(2) else { return Ok(()) }; - self.path.move_to(a); - } - _ if current.is_none() => {} - "l" | "L" => { - if let Some(a) = p(2) { - self.path.line_to(a); - } - } - "c" | "C" => { - if let (Some(a), Some(b), Some(c)) = (p(6), p(4), p(2)) { - self.path.curve_to(a, b, c); - } - } - "v" | "V" => { - if let (Some(cur), Some(b), Some(c)) = (current, p(4), p(2)) { - self.path.curve_to(cur, b, c); - } - } - _ => { - if let (Some(a), Some(c)) = (p(4), p(2)) { - self.path.curve_to(a, c, c); - } - } - } - Ok(()) - } - - /// A path left unpainted before a container opens. - fn end_path_quietly(&mut self) { - self.path = BezPath::new(); - self.points = 0; - self.clip_next = false; - } - - /// Paint the current path (`op`), as a guide when `guide`. - fn paint(&mut self, op: &str, hidden: bool, guide: bool) -> Result<(), String> { - let mut bp = std::mem::take(&mut self.path); - self.points = 0; - let clip = std::mem::take(&mut self.clip_next); - if let Some(i) = self.instance.take() { - self.set_instance(&i); - } - let (fill_paint, stroke_paint) = (self.pending_fill.take(), self.pending_stroke.take()); - if bp.elements().is_empty() { - return Ok(()); - } - if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { - close(&mut bp); - } - let to_doc = self.space(); - let path = PathData::from_bezpath(&(to_doc * bp)); - let gs = self.gs.clone(); - let (fill, stroke) = match op.to_ascii_lowercase().as_str() { - "f" => (true, false), - "s" => (false, true), - "b" => (true, true), - _ => (false, false), - }; - let mut items = Vec::new(); - if fill && !clip { - items.push(AppearanceItem::Fill(FillLayer { - overprint: gs.overprint_fill, - ..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone())) - })); - } - if stroke && !clip { - let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width); - st.cap = gs.cap; - st.join = gs.join; - st.miter_limit = gs.miter; - st.dash = gs.dash.clone(); - st.overprint = gs.overprint_stroke; - items.push(AppearanceItem::Stroke(st)); - } - let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide }; - let mut node = self.new_node(kind, &gs); - node.appearance = Appearance { items, ..Appearance::default() }.into(); - node.opacity = gs.opacity; - node.blend = gs.blend; - node.knockout_shape = gs.knockout_shape; - if clip && let Some(f) = self.frames.last_mut() { - f.clips.push(f.children.len()); - } - // A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it. - let painted = clip && fill && !matches!(gs.fill, Paint::None); - let copy = painted.then(|| { - let mut copy = node.clone(); - copy.id = self.doc.alloc_id(); - if let NodeKind::Path { clipping, .. } = &mut copy.kind { - *clipping = false; - } - let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone()); - copy.appearance = Appearance { - items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })], - ..Appearance::default() - } - .into(); - copy - }); - self.add(node, hidden)?; - if let Some(copy) = copy { - self.clip_fills.insert(copy.id); - self.add(copy, hidden)?; - } - Ok(()) - } - - /// `/End X!`: the gradient mesh read, as one object. - fn end_mesh(&mut self) -> Result<(), String> { - let Some((m, hidden)) = self.mesh.take() else { return Ok(()) }; - if m.unread || m.patches.is_empty() { - self.unreadable("gradient meshes it can't read"); - return Ok(()); - } - let gs = self.gs.clone(); - // One mesh where the patches join up into the grid they say they make; else each patch is - // a mesh of its own, in a group. - match m.one_mesh() { - Ok(one) => { - let mut node = self.new_node(NodeKind::Mesh(one), &gs); - node.opacity = gs.opacity; - node.blend = gs.blend; - return self.add(node, hidden); - } - Err(super::mesh::NotOne::Unjoined) => self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches"), - Err(super::mesh::NotOne::Unplaced) => {} - } - let mut patches: Vec = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect(); - let mut node = match patches.len() { - 1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)), - _ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs), - }; - node.opacity = gs.opacity; - node.blend = gs.blend; - self.add(node, hidden) - } - - /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. - fn stroke_last(&mut self) { - let gs = self.gs.clone(); - let Some(n) = self.last_object() else { return }; - if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { - return; - } - let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width); - st.cap = gs.cap; - st.join = gs.join; - st.miter_limit = gs.miter; - st.dash = gs.dash.clone(); - st.overprint = gs.overprint_stroke; - let mut a = (*n.appearance).clone(); - a.items.push(AppearanceItem::Stroke(st)); - n.appearance = a.into(); - } - - fn set_instance(&mut self, i: &Instance) { - let mut to_doc = self.space(); - // In a pattern's definition a gradient's `Xm` and `Bm` are written y down about the canvas point of art space's - // origin, as the file's PDF part draws them. - if self.pattern.is_some() - && i.has_matrix() - && let Some(t) = self.template - { - let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); - to_doc *= Affine::new([1.0, 0.0, 0.0, -1.0, CANVAS_CENTRE - cx, CANVAS_CENTRE + cy]); - } - match i.paint(&self.gradients, to_doc) { - Some(p) if i.stroke => self.pending_stroke = Some(p), - Some(p) => self.pending_fill = Some(p), - None => { - let name = i.name.clone().unwrap_or_default(); - self.warn(&format!("gradient “{name}” isn't defined in the file: a plain colour stands in for it")); - } - } - } - - // ---- images ---------------------------------------------------------------------------- - - /// The samples after `XI`, its operands on the stack. - fn image(&mut self, data: &[u8]) -> Result<(), String> { - let vals = self.operands(); - // Operands that came before an `XI` on their own line. - let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { - Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, - _ => vals, - }; - self.image_from(vals, data) - } - - /// An image of operands `vals` and samples `data`. - fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { - let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); - let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let n = Self::nums(&vals); - let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let (w, h) = (*w as u64, *h as u64); - if w.saturating_mul(h) > MAX_PIXELS { - self.warn(IMAGE_TOO_LARGE); - return Ok(()); - } - if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - } - let channels: usize = match space.as_str() { - "DeviceGray" => 1, - "DeviceCMYK" => 4, - "DeviceRGB" => 3, - _ => match *kind as i64 { - 1 => 1, - 4 => 4, - _ => 3, - }, - }; - let decoded; - let data = if *binary == 0.0 { - let digits: Vec = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect(); - decoded = super::super::lex::hex_decode(&digits).unwrap_or_default(); - decoded.as_slice() - } else { - data - }; - let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels }; - let need = row.saturating_mul(h as usize); - // An alpha channel follows the colour samples, one byte a pixel. - let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; - let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { - self.warn("an image whose data is cut short was left out"); - return Ok(()); - }; - let opacity = opacity.filter(|_| alpha_len > 0); - // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK - // image's, and an alpha channel that hides part of the image becomes its opacity mask. - if channels == 4 - && *bits == 8.0 - && let Some(blob) = Inks::new(w as u32, h as u32, samples.to_vec()).and_then(|i| ImageBlob::cmyk_tiff(&i)) - { - return self.add_raster(blob, opacity, &m, (w as u32, h as u32)); - } - let mut rgba = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h as usize { - let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); - for x in 0..w as usize { - // A 1-bit row holds each pixel's channels in turn, a bit each, padded to a whole - // byte (`row`); a set bit is the channel at full strength (255). - let s = |c: usize| { - let i = x * channels + c; - if *bits == 1.0 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) } - }; - let px = match channels { - 1 => [s(0); 3], - 4 => { - let key = [s(0), s(1), s(2), s(3)]; - if self.cmyk_cache.len() > 1 << 16 { - self.cmyk_cache.clear(); - } - *self.cmyk_cache.entry(key).or_insert_with(|| cmyk_rgb(key)) - } - _ => [s(0), s(1), s(2)], - }; - let a = opacity.and_then(|o| o.get(y * w as usize + x)).copied().unwrap_or(255); - rgba.extend_from_slice(&[px[0], px[1], px[2], a]); - } - } - let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let mut png = vec![]; - if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - } - self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32)) - } - - /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with - /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the - /// image. - fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { - // The matrix is an art-space matrix (y up) applied to the pixels with their rows running - // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead - // gives the same place only when the image isn't rotated (b = c = 0). - let [a, b, c, d, tx, ty] = m else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let to_doc = self.space(); - let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - } - let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); - let key = blob.content_key(); - self.doc.images.entry(key.clone()).or_insert(blob); - self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) - } - - /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its - /// opacity mask, as in PDF import. `None` when it hides nothing. - fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { - if alpha.iter().all(|v| *v == 255) { - return None; - } - let mut png = Vec::new(); - image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - let blob = ImageBlob::new("image/png", png); - let key = blob.content_key(); - self.doc.images.entry(key.clone()).or_insert(blob); - let art = Node::new( - self.doc.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), - ); - Some(OpacityMask::new(art, true)) - } - - /// Add an image object with the state's transparency. - fn add_image(&mut self, im: ImageObject, hidden: bool) -> Result<(), String> { - self.add_image_masked(im, None, hidden) - } - - /// Add an image object with the state's transparency and `mask` as its opacity mask. - fn add_image_masked(&mut self, im: ImageObject, mask: Option, hidden: bool) -> Result<(), String> { - let gs = self.gs.clone(); - let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); - node.opacity = gs.opacity; - node.blend = gs.blend; - node.extras_mut().mask = mask.map(Box::new); - self.add(node, hidden) - } - - /// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that - /// shows the preview the data keeps of it, as a linked image whose file is missing does. One - /// without a preview, a path or a matrix isn't read. - fn end_place(&mut self, hidden: bool) -> Result<(), String> { - let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty()) - else { - self.unreadable("placed files"); - return Ok(()); - }; - // The file's pixels (y down) start at the canvas point of art space's origin (the box's own - // corner when the file doesn't say where the canvas is); the preview's are spread over them. - let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); - let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y)); - let over_file = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(b.width() / f64::from(w), b.height() / f64::from(h)); - let xf = self.space() * m * over_file; - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.unreadable("placed files"); - return Ok(()); - } - // The preview is all the document has of the file: what a missing link shows. - let bytes = Arc::new(png); - let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }; - let key = blob.content_key(); - self.doc.images.entry(key.clone()).or_insert(blob); - self.warn(LINKED_PREVIEWS); - self.add_image( - ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, - hidden, - ) - } - - // ---- the document ---------------------------------------------------------------------- - - /// The artboard of a file without artboards or crop marks: its size around the template - /// box's centre (half a point off the grid). - fn sized_board(&self) -> Option { - let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); - // Rounded out, but not off a whole point the decimals in the file only just miss. - let near = |v: f64| (v - v.round()).abs() < 1e-6; - let (left, top) = (cx - w / 2.0, cy + h / 2.0); - let x0 = if near(left) { left.round() } else { left.floor() }; - let y1 = if near(top) { top.round() } else { top.ceil() }; - let r = Rect::new(x0, y1 - h, x0 + w, y1); - (sane(r) && w > 0.0 && h > 0.0).then_some(r) - } - - /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. - fn first_board(&self) -> Option { - self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) - } - - /// The page of a file that says nothing of its artboard (the legacy format as other apps write - /// it): its art's bounding box. - fn bbox_board(&self) -> Option { - let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; - Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) - } - - /// Art space → the document (fixed when the art starts, from the artboards read by then). A - /// Large Canvas editing copy is written at 1/N scale ([`Self::large_canvas`]): the factor makes - /// its art and boxes reach the page's size, which is what the page it is opened beside holds. - fn ensure_space(&mut self) -> Affine { - if let Some(m) = self.to_doc { - return m; - } - let first = self.first_board().or_else(|| self.bbox_board()); - let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); - let s = self.large_canvas.unwrap_or(1.0); - let m = Affine::new([s, 0.0, 0.0, -s, -s * top_left.x, s * top_left.y]); - self.to_doc = Some(m); - m - } - - /// Art space → where the art being read goes: the document, or while a pattern's definition is - /// read, the pattern's space (y down). - fn space(&mut self) -> Affine { - if self.pattern.is_some() { FLIP } else { self.ensure_space() } - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { - self.warnings.push(w.to_string()); - } - } - - fn finish(mut self) -> Result { - if !self.unknown.is_empty() { - let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); - self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); - } - if !self.unsupported.is_empty() { - let list: Vec<&str> = self.unsupported.iter().map(String::as_str).collect(); - return Err(format!("it has {}, which VectorCraft doesn't read from it yet", join_and(&list))); - } - if self.doc.layers.is_empty() { - return Err("it has no layers".into()); - } - let to_doc = self.ensure_space(); - let artboard = self.first_board(); - drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); - let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); - let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() - } else { - self.artboards.clone() - }; - doc.artboards = boards - .into_iter() - .enumerate() - .map(|(i, (name, r))| Artboard { - id: i as u32 + 1, - name, - rect: to_doc.transform_rect_bbox(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(); - if doc.artboards.is_empty() { - let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); - doc.artboards = vec![Artboard { - id: 1, - name: "Artboard 1".into(), - 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, - }]; - } - if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { - doc.color_mode = ColorMode::Cmyk; - let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - if let Some(u) = self.ruler_units.and_then(ruler_unit) { - doc.units = u; - } - // The file's own swatches in place of the defaults, in its order and colour groups. - if !self.palette.is_empty() { - let palette = std::mem::take(&mut self.palette); - let swatch = |e: super::palette::Entry| -> Option { - use super::palette::Cell; - let (paint, global, spot) = match e.cell { - Cell::Color { color, global, spot } => (Paint::solid(color), global, spot), - Cell::Gradient(g) => { - let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone()); - gp.swatch = Some(e.name.clone()); - (Paint::Gradient(Box::new(gp)), false, false) - } - Cell::Pattern(p) => { - doc.patterns.iter().find(|d| d.name == p)?; - (Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false) - } - }; - Some(Swatch { name: e.name, paint, global, spot }) - }; - let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }]; - swatches.extend(palette.swatches.into_iter().filter_map(swatch)); - let groups: Vec<_> = palette - .groups - .into_iter() - .map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() }) - .collect(); - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - // The colour operators' definitions first: a gradient, defined before the art, names a - // global process colour as a stop and would otherwise make it an ink (#1771). - for n in self.spots.iter().chain(&self.stop_spots) { - if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); - } - } - for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { - if doc.swatch(&name).is_none() { - doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); - } - } - let mut warnings = self.warnings; - if self.drawn_looks > 0 { - warnings.push(format!( - "objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", - self.drawn_looks - )); - } - warnings.extend(self.variables.note()); - Ok(Structure { - doc, - to_doc, - bbox: self.hires.or(self.bbox), - artboard, - template: self.template, - hidden_unread: self.hidden_unread, - slot_names: self.slot_names, - restyled: self.restyled, - warnings, - }) - } -} - -/// The source text inside an envelope options dictionary, carried through its containing metadata. -fn envelope_text_slots(node: &Node, out: &mut Vec) { - if slot_of(node.name.as_deref()).is_some() { - out.push(node.clone()); - } else if let Some(children) = node.children() { - for child in children { - envelope_text_slots(child, out); - } - } -} - -/// The text objects written twice, plainly and on `%_` lines: the commented copy (`hidden`) of a -/// story that also has a plain one goes. -fn drop_commented_copies(layers: &mut [Arc], hidden: &HashSet) { - fn plain(nodes: &[Arc], hidden: &HashSet, out: &mut HashSet) { - for n in nodes { - match slot_of(n.name.as_deref()) { - Some(Some(story)) if !hidden.contains(&n.id) => { - out.insert(story); - } - _ => { - if let Some(c) = n.children() { - plain(c, hidden, out); - } - } - } - } - } - fn drop(nodes: &mut Vec>, hidden: &HashSet, plain: &HashSet) { - nodes.retain(|n| !(hidden.contains(&n.id) && slot_of(n.name.as_deref()).flatten().is_some_and(|s| plain.contains(&s)))); - for n in nodes.iter_mut() { - if n.children().is_some_and(|c| !c.is_empty()) - && let Some(c) = Arc::make_mut(n).children_mut() - { - drop(c, hidden, plain); - } - } - } - let mut stories = HashSet::new(); - plain(layers, hidden, &mut stories); - if stories.is_empty() { - return; - } - for l in layers.iter_mut() { - if let Some(c) = Arc::make_mut(l).children_mut() { - drop(c, hidden, &stories); - } - } -} - -/// A node and its index among its siblings. -type Indexed = (usize, Node); - -/// Close the subpath `bp` ends with, if it has one open. -fn close(bp: &mut BezPath) { - if bp.elements().last().is_some_and(|e| !matches!(e, PathEl::ClosePath)) { - bp.close_path(); - } -} - -/// The fill rule of a path or compound node. -/// `kids` with the members of each plain group among them in the group's place, at any depth. -fn ungrouped(kids: Vec) -> Vec { - let mut out = Vec::with_capacity(kids.len()); - for k in kids { - match k.kind { - NodeKind::Group { children, clip: false } => out.extend(ungrouped(children.into_iter().map(Arc::unwrap_or_clone).collect())), - _ => out.push(k), - } - } - out -} - -/// Whether `look`'s paths don't all paint the same. -fn paints_apart(look: &Node) -> bool { - fn leaves<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { - match &n.kind { - NodeKind::Group { children, .. } => children.iter().for_each(|c| leaves(c, out)), - _ => out.push(n), - } - } - let mut paths = vec![]; - leaves(look, &mut paths); - paths.windows(2).any(|w| matches!(w, [a, b] if a.appearance != b.appearance)) -} - -fn path_rule(n: &Node) -> FillRule { - match &n.kind { - NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, - _ => FillRule::NonZero, - } -} - -/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space -/// → the document (`space`). The tile's lower left is the pattern's origin wherever the -/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted. -fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option { - let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); - (xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) -} - -/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. -struct StylePaints<'a> { - gradients: &'a HashMap, - patterns: &'a HashMap, - space: Affine, -} - -/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name -/// (the first of each; a pattern's name after `PATTERN_KEY`). -fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { - if depth > MAX_DEPTH { - return; - } - for item in &look.appearance.items { - let key = match item.paint() { - Paint::Gradient(g) => g.swatch.clone(), - Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")), - _ => None, - }; - if let Some(key) = key { - out.entry(key).or_insert_with(|| item.paint().clone()); - } - } - for c in look.children().into_iter().flatten() { - placed_paints(c, out, depth + 1); - } -} - -/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. -const PATTERN_KEY: &str = "\u{1}pattern "; - -/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one -/// this reads, or asks a group for fills or strokes. -fn styled(object: &Node, look: &Node, st: &super::style::ArtStyle, paints: &StylePaints) -> Option { - use super::style::StyleItem; - // A text object's slot (an empty group standing for it) takes the style as the type will. - let slot = slot_of(object.name.as_deref()).is_some(); - if !st.plain - || (!slot && !st.simple) - || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) - { - return None; - } - // A style with one gradient fill: the object's own gradient, placed as the object's fill is. - let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count(); - let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_))); - // A style's gradients are written in a space of the object's bounds that isn't documented; its - // drawn look has each where the app placed it. - let mut placed = HashMap::new(); - placed_paints(look, &mut placed, 0); - let mut items = vec![]; - for (n, item) in st.items.iter().enumerate() { - let mut layer = match item { - StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), - StyleItem::Gradient { name, angle } => { - let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { - // Other objects than type: the object's own, as they always were. - (_, 1, Some(own)) if !slot => own.paint.clone(), - (Some(p), ..) => p.clone(), - (None, 1, Some(own)) => own.paint.clone(), - _ => { - let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?; - let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone()); - // Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do). - g.angle = *angle; - Paint::Gradient(Box::new(g)) - } - }; - AppearanceItem::Fill(FillLayer::new(paint)) - } - StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { - Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) - } - }, - StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => { - let mut layer = StrokeLayer::new(paint.clone(), *width); - (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); - AppearanceItem::Stroke(layer) - } - }; - if let Some(look) = st.looks.get(n) { - match &mut layer { - AppearanceItem::Fill(f) => { - (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) - } - AppearanceItem::Stroke(s) => { - (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) - } - } - } - items.push(layer); - } - let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) { - st.contents_index - } else { - object.appearance.contents_index - }; - Some(Appearance { items, effects: st.effects.clone(), contents_index }) -} - -/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). -fn strip(n: &mut Node, hidden: &HashSet) { - if let Some(children) = n.children_mut() { - children.retain(|c| !hidden.contains(&c.id)); - for c in children.iter_mut() { - strip(Arc::make_mut(c), hidden); - } - } -} - -/// The dictionary of entry `key` in `o` or the dictionaries it holds. -fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { - if depth > 8 { - return None; - } - o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1))) -} - -/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds -/// (see [`VariableLibrary`]) to `found`. -fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) { - if depth > MAX_DEPTH { - return; - } - if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") { - for set in elements(o, "variableSet") { - found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count(); - found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count(); - } - return; - } - for c in o.items() { - variable_library(c, depth + 1, found); - } -} - -/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`). -fn element_name(o: &Obj) -> Option { - let name = o.text("xmlnode-nodename")?; - Some(name.rsplit(':').next().unwrap_or(&name).to_string()) -} - -/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. -fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator { - o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name)) -} - -/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by -/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is -/// larger than [`MAX_PIXELS`] or is cut short. -fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> { - let mut rest = data.trim_ascii_start(); - let mut head = [0usize; 5]; - for v in &mut head { - let n = rest.iter().position(|b| *b == b' ')?; - *v = std::str::from_utf8(rest.get(..n)?).ok()?.parse().ok()?; - rest = rest.get(n + 1..)?; - } - let [w, h, row, bits, _] = head; - let (width, height) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?); - if w == 0 || h == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS || bits != 32 || row < w * 4 { - return None; - } - // Every row is there before anything is allocated for them. - if rest.len() < (h - 1).checked_mul(row)?.checked_add(w * 4)? { - return None; - } - let mut rgba = Vec::with_capacity(w * h * 4); - for line in rest.chunks(row).take(h) { - for &[a, r, g, b] in line.get(..w * 4)?.as_chunks::<4>().0 { - let straight = |c: u8| if a == 0 { 0 } else { (u32::from(c) * 255 / u32::from(a)).min(255) as u8 }; - rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]); - } - } - let mut png = vec![]; - image::RgbaImage::from_raw(width, height, rgba)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some((png, width, height)) -} - -fn sane(r: Rect) -> bool { - [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) -} - -/// The four numbers of a comment's value (a box that may be a point). -fn four(v: &str) -> Option<[f64; 4]> { - let n: Vec = v.split_whitespace().map_while(|w| w.parse::().ok()).collect(); - let [a, b, c, d] = n.as_slice() else { return None }; - [a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE).then_some([*a, *b, *c, *d]) -} - -/// The first two numbers of a comment's value. -fn two(v: &str) -> Option<(f64, f64)> { - let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).filter(|v: &f64| v.is_finite()).collect(); - match n.as_slice() { - [a, b, ..] => Some((*a, *b)), - _ => None, - } -} - -/// A CMYK sample in RGB. -fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { - let f = |v: u8| f32::from(v) / 255.0; - let rgb = vectorcraft_color::Color::cmyk(f(c), f(m), f(y), f(k)).to_rgba8(1.0); - [rgb[0], rgb[1], rgb[2]] -} - -/// `a`, `a and b`, `a, b and c`. -fn join_and(items: &[&str]) -> String { - match items { - [] => String::new(), - [one] => (*one).to_string(), - [rest @ .., last] => format!("{} and {last}", rest.join(", ")), - } } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs new file mode 100644 index 000000000..fcc2cb3c4 --- /dev/null +++ b/crates/eps/src/import/ai/read/art.rs @@ -0,0 +1,566 @@ +use super::containers::close; +use super::document::TEXT_SLOT; +use super::types::{FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place}; +use super::*; + +impl<'a> Reader<'a> { + /// A text object (`/AI11Text`): an empty group standing for it, named after its story. + pub(super) fn text_slot(&mut self, o: &Obj) -> Result<(), String> { + if self.pattern.is_some() { + self.unreadable("type in a pattern"); + } + // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its + // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its + // stories on its own; the object itself follows the look, on `%_` lines, and is the type. + if o.nums("FreeUndo").first() == Some(&1.0) { + return Ok(()); + } + let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); + let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); + let gs = self.gs.clone(); + let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs); + n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}"))); + // The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own. + n.opacity = gs.opacity; + n.blend = gs.blend; + let hidden = self.obj_hidden; + self.add(n, hidden) + } + + /// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but + /// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler), + /// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform` + /// maps that space onto the art (both read off files: the box lands on the clipping paths + /// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one + /// group. Without data, or with a PDF that can't be read, nothing is drawn. + pub(super) fn foreign_art(&mut self, o: &Obj) -> Result<(), String> { + let Some(text) = self.foreign_data.take() else { return Ok(()) }; + // One `%` starts each line; the rest, `_` included, is data. + let mut encoded = String::with_capacity(text.len()); + for line in text.split(|b| matches!(b, b'\r' | b'\n')) { + encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line))); + } + let Some(pdf) = ps::ascii85_decode(&encoded) else { + self.warn(NON_NATIVE_ART_UNREAD); + return Ok(()); + }; + let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums)); + let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else { + self.warn(NON_NATIVE_ART_UNREAD); + return Ok(()); + }; + let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]); + let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1)); + if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 { + self.warn(NON_NATIVE_ART_UNREAD); + return Ok(()); + } + let target = self.space().transform_rect_bbox(art_box); + let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; + let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { + Ok(r) => r, + Err(e) => { + self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})")); + return Ok(()); + } + }; + let mut art: Vec = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect(); + let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) }; + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return Ok(()); + } + // The art's own box onto the object's box (its measure of the same art). The PDF importer + // hands the art back as the page shows it, and it keeps that orientation unless its + // placement mirrors it (a negative determinant): the usual `/RTransform` `1 0 0 -1 …` with + // the origin's flip doesn't, and the art then shows as the file's own PDF part draws it. + let fit = if place.determinant() < 0.0 { + Affine::translate((target.x0, target.y1)) * Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height()) + } else { + Affine::translate((target.x0, target.y0)) * Affine::scale_non_uniform(target.width() / bbox.width(), target.height() / bbox.height()) + } * Affine::translate((-bbox.x0, -bbox.y0)); + for n in &mut art { + n.transform(fit, true); + // The PDF's swatches stay with it: its colours keep their values, unlinked. + self.reid(n); + } + for (key, blob) in read.document.images { + self.doc.images.entry(key).or_insert(blob); + } + let gs = self.gs.clone(); + let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs); + group.name = Some(NON_NATIVE_ART.into()); + self.add(group, self.obj_hidden) + } + + // ---- paths ----------------------------------------------------------------------------- + + pub(super) fn path_op(&mut self, w: &str, vals: &[V]) -> Result<(), String> { + let n = Self::nums(vals); + let p = |i: usize| -> Option { + let at = n.len().checked_sub(i)?; + Some(Point::new(*n.get(at)?, *n.get(at + 1)?)) + }; + self.points += 1; + if self.points > MAX_POINTS { + return Err("a path has too many points".into()); + } + let current = self.path.elements().last().and_then(|e| e.end_point()); + match w { + "m" => { + let Some(a) = p(2) else { return Ok(()) }; + self.path.move_to(a); + } + _ if current.is_none() => {} + "l" | "L" => { + if let Some(a) = p(2) { + self.path.line_to(a); + } + } + "c" | "C" => { + if let (Some(a), Some(b), Some(c)) = (p(6), p(4), p(2)) { + self.path.curve_to(a, b, c); + } + } + "v" | "V" => { + if let (Some(cur), Some(b), Some(c)) = (current, p(4), p(2)) { + self.path.curve_to(cur, b, c); + } + } + _ => { + if let (Some(a), Some(c)) = (p(4), p(2)) { + self.path.curve_to(a, c, c); + } + } + } + Ok(()) + } + + /// A path left unpainted before a container opens. + pub(crate) fn end_path_quietly(&mut self) { + self.path = BezPath::new(); + self.points = 0; + self.clip_next = false; + } + + /// Paint the current path (`op`), as a guide when `guide`. + pub(super) fn paint(&mut self, op: &str, hidden: bool, guide: bool) -> Result<(), String> { + let mut bp = std::mem::take(&mut self.path); + self.points = 0; + let clip = std::mem::take(&mut self.clip_next); + if let Some(i) = self.instance.take() { + self.set_instance(&i); + } + let (fill_paint, stroke_paint) = (self.pending_fill.take(), self.pending_stroke.take()); + if bp.elements().is_empty() { + return Ok(()); + } + if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { + close(&mut bp); + } + let to_doc = self.space(); + let path = PathData::from_bezpath(&(to_doc * bp)); + let gs = self.gs.clone(); + let (fill, stroke) = match op.to_ascii_lowercase().as_str() { + "f" => (true, false), + "s" => (false, true), + "b" => (true, true), + _ => (false, false), + }; + let mut items = Vec::new(); + if fill && !clip { + items.push(AppearanceItem::Fill(FillLayer { + overprint: gs.overprint_fill, + ..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone())) + })); + } + if stroke && !clip { + let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width); + st.cap = gs.cap; + st.join = gs.join; + st.miter_limit = gs.miter; + st.dash = gs.dash.clone(); + st.overprint = gs.overprint_stroke; + items.push(AppearanceItem::Stroke(st)); + } + let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide }; + let mut node = self.new_node(kind, &gs); + node.appearance = Appearance { items, ..Appearance::default() }.into(); + node.opacity = gs.opacity; + node.blend = gs.blend; + node.knockout_shape = gs.knockout_shape; + if clip && let Some(f) = self.frames.last_mut() { + f.clips.push(f.children.len()); + } + // A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it. + let painted = clip && fill && !matches!(gs.fill, Paint::None); + let copy = painted.then(|| { + let mut copy = node.clone(); + copy.id = self.doc.alloc_id(); + if let NodeKind::Path { clipping, .. } = &mut copy.kind { + *clipping = false; + } + let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone()); + copy.appearance = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })], + ..Appearance::default() + } + .into(); + copy + }); + self.add(node, hidden)?; + if let Some(copy) = copy { + self.clip_fills.insert(copy.id); + self.add(copy, hidden)?; + } + Ok(()) + } + + /// `/End X!`: the gradient mesh read, as one object. + pub(super) fn end_mesh(&mut self) -> Result<(), String> { + let Some((m, hidden)) = self.mesh.take() else { return Ok(()) }; + if m.unread || m.patches.is_empty() { + self.unreadable("gradient meshes it can't read"); + return Ok(()); + } + let gs = self.gs.clone(); + // One mesh where the patches join up into the grid they say they make; else each patch is + // a mesh of its own, in a group. + match m.one_mesh() { + Ok(one) => { + let mut node = self.new_node(NodeKind::Mesh(one), &gs); + node.opacity = gs.opacity; + node.blend = gs.blend; + return self.add(node, hidden); + } + Err(crate::import::ai::mesh::NotOne::Unjoined) => { + self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches") + } + Err(crate::import::ai::mesh::NotOne::Unplaced) => {} + } + let mut patches: Vec = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect(); + let mut node = match patches.len() { + 1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)), + _ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs), + }; + node.opacity = gs.opacity; + node.blend = gs.blend; + self.add(node, hidden) + } + + /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. + pub(super) fn stroke_last(&mut self) { + let gs = self.gs.clone(); + let Some(n) = self.last_object() else { return }; + if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { + return; + } + let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width); + st.cap = gs.cap; + st.join = gs.join; + st.miter_limit = gs.miter; + st.dash = gs.dash.clone(); + st.overprint = gs.overprint_stroke; + let mut a = (*n.appearance).clone(); + a.items.push(AppearanceItem::Stroke(st)); + n.appearance = a.into(); + } + + pub(super) fn set_instance(&mut self, i: &Instance) { + let mut to_doc = self.space(); + // In a pattern's definition a gradient's `Xm` and `Bm` are written y down about the canvas point of art space's + // origin, as the file's PDF part draws them. + if self.pattern.is_some() + && i.has_matrix() + && let Some(t) = self.template + { + let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); + to_doc *= Affine::new([1.0, 0.0, 0.0, -1.0, CANVAS_CENTRE - cx, CANVAS_CENTRE + cy]); + } + match i.paint(&self.gradients, to_doc) { + Some(p) if i.stroke => self.pending_stroke = Some(p), + Some(p) => self.pending_fill = Some(p), + None => { + let name = i.name.clone().unwrap_or_default(); + self.warn(&format!("gradient “{name}” isn't defined in the file: a plain colour stands in for it")); + } + } + } + + // ---- images ---------------------------------------------------------------------------- + + /// The samples after `XI`, its operands on the stack. + pub(super) fn image(&mut self, data: &[u8]) -> Result<(), String> { + let vals = self.operands(); + // Operands that came before an `XI` on their own line. + let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { + Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, + _ => vals, + }; + self.image_from(vals, data) + } + + /// An image of operands `vals` and samples `data`. + pub(super) fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { + let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); + let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let n = Self::nums(&vals); + let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let (w, h) = (*w as u64, *h as u64); + if w.saturating_mul(h) > MAX_PIXELS { + self.warn(IMAGE_TOO_LARGE); + return Ok(()); + } + if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + let channels: usize = match space.as_str() { + "DeviceGray" => 1, + "DeviceCMYK" => 4, + "DeviceRGB" => 3, + _ => match *kind as i64 { + 1 => 1, + 4 => 4, + _ => 3, + }, + }; + let decoded; + let data = if *binary == 0.0 { + let digits: Vec = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect(); + decoded = crate::import::lex::hex_decode(&digits).unwrap_or_default(); + decoded.as_slice() + } else { + data + }; + let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels }; + let need = row.saturating_mul(h as usize); + // An alpha channel follows the colour samples, one byte a pixel. + let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; + let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { + self.warn("an image whose data is cut short was left out"); + return Ok(()); + }; + let opacity = opacity.filter(|_| alpha_len > 0); + // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK + // image's, and an alpha channel that hides part of the image becomes its opacity mask. + if channels == 4 + && *bits == 8.0 + && let Some(blob) = Inks::new(w as u32, h as u32, samples.to_vec()).and_then(|i| ImageBlob::cmyk_tiff(&i)) + { + return self.add_raster(blob, opacity, &m, (w as u32, h as u32)); + } + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h as usize { + let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); + for x in 0..w as usize { + // A 1-bit row holds each pixel's channels in turn, a bit each, padded to a whole + // byte (`row`); a set bit is the channel at full strength (255). + let s = |c: usize| { + let i = x * channels + c; + if *bits == 1.0 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) } + }; + let px = match channels { + 1 => [s(0); 3], + 4 => { + let key = [s(0), s(1), s(2), s(3)]; + if self.cmyk_cache.len() > 1 << 16 { + self.cmyk_cache.clear(); + } + *self.cmyk_cache.entry(key).or_insert_with(|| cmyk_rgb(key)) + } + _ => [s(0), s(1), s(2)], + }; + let a = opacity.and_then(|o| o.get(y * w as usize + x)).copied().unwrap_or(255); + rgba.extend_from_slice(&[px[0], px[1], px[2], a]); + } + } + let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let mut png = vec![]; + if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32)) + } + + /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with + /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the + /// image. + fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + // The matrix is an art-space matrix (y up) applied to the pixels with their rows running + // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead + // gives the same place only when the image isn't rotated (b = c = 0). + let [a, b, c, d, tx, ty] = m else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let to_doc = self.space(); + let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) + } + + /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its + /// opacity mask, as in PDF import. `None` when it hides nothing. + fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { + if alpha.iter().all(|v| *v == 255) { + return None; + } + let mut png = Vec::new(); + image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + let art = Node::new( + self.doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); + Some(OpacityMask::new(art, true)) + } + + /// Add an image object with the state's transparency. + fn add_image(&mut self, im: ImageObject, hidden: bool) -> Result<(), String> { + self.add_image_masked(im, None, hidden) + } + + /// Add an image object with the state's transparency and `mask` as its opacity mask. + fn add_image_masked(&mut self, im: ImageObject, mask: Option, hidden: bool) -> Result<(), String> { + let gs = self.gs.clone(); + let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); + node.opacity = gs.opacity; + node.blend = gs.blend; + node.extras_mut().mask = mask.map(Box::new); + self.add(node, hidden) + } + + /// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that + /// shows the preview the data keeps of it, as a linked image whose file is missing does. One + /// without a preview, a path or a matrix isn't read. + pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> { + let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty()) + else { + self.unreadable("placed files"); + return Ok(()); + }; + // The file's pixels (y down) start at the canvas point of art space's origin (the box's own + // corner when the file doesn't say where the canvas is); the preview's are spread over them. + let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y)); + let over_file = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(b.width() / f64::from(w), b.height() / f64::from(h)); + let xf = self.space() * m * over_file; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + self.unreadable("placed files"); + return Ok(()); + } + // The preview is all the document has of the file: what a missing link shows. + let bytes = Arc::new(png); + let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }; + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + self.warn(LINKED_PREVIEWS); + self.add_image( + ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, + hidden, + ) + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { + self.warnings.push(w.to_string()); + } + } +} + +/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`). +pub(super) fn element_name(o: &Obj) -> Option { + let name = o.text("xmlnode-nodename")?; + Some(name.rsplit(':').next().unwrap_or(&name).to_string()) +} + +/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. +pub(super) fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator { + o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name)) +} + +/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by +/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is +/// larger than [`MAX_PIXELS`] or is cut short. +pub(super) fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> { + let mut rest = data.trim_ascii_start(); + let mut head = [0usize; 5]; + for v in &mut head { + let n = rest.iter().position(|b| *b == b' ')?; + *v = std::str::from_utf8(rest.get(..n)?).ok()?.parse().ok()?; + rest = rest.get(n + 1..)?; + } + let [w, h, row, bits, _] = head; + let (width, height) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?); + if w == 0 || h == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS || bits != 32 || row < w * 4 { + return None; + } + // Every row is there before anything is allocated for them. + if rest.len() < (h - 1).checked_mul(row)?.checked_add(w * 4)? { + return None; + } + let mut rgba = Vec::with_capacity(w * h * 4); + for line in rest.chunks(row).take(h) { + for &[a, r, g, b] in line.get(..w * 4)?.as_chunks::<4>().0 { + let straight = |c: u8| if a == 0 { 0 } else { (u32::from(c) * 255 / u32::from(a)).min(255) as u8 }; + rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]); + } + } + let mut png = vec![]; + image::RgbaImage::from_raw(width, height, rgba)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some((png, width, height)) +} + +pub(super) fn sane(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) +} + +/// The four numbers of a comment's value (a box that may be a point). +pub(super) fn four(v: &str) -> Option<[f64; 4]> { + let n: Vec = v.split_whitespace().map_while(|w| w.parse::().ok()).collect(); + let [a, b, c, d] = n.as_slice() else { return None }; + [a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE).then_some([*a, *b, *c, *d]) +} + +/// The first two numbers of a comment's value. +pub(super) fn two(v: &str) -> Option<(f64, f64)> { + let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).filter(|v: &f64| v.is_finite()).collect(); + match n.as_slice() { + [a, b, ..] => Some((*a, *b)), + _ => None, + } +} + +/// A CMYK sample in RGB. +fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { + let f = |v: u8| f32::from(v) / 255.0; + let rgb = vectorcraft_color::Color::cmyk(f(c), f(m), f(y), f(k)).to_rgba8(1.0); + [rgb[0], rgb[1], rgb[2]] +} + +/// `a`, `a and b`, `a, b and c`. +pub(super) fn join_and(items: &[&str]) -> String { + match items { + [] => String::new(), + [one] => (*one).to_string(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs new file mode 100644 index 000000000..b1168b2bc --- /dev/null +++ b/crates/eps/src/import/ai/read/containers.rs @@ -0,0 +1,469 @@ +use super::document::slot_of; +use super::ops::{paints_apart, path_rule}; +use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES}; +use super::*; + +impl<'a> Reader<'a> { + // ---- frames ---------------------------------------------------------------------------- + + pub(super) fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { + if self.frames.len() >= MAX_DEPTH { + return Err("its objects are nested too deeply".into()); + } + if matches!(kind, Kind::Layer(_)) { + self.ensure_space(); + } + let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; + self.frames.push(Frame { + kind, + children: Vec::new(), + hidden, + after: self.after_object, + gs: self.gs.clone(), + base, + clips: Vec::new(), + compound_members: false, + }); + self.after_object = false; + Ok(()) + } + + /// Close the innermost frame, which must be of the kind `is` accepts. + fn close(&mut self, what: &str, is: impl Fn(&Kind) -> bool) -> Result { + match self.frames.last() { + Some(f) if is(&f.kind) => self.frames.pop().ok_or_else(|| format!("`{what}` closes nothing")), + _ => Err(format!("`{what}` closes something it didn't open")), + } + } + + /// Add a node to the innermost container. + pub(crate) fn add(&mut self, node: Node, hidden: bool) -> Result<(), String> { + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(format!("it has more than {MAX_NODES} objects")); + } + if hidden { + self.hidden_ids.insert(node.id); + } + match self.frames.last_mut() { + Some(f) => f.children.push(node), + None => return Err("it has art outside its layers".into()), + } + self.after_object = true; + Ok(()) + } + + /// The last object read, while nothing else started. + pub(super) fn last_object(&mut self) -> Option<&mut Node> { + if !self.after_object { + return None; + } + self.frames.last_mut()?.children.last_mut() + } + + pub(crate) fn new_node(&mut self, kind: NodeKind, gs: &GState) -> Node { + let mut n = Node::new(self.doc.alloc_id(), kind); + n.visible = !gs.hidden; + n.locked = gs.locked; + n + } + + pub(super) fn end_container(&mut self, what: &str, hidden: bool) -> Result<(), String> { + let f = match what { + "U" => self.close(what, |k| matches!(k, Kind::Group))?, + "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, + _ => self.close(what, |k| matches!(k, Kind::Clip))?, + }; + let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; + // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save + // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. + if matches!(kind, Kind::Group) + && !clips.is_empty() + && clips.len() == kids.len() + && matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. })) + { + for kid in kids { + if let Some(parent) = self.frames.last_mut() { + parent.clips.push(parent.children.len()); + } + self.add(kid, opened_hidden || hidden)?; + } + return Ok(()); + } + // A compound path of clipping paths clips its clipping group. + let clipping = matches!(kind, Kind::Compound) && !clips.is_empty(); + self.group_ended = !matches!(kind, Kind::Compound); + let mut painted = None; + let node = match kind { + // A compound path holding a group (its drawn look) is that look: its paths paint apart. + // Some files mark every group so, the plain one holding the subpaths too (#980): one + // paint for all its paths keeps it one compound path, its holes cut. + Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id) && paints_apart(k)) => { + let (children, clip) = self.clip_first(kids, &clips); + self.new_node(NodeKind::Group { children, clip }, &gs) + } + Kind::Compound => { + // A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths' + // painted copies make a compound of their own, under what it clips. + let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); + painted = self.compound(fills, &gs); + let Some(n) = self.compound(kids, &gs) else { return Ok(()) }; + n + } + // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. + _ => { + let (children, clip) = self.clip_first(kids, &clips); + self.new_node(NodeKind::Group { children, clip }, &gs) + } + }; + if clipping && let Some(f) = self.frames.last_mut() { + f.clips.push(f.children.len()); + } + if compound_members { + self.compound_groups.insert(node.id); + } + self.add(node, opened_hidden || hidden)?; + if let Some(p) = painted { + self.clip_fills.insert(p.id); + self.add(p, opened_hidden || hidden)?; + } + Ok(()) + } + + /// The compound path of `kids`, painted and made transparent as its subpaths say. + fn compound(&mut self, mut kids: Vec, gs: &GState) -> Option { + // The compound path's paint is its last path's (a gradient's instance wraps only + // that one); the last painted one, should the last have none. + let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?; + let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown)); + for k in &mut kids { + k.opacity = 1.0; + k.blend = BlendMode::Normal; + k.visible = true; + k.locked = false; + } + let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs); + n.appearance = appearance; + n.opacity = opacity; + n.blend = blend; + Some(n) + } + + /// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound + /// path → the children and whether they clip. + fn clip_first(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { + let clips: std::collections::HashSet = clips.iter().copied().collect(); + let (mut clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); + let clip_node = match clip.len() { + 0 => None, + 1 => clip.pop().map(|(_, n)| n), + _ => { + let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n)); + let children = clip.into_iter().map(|(_, n)| Arc::new(n)).collect(); + Some(Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })) + } + }; + let clips = clip_node.is_some(); + // A clipping path's own fill paints under the art it clips. + let (fills, rest): (Vec, Vec) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id)); + let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect(); + (children, clips) + } + + pub(super) fn end_layer(&mut self) -> Result<(), String> { + let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let Kind::Layer(a) = f.kind else { return Ok(()) }; + let (children, clip) = self.clip_first(f.children, &f.clips); + let color = a.color.unwrap_or(LayerColor::Preset(0)); + let mut layer = Node::new( + self.doc.alloc_id(), + NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, + ); + layer.name = Some(a.name.clone()); + layer.visible = a.visible; + layer.locked = a.locked; + // The layer's own opacity, blend and knockout, written after its art (#2003). + if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { + (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); + } + match self.frames.last_mut() { + Some(parent) => { + parent.children.push(layer); + self.after_object = true; + } + None => { + self.doc.layers.push(Arc::new(layer)); + self.after_object = false; + } + } + Ok(()) + } + + /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file + /// is read from its PDF part. + pub(super) fn note_unknown(&mut self, w: &str) { + let op: String = w.chars().take(24).collect(); + if !self.shown() { + if self.hidden_unread.len() < 16 { + self.hidden_unread.insert(format!("`{op}`")); + } + } else if self.unknown.len() < 16 { + self.unknown.insert(op); + } + } + + /// `:` after `/Type`: a dictionary opens. + pub(super) fn begin_obj(&mut self, hidden: bool) -> Result<(), String> { + let ty = match self.stack.pop() { + Some(V::Name(n)) => n, + Some(other) => { + self.stack.push(other); + String::new() + } + None => String::new(), + }; + self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?; + // A dictionary's state starts afresh (styles set theirs inside). + self.gs = GState::default(); + Ok(()) + } + + /// Capture mask art in document coordinates, without adding it to the owner's siblings. + pub(super) fn end_mask(&mut self) -> Result<(), String> { + self.end_path_quietly(); + let f = self.close("X+", |k| matches!(k, Kind::Mask))?; + let (children, clip) = self.clip_first(f.children, &f.clips); + let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); + self.gs = f.gs; + self.after_object = f.after; + let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { + return Err("mask art ends outside a mask dictionary".into()); + }; + if o.art.replace(Arc::new(art)).is_some() { + return Err("a mask has more than one art block".into()); + } + Ok(()) + } + + /// `;`: the innermost dictionary closes. + pub(super) fn end_obj(&mut self) -> Result<(), String> { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { + return Ok(()); + } + let Some(f) = self.frames.pop() else { return Ok(()) }; + let Frame { kind, children, base, hidden, gs, after, .. } = f; + let Kind::Obj(mut o) = kind else { return Ok(()) }; + obj::close(&mut o, &mut self.stack, base); + self.obj_hidden = hidden; + // Art made inside a dictionary (type's paths) isn't kept. + self.gs = gs; + self.after_object = after; + if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { + return self.push(V::Obj(Rc::new(*o))); + } + match o.ty.as_str() { + "ArtDictionary" => { + let envelope = o.get("AIDeformOptionsKey").is_some(); + self.art_dictionary(&o); + if envelope { + let mut slots = vec![]; + for child in &children { + envelope_text_slots(child, &mut slots); + } + for mut slot in slots { + // The editing data keeps the source text inside the envelope's options; + // its visible result is the outlined art the PDF page carries. + slot.visible = false; + self.add(slot, false)?; + } + } + } + "Document" => self.document(&o), + "AI11Text" => self.text_slot(&o)?, + "SymbolInstance" => self.unreadable("symbols"), + "ForeignObject" => self.foreign_art(&o)?, + _ => {} + } + Ok(()) + } + + /// Give `n` and its descendants ids of this document, and unlink their swatches. + pub(super) fn reid(&mut self, n: &mut Node) { + n.id = self.doc.alloc_id(); + for it in &mut n.appearance.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = p { + g.swatch = None; + } + p.map_links(&mut |_, link, _| { + *link = None; + true + }); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + self.reid(Arc::make_mut(&mut m.art)); + } + if let Some(children) = n.children_mut() { + for c in children { + self.reid(Arc::make_mut(c)); + } + } + } + + /// Note something the data has that isn't read: an error on a layer that shows, left out of a + /// layer that doesn't. + pub(super) fn unreadable(&mut self, what: &str) { + if self.shown() { + self.unsupported.insert(what.into()); + } else { + self.hidden_unread.insert(what.into()); + } + } + + /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding + /// only gradient meshes. Whether it was dropped. + pub(super) fn drop_envelope_mesh(&mut self) -> bool { + fn only_meshes(n: &Node) -> bool { + match &n.kind { + NodeKind::Mesh(_) => true, + NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), + _ => false, + } + } + let Some(n) = self.last_object() else { return false }; + let id = n.id; + if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { + return false; + } + if let Some(f) = self.frames.last_mut() { + f.children.pop(); + } + self.hidden_ids.remove(&id); + self.after_object = false; + true + } + + /// Do the layers being read show? + fn shown(&self) -> bool { + self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) + } + + /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` + /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, + /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). + pub(super) fn end_blend(&mut self) { + let Some((_, hex)) = self.blend_record.take() else { return }; + let ids = crate::import::ai::blend::hex_bytes(&hex).as_deref().and_then(crate::import::ai::blend::record_ids); + let roles = &self.blend_roles; + let Some(f) = self.frames.last_mut() else { return }; + let n = f.children.len(); + let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return }; + let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id))); + let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return }; + if !part_of_blend(edit) { + return; + } + if !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { + // The steps aren't a group of drawn steps: the blend's own art goes all the same, so + // its keys and spine aren't drawn over what the file draws. + f.children.remove(at); + self.warn(crate::import::ai::blend::DRAWN); + return; + } + let mut budget = self.blend_budget; + let live = ids.and_then(|ids| crate::import::ai::blend::live_blend(edit, steps, &ids, roles, &mut budget)); + self.blend_budget = budget; + let Some(mut steps) = f.children.pop() else { return }; + let Some(_edit) = f.children.pop() else { return }; + match live { + Some(kind) => { + // The steps the blend lays out count as the objects they draw, as its drawn steps + // did (they were counted as read). + let mut blend = Node::new(self.doc.alloc_id(), kind); + (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); + (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); + f.children.push(blend); + } + None => { + f.children.push(steps); + self.warn(crate::import::ai::blend::DRAWN); + } + } + } +} + +/// The source text inside an envelope options dictionary, carried through its containing metadata. +fn envelope_text_slots(node: &Node, out: &mut Vec) { + if slot_of(node.name.as_deref()).is_some() { + out.push(node.clone()); + } else if let Some(children) = node.children() { + for child in children { + envelope_text_slots(child, out); + } + } +} + +/// The text objects written twice, plainly and on `%_` lines: the commented copy (`hidden`) of a +/// story that also has a plain one goes. +pub(super) fn drop_commented_copies(layers: &mut [Arc], hidden: &HashSet) { + fn plain(nodes: &[Arc], hidden: &HashSet, out: &mut HashSet) { + for n in nodes { + match slot_of(n.name.as_deref()) { + Some(Some(story)) if !hidden.contains(&n.id) => { + out.insert(story); + } + _ => { + if let Some(c) = n.children() { + plain(c, hidden, out); + } + } + } + } + } + fn drop(nodes: &mut Vec>, hidden: &HashSet, plain: &HashSet) { + nodes.retain(|n| !(hidden.contains(&n.id) && slot_of(n.name.as_deref()).flatten().is_some_and(|s| plain.contains(&s)))); + for n in nodes.iter_mut() { + if n.children().is_some_and(|c| !c.is_empty()) + && let Some(c) = Arc::make_mut(n).children_mut() + { + drop(c, hidden, plain); + } + } + } + let mut stories = HashSet::new(); + plain(layers, hidden, &mut stories); + if stories.is_empty() { + return; + } + for l in layers.iter_mut() { + if let Some(c) = Arc::make_mut(l).children_mut() { + drop(c, hidden, &stories); + } + } +} + +/// A node and its index among its siblings. +type Indexed = (usize, Node); + +/// Close the subpath `bp` ends with, if it has one open. +pub(super) fn close(bp: &mut BezPath) { + if bp.elements().last().is_some_and(|e| !matches!(e, PathEl::ClosePath)) { + bp.close_path(); + } +} + +/// The fill rule of a path or compound node. +/// `kids` with the members of each plain group among them in the group's place, at any depth. +fn ungrouped(kids: Vec) -> Vec { + let mut out = Vec::with_capacity(kids.len()); + for k in kids { + match k.kind { + NodeKind::Group { children, clip: false } => out.extend(ungrouped(children.into_iter().map(Arc::unwrap_or_clone).collect())), + _ => out.push(k), + } + } + out +} diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs new file mode 100644 index 000000000..b3e7d6801 --- /dev/null +++ b/crates/eps/src/import/ai/read/directives.rs @@ -0,0 +1,227 @@ +use super::art::{four, sane, two}; +use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED}; +use super::*; + +impl<'a> Reader<'a> { + pub(super) fn push(&mut self, v: V) -> Result<(), String> { + if self.stack.len() >= MAX_STACK { + return Err("it has too many values in a row".into()); + } + self.stack.push(v); + Ok(()) + } + + /// The operands of the operator being run (above the innermost dictionary's), taken off. + pub(super) fn operands(&mut self) -> Vec { + let base = self.frames.last().map_or(0, |f| f.base).min(self.stack.len()); + self.stack.split_off(base) + } + + pub(super) fn nums(vals: &[V]) -> Vec { + vals.iter().filter_map(V::num).collect() + } + + // ---- comments -------------------------------------------------------------------------- + + pub(super) fn comment(&mut self, c: &[u8]) -> Result<(), String> { + let line = String::from_utf8_lossy(c); + let line = line.trim_end(); + if let Some((len, hex)) = self.blend_record.as_mut() { + if !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { + // As many digits as the record says it has, no more. + let wanted = len.saturating_mul(2); + let take = wanted.saturating_sub(hex.len()).min(line.len()); + hex.extend_from_slice(line.as_bytes().get(..take).unwrap_or_default()); + if hex.len() >= wanted { + self.end_blend(); + } + return Ok(()); + } + self.end_blend(); + } + if let Some(r) = self.raster.as_mut() + && r.operands.is_some() + && !line.is_empty() + && line.bytes().all(|b| b.is_ascii_hexdigit()) + { + if r.hex.len() < MAX_HEX { + r.hex.extend_from_slice(line.as_bytes()); + } + return Ok(()); + } + let m = marker(line); + // A pattern's definition (one inside another's isn't read). + if m == "BeginPattern" && self.pattern.is_none() { + let section = self.section("EndPattern")?; + self.define_pattern(section); + return Ok(()); + } + if m == "BeginArtStyles" { + let section = self.section("EndArtStyles")?; + self.styles = crate::import::ai::style::art_styles(section); + return Ok(()); + } + if m == "BeginPalette" { + let section = self.section("EndPalette")?; + self.palette = crate::import::ai::palette::palette(section); + return Ok(()); + } + if m == "BeginEncoding" { + self.type_state.encoding_comment(line); + } + if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { + return self.skip_to(end); + } + match m { + "%BeginProlog" => return self.skip_to("%EndProlog"), + "%BeginResource" => return self.skip_to("%EndResource"), + // The art ends with the page. + "%PageTrailer" | "%Trailer" => { + self.done = true; + return Ok(()); + } + _ => {} + } + if line.starts_with("AI5_BeginLayer") { + return self.open(Kind::Layer(Box::default()), false); + } + if line.starts_with("AI5_BeginRaster") { + self.raster = Some(Raster::default()); + } else if line.starts_with("AI5_EndRaster") { + if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { + return self.image_from(vals, &hex); + } + } else if line.starts_with("AI5_BeginPlace") { + self.place = Some(Place::default()); + } else if line.starts_with("AI5_EndPlace") { + // One that `~` didn't end isn't read. + if self.place.take().is_some() { + self.unreadable("placed files"); + } + } else if self.frames.is_empty() + && let Some(v) = line.strip_prefix("%HiResBoundingBox:") + { + self.hires = four(v).or(self.hires); + } else if self.frames.is_empty() + && let Some(v) = line.strip_prefix("%BoundingBox:") + { + self.bbox = four(v).or(self.bbox); + } else if let Some(v) = line.strip_prefix("AI3_Cropmarks:") { + let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).collect(); + if let [x0, y0, x1, y1] = n.as_slice() { + let r = Rect::new(*x0, *y0, *x1, *y1); + if sane(r) { + self.cropmarks = Some(r); + } + } + } else if self.frames.is_empty() + && let Some(v) = line.strip_prefix("AI5_RulerUnits:") + && let Ok(n) = v.trim().parse::() + { + self.ruler_units = Some(n); + } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { + self.art_size = two(v); + } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { + self.template_center = two(v); + self.template = four(v).or(self.template); + } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { + // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N + // being this; the printed page applies the same factor, so the art reaches the page's + // size once it is applied to the art space ([`Self::ensure_space`]). A value outside + // the range Illustrator writes is left alone. + if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) + && (1.0..=100.0).contains(&n) + { + *scale = n; + } + } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { + self.color_model = Some(v.trim() == "2"); + } + Ok(()) + } + + /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and + /// versioned content in an alternate). + fn skip_to(&mut self, end: &str) -> Result<(), String> { + self.section(end).map(|_| ()) + } + + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + fn section(&mut self, end: &str) -> Result<&'a [u8], String> { + let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); + let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; + let start = self.lex.offset(); + let mut depth = 0usize; + loop { + let before = self.lex.offset(); + let Some(t) = self.lex.next_token() else { break }; + if let Tok::Comment(c) = t.tok { + let line = String::from_utf8_lossy(c); + let m = marker(&line); + if !begin.is_empty() && m == begin { + depth += 1; + } else if m == end { + if depth == 0 { + return Ok(self.lex.slice(start, before)); + } + depth -= 1; + } + } + } + Err(format!("its section ending with `%{end}` doesn't end")) + } + + /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its + /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't + /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// filled with it can't be read. + fn define_pattern(&mut self, section: &[u8]) { + if self.pattern_tiles.len() >= MAX_PATTERNS { + return; + } + let mut sub = Reader::new(section); + sub.pattern = Some(PatternHead::default()); + // The gradients defined so far, lent (and those it defines kept). + sub.gradients = std::mem::take(&mut self.gradients); + sub.nodes = self.nodes; + sub.template = self.template; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; + let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { + return; + }; + let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut def = PatternDef::new(&head.name, art); + def.tile = FLIP.transform_rect_bbox(tile); + if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { + self.doc.patterns.retain(|p| p.name != def.name); + } + self.doc.patterns.push(def); + for (key, blob) in sub.doc.images { + self.doc.images.entry(key).or_insert(blob); + } + self.spots.extend(sub.spots); + self.stop_spots.extend(sub.stop_spots); + self.cmyk_colors += sub.cmyk_colors; + self.rgb_colors += sub.rgb_colors; + for w in &sub.warnings { + self.warn(w); + } + } + + /// In a pattern's definition, the operands of its first operator start with its head, + /// `(name) llx lly urx ury`: taken off them. + pub(super) fn take_head(&mut self, mut vals: Vec) -> Vec { + let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; + head.taken = true; + let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; + let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); + let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + head.name = name; + head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); + vals.split_off(at + 5) + } +} diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs new file mode 100644 index 000000000..b61b74953 --- /dev/null +++ b/crates/eps/src/import/ai/read/document.rs @@ -0,0 +1,437 @@ +use super::art::{element_name, elements, join_and, sane}; +use super::containers::drop_commented_copies; +use super::types::{FLIP, Kind, MAX_DEPTH}; +use super::*; + +/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). +fn ruler_unit(v: u8) -> Option { + Some(match v { + 0 => Unit::Inches, + 1 => Unit::Millimeters, + 2 => Unit::Points, + 3 => Unit::Picas, + 4 => Unit::Centimeters, + 6 => Unit::Pixels, + _ => return None, + }) +} + +/// The name of the empty group that stands for a text object, followed by the number of its +/// story in the text document when the file says. +pub(crate) const TEXT_SLOT: &str = "\u{0}text"; + +/// Is `name` that of a text slot, and which story is it of? +pub(crate) fn slot_of(name: Option<&str>) -> Option> { + name?.strip_prefix(TEXT_SLOT).map(|rest| rest.split(':').next().and_then(|s| s.parse().ok())) +} + +/// Which of its story's frames a text slot named `name` is (0 when it doesn't say). +pub(crate) fn slot_frame(name: Option<&str>) -> usize { + name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0) +} + +/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't +/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to +/// call them (#1068). +pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { + let found = artboards_read(data); + if !found.is_empty() { + return found; + } + // Reading can stop before the artboards (art kept in the document's data comes first): read + // their array on its own. + artboard_array(data).map_or(found, |array| { + let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); + alone.extend_from_slice(array); + alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); + artboards_read(&alone) + }) +} + +/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found +/// from the key back, counting the dictionaries its artboards open and close. +fn artboard_array(data: &[u8]) -> Option<&[u8]> { + const KEY: &[u8] = b"%_; (ArtboardArray) ,"; + let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len(); + let head = data.get(..end)?; + let mut depth = 0usize; + let mut line_end = head.len(); + loop { + let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); + if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") { + if text.starts_with(b";") || text.starts_with(b"/Def ;") { + depth += 1; + } else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") { + depth = depth.checked_sub(1)?; + if depth == 0 { + return head.get(line_start..); + } + } + } + line_end = line_start.checked_sub(1)?; + } +} + +/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and +/// those of a `/Document` still open when it stopped (its art can be what stopped it). +fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { + let mut r = Reader::new(data); + let _ = r.run(); + let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); + for o in &open { + if r.artboards.is_empty() { + r.document(o); + } + } + r.artboards +} + +/// How many variables and data sets the variable library of the editing data has: XML nodes +/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each +/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements). +/// VectorCraft doesn't read them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VariableLibrary { + pub variables: usize, + pub data_sets: usize, +} + +impl VariableLibrary { + /// The import's note that the library wasn't read, when it has a variable or a data set. + pub fn note(self) -> Option { + let Self { variables, data_sets } = self; + // Counts after a colon need no plural, so every language can translate the note whole. + (variables + data_sets > 0).then(|| { + format!("the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them") + }) + } +} + +/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers +/// [`read`] can't read. Data without a `variableSets` element isn't read. +pub fn variables(data: &[u8]) -> VariableLibrary { + if crate::import::ai::find(data, b"variableSets").is_none() { + return VariableLibrary::default(); + } + let mut r = Reader::new(data); + let _ = r.run(); + // A `/Document` still open when reading stopped. + let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); + for o in open.iter().filter(|o| o.ty == "Document") { + variable_library(o, 0, &mut r.variables); + } + if r.variables != VariableLibrary::default() { + return r.variables; + } + // Reading can stop before the library: read its node on its own. + variable_sets_node(data).map_or(r.variables, |node| { + let mut alone = b"%_/Document :\n%_/Dictionary :\n%_/Array :\n".to_vec(); + alone.extend_from_slice(node); + alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n"); + let mut r = Reader::new(&alone); + let _ = r.run(); + r.variables + }) +} + +/// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line +/// that closes it: found from its name back, counting the dictionaries and arrays it holds. +fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { + const NAME: &[u8] = b"variableSets) /String (xmlnode-nodename) ,"; + let at = data.windows(NAME.len()).position(|w| w == NAME)?; + let opens = |text: &[u8]| text.starts_with(b"/") && text.windows(2).any(|w| w == b" :"); + let closes = |text: &[u8]| text.starts_with(b";") || text.starts_with(b"/Def ;"); + let mut depth = 0usize; + let mut line_end = data.get(..at)?.iter().rposition(|&b| b == b'\n' || b == b'\r')?; + let start = loop { + let line_start = data.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); + if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { + if closes(text) { + depth += 1; + } else if opens(text) { + match depth.checked_sub(1) { + Some(d) => depth = d, + None => break line_start, + } + } + } + line_end = line_start.checked_sub(1)?; + }; + let mut line_start = start; + loop { + let rest = data.get(line_start..)?; + let line_end = line_start + rest.iter().position(|&b| b == b'\n' || b == b'\r').unwrap_or(rest.len()); + if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { + if closes(text) { + depth = depth.checked_sub(1)?; + if depth == 0 { + return data.get(start..line_end); + } + } else if opens(text) { + depth += 1; + } + } + if line_end >= data.len() { + return None; + } + line_start = line_end + 1; + } +} + +impl<'a> Reader<'a> { + /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the + /// opacity mask it may carry. + pub(super) fn art_dictionary(&mut self, o: &Obj) { + // A blend's key object or spine. + if let Some(kind) = o.text("ShapeBlendPathType") + && let Some(id) = self.last_object().map(|n| n.id) + && self.blend_roles.len() < 100_000 + { + self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); + } + // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as + // a group holding the mesh that shapes it; the distorted art the file's PDF part draws + // follows on plain lines. That mesh isn't art, so it goes (#2405). + if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { + return; + } + if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { + if let Some(art) = &mask.art { + let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); + let value = + OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; + if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { + n.extras_mut().mask = Some(Box::new(value)); + } else if !on("Disabled") { + self.unreadable("opacity masks without an owning object"); + } + } else if mask.nums("Disabled").first().copied() != Some(1.0) { + self.unreadable("opacity masks without readable art"); + } + } + let name = match o.text("AIArtName") { + Some(name) => name, + None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { + Some(id) => obj::xml_name(&id), + None => return, + }, + }; + let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }) && !name.is_empty()) else { return }; + if slot_of(n.name.as_deref()).is_some() { + let id = n.id; + self.slot_names.insert(id, name); + } else { + n.name = Some(name); + } + } + + /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable + /// library has. + pub(super) fn document(&mut self, o: &Obj) { + variable_library(o, 0, &mut self.variables); + let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; + for (i, ab) in list.items().enumerate().take(1000) { + let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); + let ([x0, y0, ..], [x1, y1, ..]) = (p1.as_slice(), p2.as_slice()) else { continue }; + let r = Rect::new(*x0, *y0, *x1, *y1).abs(); + if !sane(r) || r.area() <= 0.0 { + continue; + } + let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); + self.artboards.push((name, r)); + } + } + + // ---- the document ---------------------------------------------------------------------- + + /// The artboard of a file without artboards or crop marks: its size around the template + /// box's centre (half a point off the grid). + fn sized_board(&self) -> Option { + let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); + // Rounded out, but not off a whole point the decimals in the file only just miss. + let near = |v: f64| (v - v.round()).abs() < 1e-6; + let (left, top) = (cx - w / 2.0, cy + h / 2.0); + let x0 = if near(left) { left.round() } else { left.floor() }; + let y1 = if near(top) { top.round() } else { top.ceil() }; + let r = Rect::new(x0, y1 - h, x0 + w, y1); + (sane(r) && w > 0.0 && h > 0.0).then_some(r) + } + + /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. + fn first_board(&self) -> Option { + self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + } + + /// The page of a file that says nothing of its artboard (the legacy format as other apps write + /// it): its art's bounding box. + fn bbox_board(&self) -> Option { + let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; + Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) + } + + /// Art space → the document (fixed when the art starts, from the artboards read by then). A + /// Large Canvas editing copy is written at 1/N scale ([`Self::large_canvas`]): the factor makes + /// its art and boxes reach the page's size, which is what the page it is opened beside holds. + pub(super) fn ensure_space(&mut self) -> Affine { + if let Some(m) = self.to_doc { + return m; + } + let first = self.first_board().or_else(|| self.bbox_board()); + let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); + let s = self.large_canvas.unwrap_or(1.0); + let m = Affine::new([s, 0.0, 0.0, -s, -s * top_left.x, s * top_left.y]); + self.to_doc = Some(m); + m + } + + /// Art space → where the art being read goes: the document, or while a pattern's definition is + /// read, the pattern's space (y down). + pub(crate) fn space(&mut self) -> Affine { + if self.pattern.is_some() { FLIP } else { self.ensure_space() } + } + + pub(super) fn finish(mut self) -> Result { + if !self.unknown.is_empty() { + let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); + self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); + } + if !self.unsupported.is_empty() { + let list: Vec<&str> = self.unsupported.iter().map(String::as_str).collect(); + return Err(format!("it has {}, which VectorCraft doesn't read from it yet", join_and(&list))); + } + if self.doc.layers.is_empty() { + return Err("it has no layers".into()); + } + let to_doc = self.ensure_space(); + let artboard = self.first_board(); + drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); + let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); + let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { + artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + } else { + self.artboards.clone() + }; + doc.artboards = boards + .into_iter() + .enumerate() + .map(|(i, (name, r))| Artboard { + id: i as u32 + 1, + name, + rect: to_doc.transform_rect_bbox(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(); + if doc.artboards.is_empty() { + let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); + doc.artboards = vec![Artboard { + id: 1, + name: "Artboard 1".into(), + 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, + }]; + } + if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { + doc.color_mode = ColorMode::Cmyk; + let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + } + if let Some(u) = self.ruler_units.and_then(ruler_unit) { + doc.units = u; + } + // The file's own swatches in place of the defaults, in its order and colour groups. + if !self.palette.is_empty() { + let palette = std::mem::take(&mut self.palette); + let swatch = |e: crate::import::ai::palette::Entry| -> Option { + use crate::import::ai::palette::Cell; + let (paint, global, spot) = match e.cell { + Cell::Color { color, global, spot } => (Paint::solid(color), global, spot), + Cell::Gradient(g) => { + let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone()); + gp.swatch = Some(e.name.clone()); + (Paint::Gradient(Box::new(gp)), false, false) + } + Cell::Pattern(p) => { + doc.patterns.iter().find(|d| d.name == p)?; + (Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false) + } + }; + Some(Swatch { name: e.name, paint, global, spot }) + }; + let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }]; + swatches.extend(palette.swatches.into_iter().filter_map(swatch)); + let groups: Vec<_> = palette + .groups + .into_iter() + .map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() }) + .collect(); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + } + // The colour operators' definitions first: a gradient, defined before the art, names a + // global process colour as a stop and would otherwise make it an ink (#1771). + for n in self.spots.iter().chain(&self.stop_spots) { + if doc.swatch(&n.name).is_none() { + doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + } + } + for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { + if doc.swatch(&name).is_none() { + doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); + } + } + let mut warnings = self.warnings; + if self.drawn_looks > 0 { + warnings.push(format!( + "objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", + self.drawn_looks + )); + } + warnings.extend(self.variables.note()); + Ok(Structure { + doc, + to_doc, + bbox: self.hires.or(self.bbox), + artboard, + template: self.template, + hidden_unread: self.hidden_unread, + slot_names: self.slot_names, + restyled: self.restyled, + warnings, + }) + } +} + +/// The dictionary of entry `key` in `o` or the dictionaries it holds. +fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { + if depth > 8 { + return None; + } + o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1))) +} + +/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds +/// (see [`VariableLibrary`]) to `found`. +fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) { + if depth > MAX_DEPTH { + return; + } + if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") { + for set in elements(o, "variableSet") { + found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count(); + found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count(); + } + return; + } + for c in o.items() { + variable_library(c, depth + 1, found); + } +} diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs new file mode 100644 index 000000000..24105cf28 --- /dev/null +++ b/crates/eps/src/import/ai/read/ops.rs @@ -0,0 +1,646 @@ +use super::containers::close; +use super::document::slot_of; +use super::types::{BLENDS, FLIP, Frame, IGNORED, Kind, MAX_DEPTH}; +use super::*; + +impl<'a> Reader<'a> { + // ---- operators ------------------------------------------------------------------------- + + pub(super) fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { + if self.blend_record.is_some() { + self.end_blend(); + } + match w { + // The writer's placeholder for a number the object doesn't have (a radial gradient's + // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. + "--" => return self.push(V::Num(f64::NAN)), + "[" => { + self.marks.push(self.stack.len()); + return self.push(V::Mark); + } + "]" => { + // The innermost `[` still on the stack (the stack is cut back by operators). + let mut at = self.stack.len(); + while let Some(m) = self.marks.pop() { + if matches!(self.stack.get(m), Some(V::Mark)) { + at = m; + break; + } + } + let items: Vec = self.stack.split_off(at).into_iter().skip(1).collect(); + return self.push(V::Arr(items)); + } + ":" => return self.begin_obj(hidden), + "," => { + if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { + obj::add_entry(o, &mut self.stack, *base); + // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's + // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s + // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line + // of it may start with `%_` and read as hidden tokens: a `(` there would open + // a string that swallows the rest of the layer. + let key = o.entries.last().and_then(|(k, _)| k.as_deref()); + if o.ty == "Binary" && key == Some("ASCII85Decode") { + self.lex.skip_past(b"~>"); + } else if o.ty == "ForeignObject" && key == Some("Data") { + self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); + } + } + return Ok(()); + } + ";" => return self.end_obj(), + "X=" => { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { + // `X=` also introduces legacy text frame art, read by `text_frames`. + return Ok(()); + } + self.end_path_quietly(); + return self.open(Kind::Mask, hidden); + } + "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), + "X+" => return Ok(()), + _ => {} + } + // What follows an object (its name, transparency and style) ends with anything else. The + // state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the `LB`. + if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { + self.after_object = false; + self.layer_state = None; + } + let group_ended = std::mem::take(&mut self.group_ended); + let vals = self.operands(); + let vals = self.take_head(vals); + // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so + // the operator is one the reader doesn't know, as before. + if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { + self.note_unknown("--"); + return Ok(()); + } + if let Some(d) = self.gradient_def.as_mut() { + match w { + "Bs" => d.stop(&vals, &mut self.stop_spots), + "BD" => { + if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { + self.gradients.insert(name, g); + } + } + _ => {} + } + return Ok(()); + } + if w == "X!" || self.mesh.is_some() { + let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None }); + match (w, name) { + ("X!", Some("Mesh")) => { + self.end_path_quietly(); + self.mesh = Some((crate::import::ai::mesh::MeshReader::default(), hidden)); + return Ok(()); + } + ("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(), + ("X#", _) => { + let to_doc = self.space(); + if let Some((m, _)) = self.mesh.as_mut() { + m.entry(&vals, to_doc); + } + return Ok(()); + } + _ if self.mesh.is_some() => return Ok(()), + _ => {} + } + } + match w { + // Containers. + "u" => { + self.end_path_quietly(); + self.open(Kind::Group, hidden)? + } + "*u" => { + self.end_path_quietly(); + self.open(Kind::Compound, hidden)? + } + "q" => { + self.end_path_quietly(); + self.open(Kind::Clip, hidden)? + } + "U" | "*U" | "Q" => self.end_container(w, hidden)?, + "Lb" => self.layer_attrs(&vals), + "Ln" => { + if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { + a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); + } + } + "LB" => self.end_layer()?, + // Paths. + "m" | "l" | "L" | "c" | "C" | "v" | "V" | "y" | "Y" => self.path_op(w, &vals)?, + "h" => close(&mut self.path), + // Ends a path that stays open. + "H" => {} + "W" => self.clip_next = true, + // A text object's frame or path. + "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(), + "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?, + "*" => { + let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into()); + self.paint(&op, hidden, true)?; + } + // Colours. + "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" | "Xs" | "XS" | "Xz" | "XZ" => self.color(w, &vals), + "p" | "P" => self.pattern_paint(w, &vals), + // A pattern's definition ends. + "E" if self.pattern.is_some() => self.done = true, + "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), + "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), + "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, + "w" => { + if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) { + self.gs.width = *v; + } + } + "J" => { + self.gs.cap = match Self::nums(&vals).last().map(|v| *v as i64) { + Some(1) => LineCap::Round, + Some(2) => LineCap::Square, + _ => LineCap::Butt, + } + } + "j" => { + self.gs.join = match Self::nums(&vals).last().map(|v| *v as i64) { + Some(1) => LineJoin::Round, + Some(2) => LineJoin::Bevel, + _ => LineJoin::Miter, + } + } + "M" => { + if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 1.0) { + self.gs.miter = v.min(500.0); + } + } + "d" => self.dash(&vals), + "D" => { + // The published Adobe Illustrator File Format Specification (version 3.0), §6.8, + // permits `u` groups of compound members. + // Their explicit winding declarations describe subpaths, not separate paints; + // modern streams can also end these groups with `9 () XW`. + let n = self.frames.len(); + if n >= 2 + && self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound)) + && let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group)) + { + f.compound_members = true; + } + } + // Transparency and state. + "Xy" => self.transparency(&vals), + // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as + // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. + "Xd" if group_ended => { + let gs = self.gs.clone(); + if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) { + (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + } + } + "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), + "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), + "XW" => self.style_marker(&vals, hidden), + // Gradients. + "Bd" => self.gradient_def = GradientDef::begin(&vals), + "Bs" | "BD" => {} + "Bb" => self.instance = Some(Instance::begin(&vals)), + "Bg" => { + if let Some(i) = self.instance.as_mut() { + i.bg(&vals); + } + } + "Bm" => { + if let Some(i) = self.instance.as_mut() { + i.bm(&vals); + } + } + "Xm" => { + if let Some(i) = self.instance.as_mut() { + i.xm(&vals); + } + } + "Bh" => { + if let Some(i) = self.instance.as_mut() { + i.bh(&vals); + } + } + "BB" => { + // An instance that closes with no path to paint is an object's own note of its + // gradient, written after it: it paints nothing (not the next path either). + if let Some(i) = self.instance.take() + && !self.path.elements().is_empty() + { + self.set_instance(&i); + } + // `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3). + if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) { + self.stroke_last(); + } + } + // Images. + "XN" => { + if let Some(r) = self.raster.as_mut() { + r.space = vals.iter().rev().find_map(V::text); + } + } + // Placed files. + "`" if self.place.is_some() => { + let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }); + let path = vals.iter().rev().find_map(V::text).unwrap_or_default(); + if let Some(p) = self.place.as_mut() { + p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new); + p.bounds = + Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1)); + p.path = path; + } + } + "~" if self.place.is_some() => self.end_place(hidden)?, + // Type in the legacy format. + "To" => self.begin_text(&vals, hidden), + "Tp" => self.begin_text_path(&vals), + "TP" => self.end_text_path(), + "TO" => self.end_text(hidden)?, + "Tx" | "Tj" | "TX" => self.text_body(w, &vals), + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), + // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, + // Far-Eastern type) and final-form positions (§15.3). + "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" + | "Tu" | "Xu" => {} + // Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th` + // hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe` + // tab descriptions between them). + "Th" | "XL" | "Xb" | "XB" | "Xe" => {} + "TE" => self.te(&vals), + // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). + "XI" if self.raster.is_some() => { + if let Some(r) = self.raster.as_mut() { + r.operands = Some(vals); + } + } + // A plugin group ends; a blend's record follows on `%` lines. + "XP" => { + let name = vals.iter().find_map(V::text).unwrap_or_default(); + let len = Self::nums(&vals).last().copied().unwrap_or(0.0); + if name == crate::import::ai::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= crate::import::ai::blend::MAX_RECORD as f64 { + self.blend_record = Some((len as usize, vec![])); + } + } + _ if IGNORED.contains(&w) => {} + // Before the art, the setup runs procedures of the printing format. + _ if self.frames.is_empty() => {} + _ => self.note_unknown(w), + } + Ok(()) + } + + /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place + /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). + fn layer_attrs(&mut self, vals: &[V]) { + let n = Self::nums(vals); + let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; + let flag = |i: usize, default: bool| n.get(i).map_or(default, |v| *v != 0.0); + a.visible = flag(0, true); + a.preview = flag(1, true); + a.locked = !flag(2, true); + a.printable = flag(3, true); + let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); + a.dim = flag(4, false).then_some(dim_pct.round() as u8); + let at = if n.len() <= 10 { 6 } else { 7 }; + let index = n.get(at).copied().unwrap_or(0.0); + let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { + LayerColor::Preset(index as u8) + } else { + LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) + }); + } + + /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): + /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't + /// read can't be drawn. + fn pattern_paint(&mut self, w: &str, vals: &[V]) { + // In the setup it paints nothing (and the art's space isn't fixed yet). + if self.frames.is_empty() { + return; + } + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { + self.unreadable("pattern fills"); + return; + }; + let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + let Some(xf) = pattern_xf(self.space(), matrix, tile) else { + self.unreadable("pattern fills"); + return; + }; + let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; + if w == "p" { + self.gs.fill = p; + } else { + self.gs.stroke = p; + } + } + + fn color(&mut self, w: &str, vals: &[V]) { + let Some((p, named)) = paint::color_op(w, vals) else { return }; + // Registration (`Xs`, `Xz`) says nothing of the document's colour model. + let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); + match p.color().filter(|_| !registration) { + Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, + Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, + _ => {} + } + if let Some(n) = named { + self.spots.push(n); + } + // A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no + // path) is the next object's paint, not the gradient. + if w.ends_with(|c: char| c.is_ascii_lowercase()) { + self.gs.fill = p; + self.pending_fill = None; + } else { + self.gs.stroke = p; + self.pending_stroke = None; + } + } + + /// `[dashes] phase d`. + fn dash(&mut self, vals: &[V]) { + let pattern: Vec = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }).unwrap_or_default(); + let offset = Self::nums(vals).last().copied().unwrap_or(0.0); + self.gs.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash { + pattern, + offset: if offset.is_finite() { offset } else { 0.0 }, + align_corners: false, + }); + } + + /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. + fn transparency(&mut self, vals: &[V]) { + let n = Self::nums(vals); + let [mode, opacity, rest @ ..] = n.as_slice() else { return }; + let gs = &mut self.gs; + gs.blend = BLENDS.get(*mode as usize).copied().filter(|_| *mode >= 0.0).unwrap_or(BlendMode::Normal); + gs.opacity = if opacity.is_finite() { opacity.clamp(0.0, 1.0) as f32 } else { 1.0 }; + gs.isolate = rest.first().is_some_and(|v| *v != 0.0); + gs.knockout = match rest.get(1).map(|v| *v as i64) { + Some(1) => Knockout::On, + Some(2) => Knockout::Neutral, + _ => Knockout::Off, + }; + gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); + let gs = self.gs.clone(); + if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { + if let Some(n) = self.last_object() { + n.opacity = gs.opacity; + n.blend = gs.blend; + n.isolate = gs.isolate; + n.knockout = gs.knockout; + } + self.layer_state = None; + } else { + // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until + // an operator that isn't part of the state block (or the `LB` that takes it) says the + // art moves on, so a path's state before its paint isn't read as its layer's. + self.layer_state = + matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + } + } + + /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its + /// drawn look before it (all on `%_` lines inside another such object). Other markers end what + /// follows an object. + fn style_marker(&mut self, vals: &[V], hidden: bool) { + let code = Self::nums(vals).last().copied().unwrap_or(0.0); + let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); + let after = std::mem::take(&mut self.after_object); + if code == 9.0 + && let Some(look) = self + .frames + .last() + .and_then(|f| f.children.last()) + .filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id)) + { + self.looks.insert(look.id); + } + if code != 1.0 || style.is_empty() || !after { + return; + } + let space = self.space(); + let Some(f) = self.frames.last_mut() else { return }; + let n = f.children.len(); + let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { + return; + }; + // The look is a group of paths, or one image where an effect (a blur, a shadow) rasterizes it. + if !self.hidden_ids.contains(&object.id) + || self.hidden_ids.contains(&look.id) != hidden + || self.text_looks.contains(&look.id) + || !matches!(look.kind, NodeKind::Group { clip: false, .. } | NodeKind::Image(_)) + { + return; + } + // A style read here: the object, painted as it says. + if let Some(appearance) = self + .styles + .get(&style) + .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) + { + let Some(mut object) = f.children.pop() else { return }; + let Some(look) = f.children.pop() else { return }; + if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { + self.restyled.push(b); + } + object.appearance = appearance.into(); + // The object's transparency is written on its look, after the look's group ends. + (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); + // Now the object itself, not a copy an enclosing look's `strip` would remove. + self.hidden_ids.remove(&object.id); + f.children.push(object); + return; + } + let is_text = slot_of(object.name.as_deref()).is_some(); + let Some(mut object) = f.children.pop() else { return }; + let Some(look) = f.children.last_mut() else { return }; + // Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart. + if !hidden { + strip(look, &self.hidden_ids); + } + if is_text { + // A text object stays the type it is: over what else its look draws, or in place of a + // look that was only its type. Its transparency is written on its look. + let empty = look.children().is_some_and(|c| c.is_empty()); + if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) { + (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); + if empty { + *look = object; + } else { + f.children.push(object); + } + return; + } + // The look and the type go into a group that has the transparency. + let Some(mut look) = f.children.pop() else { return }; + let state = (look.opacity, look.blend, look.isolate, look.knockout); + (look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral); + (object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false); + let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]); + (group.opacity, group.blend, group.isolate, group.knockout) = state; + self.text_looks.insert(group.id); + if let Some(f) = self.frames.last_mut() { + f.children.push(group); + } + return; + } + look.name = object.name.clone().or(look.name.take()); + look.visible = object.visible; + look.locked = object.locked; + if !hidden { + self.drawn_looks += 1; + } + } +} + +/// Whether `look`'s paths don't all paint the same. +pub(super) fn paints_apart(look: &Node) -> bool { + fn leaves<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + match &n.kind { + NodeKind::Group { children, .. } => children.iter().for_each(|c| leaves(c, out)), + _ => out.push(n), + } + } + let mut paths = vec![]; + leaves(look, &mut paths); + paths.windows(2).any(|w| matches!(w, [a, b] if a.appearance != b.appearance)) +} + +pub(super) fn path_rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + } +} + +/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space +/// → the document (`space`). The tile's lower left is the pattern's origin wherever the +/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted. +fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option { + let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); + (xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) +} + +/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. +struct StylePaints<'a> { + gradients: &'a HashMap, + patterns: &'a HashMap, + space: Affine, +} + +/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name +/// (the first of each; a pattern's name after `PATTERN_KEY`). +fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for item in &look.appearance.items { + let key = match item.paint() { + Paint::Gradient(g) => g.swatch.clone(), + Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")), + _ => None, + }; + if let Some(key) = key { + out.entry(key).or_insert_with(|| item.paint().clone()); + } + } + for c in look.children().into_iter().flatten() { + placed_paints(c, out, depth + 1); + } +} + +/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. +const PATTERN_KEY: &str = "\u{1}pattern "; + +/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one +/// this reads, or asks a group for fills or strokes. +fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, paints: &StylePaints) -> Option { + use crate::import::ai::style::StyleItem; + // A text object's slot (an empty group standing for it) takes the style as the type will. + let slot = slot_of(object.name.as_deref()).is_some(); + if !st.plain + || (!slot && !st.simple) + || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) + { + return None; + } + // A style with one gradient fill: the object's own gradient, placed as the object's fill is. + let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count(); + let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_))); + // A style's gradients are written in a space of the object's bounds that isn't documented; its + // drawn look has each where the app placed it. + let mut placed = HashMap::new(); + placed_paints(look, &mut placed, 0); + let mut items = vec![]; + for (n, item) in st.items.iter().enumerate() { + let mut layer = match item { + StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), + StyleItem::Gradient { name, angle } => { + let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { + // Other objects than type: the object's own, as they always were. + (_, 1, Some(own)) if !slot => own.paint.clone(), + (Some(p), ..) => p.clone(), + (None, 1, Some(own)) => own.paint.clone(), + _ => { + let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?; + let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone()); + // Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do). + g.angle = *angle; + Paint::Gradient(Box::new(g)) + } + }; + AppearanceItem::Fill(FillLayer::new(paint)) + } + StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), + None => { + let tile = paints.patterns.get(name).copied()?; + let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; + AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) + } + }, + StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => { + let mut layer = StrokeLayer::new(paint.clone(), *width); + (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); + AppearanceItem::Stroke(layer) + } + }; + if let Some(look) = st.looks.get(n) { + match &mut layer { + AppearanceItem::Fill(f) => { + (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + AppearanceItem::Stroke(s) => { + (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + } + } + items.push(layer); + } + let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) { + st.contents_index + } else { + object.appearance.contents_index + }; + Some(Appearance { items, effects: st.effects.clone(), contents_index }) +} + +/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). +fn strip(n: &mut Node, hidden: &HashSet) { + if let Some(children) = n.children_mut() { + children.retain(|c| !hidden.contains(&c.id)); + for c in children.iter_mut() { + strip(Arc::make_mut(c), hidden); + } + } +} diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs new file mode 100644 index 000000000..4c15b04fa --- /dev/null +++ b/crates/eps/src/import/ai/read/types.rs @@ -0,0 +1,255 @@ +use super::*; + +/// Deepest nesting of layers, groups and dictionaries. +pub(super) const MAX_DEPTH: usize = 256; + +/// Most operands on the stack. +pub(super) const MAX_STACK: usize = 1 << 20; + +/// Most objects read. +pub(super) const MAX_NODES: usize = 4_000_000; + +/// The most objects all the blends of one import may lay out to be checked (each twice) and made +/// live; past it, blends keep their drawn steps. +pub(super) const BLEND_BUDGET: usize = 50_000; + +/// Most points in one path. +pub(super) const MAX_POINTS: usize = 4_000_000; + +/// Largest image read (pixels). +pub(super) const MAX_PIXELS: u64 = 1 << 25; + +/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads +/// larger images (#1479). +pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; + +/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). +pub(super) const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; + +/// Largest side of the document and its artboards (points). +pub(super) const MAX_SIDE: f64 = 1e6; + +/// Art space (y up) → a pattern's space in the document's terms (y down). +pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + +/// Most patterns read (others can't fill art). +pub(super) const MAX_PATTERNS: usize = 4096; + +#[derive(Clone, Debug)] +pub(crate) struct GState { + pub(crate) fill: Paint, + pub(crate) stroke: Paint, + pub(crate) width: f64, + pub(crate) cap: LineCap, + pub(crate) join: LineJoin, + pub(crate) miter: f64, + pub(crate) dash: Option, + pub(super) rule: FillRule, + pub(super) opacity: f32, + pub(super) blend: BlendMode, + pub(super) isolate: bool, + pub(super) knockout: Knockout, + pub(super) knockout_shape: bool, + pub(crate) overprint_fill: bool, + pub(crate) overprint_stroke: bool, + pub(super) locked: bool, + pub(super) hidden: bool, +} + +impl Default for GState { + fn default() -> Self { + let black = Paint::solid(vectorcraft_color::Color::BLACK); + Self { + fill: black.clone(), + stroke: black, + width: 1.0, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 10.0, + dash: None, + rule: FillRule::NonZero, + opacity: 1.0, + blend: BlendMode::Normal, + isolate: false, + knockout: Knockout::Off, + knockout_shape: false, + overprint_fill: false, + overprint_stroke: false, + locked: false, + hidden: false, + } + } +} + +/// The blend modes by their number in `Xy`. +pub(super) const BLENDS: [BlendMode; 16] = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::ColorDodge, + BlendMode::ColorBurn, + BlendMode::Darken, + BlendMode::Lighten, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, +]; + +/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`). +pub(in super::super) fn blend_name(n: usize) -> String { + format!("{:?}", blend_mode(n)) +} + +/// Blend mode `n` of `Xy` (Normal when it is out of range). +pub(in super::super) fn blend_mode(n: usize) -> BlendMode { + BLENDS.get(n).copied().unwrap_or(BlendMode::Normal) +} + +#[derive(Debug, Default)] +pub(super) struct LayerAttrs { + pub(super) name: String, + pub(super) visible: bool, + pub(super) preview: bool, + pub(super) locked: bool, + pub(super) printable: bool, + pub(super) dim: Option, + pub(super) color: Option, +} + +#[derive(Debug)] +pub(super) enum Kind { + Layer(Box), + Group, + Compound, + Clip, + Mask, + Obj(Box), +} + +#[derive(Debug)] +pub(super) struct Frame { + pub(super) kind: Kind, + pub(super) children: Vec, + /// Opened on a `%_` line. + pub(super) hidden: bool, + /// [`Reader::after_object`] when it opened. + pub(super) after: bool, + /// The state when it opened (a container's lock and visibility; a dictionary's state to go + /// back to). + pub(super) gs: GState, + /// The operand stack's height when it opened. + pub(super) base: usize, + /// The clipping paths' indexes among the children. + pub(super) clips: Vec, + /// A compound member group explicitly declares its subpaths' winding with `D`. + pub(super) compound_members: bool, +} + +/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands +/// before its first operator. +#[derive(Debug, Default)] +pub(super) struct PatternHead { + pub(super) taken: bool, + pub(super) name: String, + /// The tile in the pattern's space (y up). + pub(super) tile: Option, +} + +/// An image's operands, from `XN` and before `XI`. +#[derive(Debug, Default)] +pub(super) struct Raster { + pub(super) space: Option, + /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to + /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). + pub(super) operands: Option>, + /// Those hex digits. + pub(super) hex: Vec, +} + +/// A placed file's operands, from `` ` `` and its preview, until `~`. +#[derive(Debug, Default)] +pub(super) struct Place { + /// The canvas, where the file's pixels are → art space. + pub(super) matrix: Option, + /// The file's box (`llx lly urx ury`): its size is the file's, in pixels. + pub(super) bounds: Option, + pub(super) path: String, + /// The preview as a PNG, and its size in pixels. + pub(super) preview: Option<(Vec, u32, u32)>, +} + +/// What the open result says of placed files: they show the preview the file keeps of them. +pub(super) const LINKED_PREVIEWS: &str = + "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel"; + +/// Operators that change nothing the reader keeps. +pub(super) const IGNORED: &[&str] = &[ + "Ae", + "AE", + "Ap", + "As", + "Xd", + "Xr", + "XG", + // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. + "XC", + "Xh", + "XH", + "XF", + "Bc", + "XP", + "Np", + // Flatness (`i`): how finely curves print. + "i", + // Path resolution (`Ar`, §12): how finely curves print. + "Ar", + "TZ", + "Xs", + "Xt", + // A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path. + "XD", + "Xi", + "XI", + "`", + "LB2", + "Lc", + "Xv", + "XV", + "Xq", + "XQ", + "Xg", + "Xn", + "Bn", + "gsave", + "grestore", + "showpage", + "annotatepage", +]; + +/// Sections skipped whole, by their markers without the `AI_` prefix: begin → end. +pub(super) const SKIPPED: &[(&str, &str)] = &[ + ("BeginPattern", "EndPattern"), + ("BeginBrushPattern", "EndBrushPattern"), + ("BeginSVGFilter", "EndSVGFilter"), + ("BeginSymbol", "EndSymbol"), + ("BeginPluginObject", "EndPluginObject"), + ("BeginArtStyles", "EndArtStyles"), + ("BeginArtStyleList", "EndArtStyleList"), + ("BeginSymbolList", "EndSymbolList"), + ("BeginTextDocument", "EndTextDocument"), + ("BeginEncoding", "EndEncoding"), + ("Alternate_Content", "End_Versioned_Content"), +]; + +/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor +/// shows but doesn't edit) whose PDF can't be read. +pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out"; + +/// The name of the group a `/ForeignObject`'s art comes in as. +pub const NON_NATIVE_ART: &str = "Non-native art"; diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 389b0657e..57d7842f2 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -656,333 +656,4 @@ fn style(filters: &[Filter]) -> ArtStyle { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_variable_width_stroke_keeps_its_profile() { - let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - 2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ - /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ - /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ - /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"; - let styles = art_styles(data); - let style = &styles["Tapered"]; - assert!(style.plain); - let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) }; - assert_eq!(*width, 30.0); - assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); - } - - #[test] - fn dashes_aligned_to_corners_keep_the_drawn_look() { - // GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't. - let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ - /Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\ - /StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n"; - assert!(!art_styles(data)["Stitches"].plain); - } - - #[test] - fn a_drop_shadow_offset_down_points_down() { - // GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's - // default shadow (7 pt right, 7 pt down) upwards. - let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(data); - let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) }; - assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0))); - } - - /// The style `Shade`: a Drop Shadow whose dictionary holds `keys`. - fn shadow_style(keys: &str) -> ArtStyle { - let data = format!( - "/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" - ); - art_styles(data.as_bytes()).remove("Shade").unwrap_or_default() - } - - #[test] - fn a_drop_shadow_with_darkness_reads_its_darkness() { - // GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look. - let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n"); - assert!(style.plain, "{style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{style:?}") }; - let p = &shadow.params; - assert_eq!( - (shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]), - ("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0)) - ); - } - - #[test] - fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() { - for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] { - let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}")); - assert!(style.plain, "{dark:?}: {style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") }; - assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}"); - } - } - - #[test] - fn a_drop_shadow_with_color_keeps_its_params() { - // GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`. - for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] { - let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}")); - assert!(style.plain, "{csrc:?}: {style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") }; - let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK}); - assert_eq!(shadow.params, want, "{csrc:?}"); - } - } - - #[test] - fn a_feather_reads_its_radius_from_rad() { - let data = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n12 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(data); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(12.0))); - } - - #[test] - fn a_feather_falls_back_to_radius_and_defaults_to_five_points() { - let with_name = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (radius) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(with_name); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(3.0)); - - let bare = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(bare); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); - } - - #[test] - fn art_styles_clear_operands_beyond_the_cap() { - let mut data = Vec::from( - b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n", - ); - for _ in 0..80 { - data.extend_from_slice(b"1 "); - } - data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"); - let styles = art_styles(&data); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); - } - - #[test] - fn art_styles_ignore_filters_beyond_the_cap() { - const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; - let mut data = - Vec::from(b"/KnownStyle :\n(Capped) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n"); - for _ in 0..MAX_FILTERS + 16 { - data.extend_from_slice(UNIT); - } - data.extend_from_slice(b";\n"); - let style = art_styles(&data).remove("Capped").unwrap_or_default(); - assert!(style.effects.len() <= MAX_FILTERS); - } - - #[test] - fn hostile_dash_offsets_and_width_profiles_are_tamed() { - let mut f = Filter::default(); - paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]); - assert_eq!(f.dash.as_ref().map(|d| d.offset), Some(0.0)); - // Positions outside 0..=1 and out of order, a negative width, a non-finite one. - let arrays = HashMap::from([ - ("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]), - ("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]), - ]); - assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)])); - let ok = (0..64).map(|_| V::Num(2.0)).collect::>(); - paint_op(&mut f, "d", &[V::Arr(ok), V::Num(0.0)]); - assert_eq!(f.dash.as_ref().map(|d| d.pattern.len()), Some(64)); - let huge = (0..65).map(|_| V::Num(1.0)).collect::>(); - paint_op(&mut f, "d", &[V::Arr(huge), V::Num(0.0)]); - assert_eq!(f.dash, None); - let long = (0..1025).map(|i| i as f64 / 1024.0).collect::>(); - let wide = vec![1.0; 1025]; - let huge = HashMap::from([("BSVariableWidthTValuesArray".to_string(), long), ("BSCenteredWidthsArray".to_string(), wide)]); - assert_eq!(width_profile(&huge), None); - } - - #[test] - fn hostile_stroke_widths_are_sanitized() { - let mut f = Filter::default(); - paint_op(&mut f, "w", &[V::Num(12.0)]); - assert_eq!(f.width, 12.0); - paint_op(&mut f, "w", &[V::Num(-1.0)]); - assert_eq!(f.width, 12.0); - paint_op(&mut f, "w", &[V::Num(f64::INFINITY)]); - assert_eq!(f.width, 12.0); - } - - #[test] - fn hostile_stroke_paint_flags_ignore_short_operands() { - let mut f = Filter { paints: (true, true), ..Filter::default() }; - paint_op(&mut f, "Xd", &[V::Num(1.0)]); - assert_eq!(f.paints, (true, true)); - paint_op(&mut f, "Xd", &[V::Num(0.0), V::Num(1.0)]); - assert_eq!(f.paints, (false, true)); - paint_op(&mut f, "Xd", &[V::Num(2.0), V::Num(0.0)]); - assert_eq!(f.paints, (true, false)); - } - - #[test] - fn hostile_stroke_miter_limits_are_capped() { - let mut f = Filter::default(); - paint_op(&mut f, "M", &[V::Num(900.0)]); - assert_eq!(f.miter, 500.0); - paint_op(&mut f, "M", &[V::Num(0.5)]); - assert_eq!(f.miter, 500.0); - } - - #[test] - fn hostile_stroke_caps_and_joins_ignore_invalid_values() { - let mut f = Filter::default(); - paint_op(&mut f, "J", &[V::Num(99.0)]); - assert_eq!(f.cap, LineCap::Butt); - paint_op(&mut f, "J", &[V::Num(2.0)]); - assert_eq!(f.cap, LineCap::Square); - paint_op(&mut f, "j", &[V::Num(99.0)]); - assert_eq!(f.join, LineJoin::Miter); - paint_op(&mut f, "j", &[V::Num(1.0)]); - assert_eq!(f.join, LineJoin::Round); - } - - #[test] - fn hostile_blend_opacities_are_clamped() { - let mut f = Filter::default(); - paint_op(&mut f, "Xy", &[V::Num(3.0), V::Num(1.5)]); - assert_eq!(f.xy, Some((3, 1.0))); - paint_op(&mut f, "Xy", &[V::Num(-1.0), V::Num(0.25)]); - assert_eq!(f.xy, Some((0, 0.25))); - paint_op(&mut f, "Xy", &[V::Num(0.0), V::Num(f64::NAN)]); - assert_eq!(f.xy, Some((0, 0.25))); - } - - /// A basic filter `name` whose dictionary holds `body`. - fn basic(name: &str, body: &str) -> String { - format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") - } - - /// A compound filter `name` holding `kids`. - fn compound(name: &str, kids: &[String]) -> String { - format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()) - } - - fn fill(paint: &str) -> String { - basic( - "Fill Style Filter", - &format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"), - ) - } - - fn blend(mode: u8, opacity: f64) -> String { - basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n")) - } - - /// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim - /// repeated by Transform, and the characters at the bottom. - fn lettering() -> String { - let stack = compound( - "Stack Style Filter", - &[ - basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), - compound( - "Chain Style Filter", - &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)], - ), - fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"), - compound( - "Chain Style Filter", - &[ - basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"), - fill("0 0 0 0 1 1 1 Xa"), - basic( - "Adobe Transform", - "6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n", - ), - ], - ), - basic("Conduit Filter", ""), - ], - ); - let root = compound( - "Chain Style Filter", - &[ - basic( - "Adobe Deform", - "0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n", - ), - stack, - blend(0, 1.0), - ], - ); - format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") - } - - #[test] - fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { - // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type - // with it came in as its drawn look (outlines) instead of type. - let styles = art_styles(lettering().as_bytes()); - let st = &styles["Lettering"]; - assert!(st.plain, "{st:?}"); - // Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing. - let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else { - panic!("{:?}", st.items) - }; - assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}"); - assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots")); - let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::>(); - assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]); - assert!(ids(&st.looks[1]).is_empty()); - assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]); - assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48)); - let transform = &st.looks[0].effects[1].params; - assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25))); - assert_eq!(st.looks[0].effects[0].params["joins"], json!("round")); - // The warp is the whole object's; the characters paint below every item. - let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; - assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); - assert_eq!(st.contents_index, None); - } - - #[test] - fn contents_above_some_items_count_them() { - let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]); - let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack])); - let st = &art_styles(data.as_bytes())["Over"]; - assert!(st.plain, "{st:?}"); - assert_eq!((st.items.len(), st.contents_index), (2, Some(1))); - } - - #[test] - fn an_effect_not_read_here_keeps_the_drawn_look() { - let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); - let data = - format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); - assert!(!art_styles(data.as_bytes())["Odd"].plain); - } - - #[test] - fn a_hidden_fill_comes_in_hidden() { - let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); - let data = format!( - "/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}", - compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])]) - ); - let st = &art_styles(data.as_bytes())["Hidden"]; - assert!(st.plain, "{st:?}"); - assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); - } -} +mod tests; diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs new file mode 100644 index 000000000..964b74f10 --- /dev/null +++ b/crates/eps/src/import/ai/style/tests.rs @@ -0,0 +1,328 @@ +use super::*; + +#[test] +fn a_variable_width_stroke_keeps_its_profile() { + let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + 2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"; + let styles = art_styles(data); + let style = &styles["Tapered"]; + assert!(style.plain); + let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) }; + assert_eq!(*width, 30.0); + assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); +} + +#[test] +fn dashes_aligned_to_corners_keep_the_drawn_look() { + // GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't. + let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\ + /StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + assert!(!art_styles(data)["Stitches"].plain); +} + +#[test] +fn a_drop_shadow_offset_down_points_down() { + // GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's + // default shadow (7 pt right, 7 pt down) upwards. + let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(data); + let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) }; + assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0))); +} + +/// The style `Shade`: a Drop Shadow whose dictionary holds `keys`. +fn shadow_style(keys: &str) -> ArtStyle { + let data = format!( + "/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" + ); + art_styles(data.as_bytes()).remove("Shade").unwrap_or_default() +} + +#[test] +fn a_drop_shadow_with_darkness_reads_its_darkness() { + // GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look. + let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n"); + assert!(style.plain, "{style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{style:?}") }; + let p = &shadow.params; + assert_eq!( + (shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]), + ("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0)) + ); +} + +#[test] +fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() { + for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] { + let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}")); + assert!(style.plain, "{dark:?}: {style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") }; + assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}"); + } +} + +#[test] +fn a_drop_shadow_with_color_keeps_its_params() { + // GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`. + for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] { + let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}")); + assert!(style.plain, "{csrc:?}: {style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") }; + let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK}); + assert_eq!(shadow.params, want, "{csrc:?}"); + } +} + +#[test] +fn a_feather_reads_its_radius_from_rad() { + let data = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n12 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(data); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(12.0))); +} + +#[test] +fn a_feather_falls_back_to_radius_and_defaults_to_five_points() { + let with_name = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (radius) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(with_name); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(3.0)); + + let bare = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(bare); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); +} + +#[test] +fn art_styles_clear_operands_beyond_the_cap() { + let mut data = Vec::from( + b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n", + ); + for _ in 0..80 { + data.extend_from_slice(b"1 "); + } + data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"); + let styles = art_styles(&data); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); +} + +#[test] +fn art_styles_ignore_filters_beyond_the_cap() { + const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; + let mut data = + Vec::from(b"/KnownStyle :\n(Capped) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n"); + for _ in 0..MAX_FILTERS + 16 { + data.extend_from_slice(UNIT); + } + data.extend_from_slice(b";\n"); + let style = art_styles(&data).remove("Capped").unwrap_or_default(); + assert!(style.effects.len() <= MAX_FILTERS); +} + +#[test] +fn hostile_dash_offsets_and_width_profiles_are_tamed() { + let mut f = Filter::default(); + paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]); + assert_eq!(f.dash.as_ref().map(|d| d.offset), Some(0.0)); + // Positions outside 0..=1 and out of order, a negative width, a non-finite one. + let arrays = HashMap::from([ + ("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]), + ("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]), + ]); + assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)])); + let ok = (0..64).map(|_| V::Num(2.0)).collect::>(); + paint_op(&mut f, "d", &[V::Arr(ok), V::Num(0.0)]); + assert_eq!(f.dash.as_ref().map(|d| d.pattern.len()), Some(64)); + let huge = (0..65).map(|_| V::Num(1.0)).collect::>(); + paint_op(&mut f, "d", &[V::Arr(huge), V::Num(0.0)]); + assert_eq!(f.dash, None); + let long = (0..1025).map(|i| i as f64 / 1024.0).collect::>(); + let wide = vec![1.0; 1025]; + let huge = HashMap::from([("BSVariableWidthTValuesArray".to_string(), long), ("BSCenteredWidthsArray".to_string(), wide)]); + assert_eq!(width_profile(&huge), None); +} + +#[test] +fn hostile_stroke_widths_are_sanitized() { + let mut f = Filter::default(); + paint_op(&mut f, "w", &[V::Num(12.0)]); + assert_eq!(f.width, 12.0); + paint_op(&mut f, "w", &[V::Num(-1.0)]); + assert_eq!(f.width, 12.0); + paint_op(&mut f, "w", &[V::Num(f64::INFINITY)]); + assert_eq!(f.width, 12.0); +} + +#[test] +fn hostile_stroke_paint_flags_ignore_short_operands() { + let mut f = Filter { paints: (true, true), ..Filter::default() }; + paint_op(&mut f, "Xd", &[V::Num(1.0)]); + assert_eq!(f.paints, (true, true)); + paint_op(&mut f, "Xd", &[V::Num(0.0), V::Num(1.0)]); + assert_eq!(f.paints, (false, true)); + paint_op(&mut f, "Xd", &[V::Num(2.0), V::Num(0.0)]); + assert_eq!(f.paints, (true, false)); +} + +#[test] +fn hostile_stroke_miter_limits_are_capped() { + let mut f = Filter::default(); + paint_op(&mut f, "M", &[V::Num(900.0)]); + assert_eq!(f.miter, 500.0); + paint_op(&mut f, "M", &[V::Num(0.5)]); + assert_eq!(f.miter, 500.0); +} + +#[test] +fn hostile_stroke_caps_and_joins_ignore_invalid_values() { + let mut f = Filter::default(); + paint_op(&mut f, "J", &[V::Num(99.0)]); + assert_eq!(f.cap, LineCap::Butt); + paint_op(&mut f, "J", &[V::Num(2.0)]); + assert_eq!(f.cap, LineCap::Square); + paint_op(&mut f, "j", &[V::Num(99.0)]); + assert_eq!(f.join, LineJoin::Miter); + paint_op(&mut f, "j", &[V::Num(1.0)]); + assert_eq!(f.join, LineJoin::Round); +} + +#[test] +fn hostile_blend_opacities_are_clamped() { + let mut f = Filter::default(); + paint_op(&mut f, "Xy", &[V::Num(3.0), V::Num(1.5)]); + assert_eq!(f.xy, Some((3, 1.0))); + paint_op(&mut f, "Xy", &[V::Num(-1.0), V::Num(0.25)]); + assert_eq!(f.xy, Some((0, 0.25))); + paint_op(&mut f, "Xy", &[V::Num(0.0), V::Num(f64::NAN)]); + assert_eq!(f.xy, Some((0, 0.25))); +} + +/// A basic filter `name` whose dictionary holds `body`. +fn basic(name: &str, body: &str) -> String { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") +} + +/// A compound filter `name` holding `kids`. +fn compound(name: &str, kids: &[String]) -> String { + format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()) +} + +fn fill(paint: &str) -> String { + basic( + "Fill Style Filter", + &format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"), + ) +} + +fn blend(mode: u8, opacity: f64) -> String { + basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n")) +} + +/// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim +/// repeated by Transform, and the characters at the bottom. +fn lettering() -> String { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + compound( + "Chain Style Filter", + &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)], + ), + fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"), + compound( + "Chain Style Filter", + &[ + basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + basic( + "Adobe Transform", + "6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n", + ), + ], + ), + basic("Conduit Filter", ""), + ], + ); + let root = compound( + "Chain Style Filter", + &[ + basic( + "Adobe Deform", + "0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n", + ), + stack, + blend(0, 1.0), + ], + ); + format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") +} + +#[test] +fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { + // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type + // with it came in as its drawn look (outlines) instead of type. + let styles = art_styles(lettering().as_bytes()); + let st = &styles["Lettering"]; + assert!(st.plain, "{st:?}"); + // Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing. + let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else { + panic!("{:?}", st.items) + }; + assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}"); + assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots")); + let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::>(); + assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]); + assert!(ids(&st.looks[1]).is_empty()); + assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]); + assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48)); + let transform = &st.looks[0].effects[1].params; + assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25))); + assert_eq!(st.looks[0].effects[0].params["joins"], json!("round")); + // The warp is the whole object's; the characters paint below every item. + let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + assert_eq!(st.contents_index, None); +} + +#[test] +fn contents_above_some_items_count_them() { + let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]); + let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack])); + let st = &art_styles(data.as_bytes())["Over"]; + assert!(st.plain, "{st:?}"); + assert_eq!((st.items.len(), st.contents_index), (2, Some(1))); +} + +#[test] +fn an_effect_not_read_here_keeps_the_drawn_look() { + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); + let data = + format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + assert!(!art_styles(data.as_bytes())["Odd"].plain); +} + +#[test] +fn a_hidden_fill_comes_in_hidden() { + let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); + let data = format!( + "/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}", + compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])]) + ); + let st = &art_styles(data.as_bytes())["Hidden"]; + assert!(st.plain, "{st:?}"); + assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); +} diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index 4db791972..53848af89 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -17,1336 +17,8 @@ use super::lex::{Lexer, find, hex_decode}; use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; use crate::ps; -/// Most bytes one filter or image reads. -const MAX_DATA: usize = 1 << 28; -/// Most pixels an image may have. -const MAX_PIXELS: usize = 1 << 26; -/// Why an image is missing. -const TOO_LARGE: &str = "images too large to read were left out"; -/// Most distinct colours an image converts through a tint transform. -const MAX_TINTS: usize = 1 << 16; +mod filters; +mod ops; +mod pixels; -/// A file object: the program itself, or data a filter decoded. -#[derive(Debug)] -pub(crate) enum Stream { - Current, - /// Filters over the program's own data, decoded when first read. - Pending(Vec), - Buf { - data: Vec, - pos: usize, - /// The samples are 8-bit gray, RGB or CMYK (1, 3 or 4 channels) whatever the image's - /// color space: a decoded JPEG. - rgb: Option, - }, -} - -fn io(at: &str) -> Res { - ps_err("ioerror", at) -} - -/// The bytes `RunLengthDecode` makes of `raw`, and how many it read. -fn run_length(raw: &[u8]) -> Res<(Vec, usize)> { - let mut out = vec![]; - let mut i = 0; - while let Some(&n) = raw.get(i) { - i += 1; - match n { - 128 => break, - 0..=127 => { - let len = usize::from(n) + 1; - out.extend_from_slice(raw.get(i..i + len).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?); - i += len; - } - _ => { - let b = *raw.get(i).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?; - i += 1; - out.extend(std::iter::repeat_n(b, 257 - usize::from(n))); - } - } - if out.len() > MAX_DATA { - return Err(PsError::Limit("decoded data is too large")); - } - } - Ok((out, i)) -} - -/// zlib data inflated, and how many bytes of `raw` it took. -fn flate(raw: &[u8]) -> Res<(Vec, usize)> { - let mut z = flate2::Decompress::new(true); - let mut out = Vec::with_capacity(raw.len().saturating_mul(3).min(MAX_DATA)); - loop { - let before = (z.total_in(), z.total_out()); - if out.capacity() - out.len() < 64 * 1024 { - out.reserve(256 * 1024); - } - let input = raw.get(z.total_in() as usize..).unwrap_or_default(); - let status = z.decompress_vec(input, &mut out, flate2::FlushDecompress::None).map_err(|_| PsError::Ps("ioerror", "FlateDecode".into()))?; - if out.len() > MAX_DATA { - return Err(PsError::Limit("decoded data is too large")); - } - match status { - flate2::Status::StreamEnd => break, - _ if (z.total_in(), z.total_out()) == before => break, - _ => {} - } - } - Ok((out, z.total_in() as usize)) -} - -/// LZW data (`EarlyChange` 1) decoded, and how many bytes of `raw` it took. -fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { - let mut table: Vec> = (0..=255u8).map(|b| vec![b]).collect(); - table.push(vec![]); - table.push(vec![]); - let (mut out, mut prev): (Vec, Option>) = (vec![], None); - let (mut acc, mut bits, mut width, mut i) = (0u32, 0u32, 9u32, 0usize); - loop { - while bits < width { - let Some(&b) = raw.get(i) else { return Ok((out, i)) }; - i += 1; - acc = (acc << 8) | u32::from(b); - bits += 8; - } - let code = ((acc >> (bits - width)) & ((1 << width) - 1)) as usize; - bits -= width; - match code { - 256 => { - table.truncate(258); - width = 9; - prev = None; - } - 257 => return Ok((out, i)), - _ => { - let entry = match (table.get(code), &prev) { - (Some(e), _) if !e.is_empty() => e.clone(), - (_, Some(p)) if code == table.len() => { - let mut e = p.clone(); - e.extend(p.first()); - e - } - _ => return io("LZWDecode"), - }; - out.extend_from_slice(&entry); - if let Some(mut p) = prev.take() - && table.len() < 4096 - { - p.extend(entry.first()); - table.push(p); - } - width = match table.len() + 1 { - n if n >= 2048 => 12, - n if n >= 1024 => 11, - n if n >= 512 => 10, - _ => 9, - }; - prev = Some(entry); - if out.len() > MAX_DATA { - return Err(PsError::Limit("decoded data is too large")); - } - } - } - } -} - -/// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of -/// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read -/// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). -fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { - let end = find(raw, &[0xFF, 0xD9]).map_or(raw.len(), |e| e + 2); - let jpeg = raw.get(..end).unwrap_or_default(); - // `cmyk` gives the ink amounts of inverted samples: inverted back, they are the samples. - if jpeg_components(jpeg) == Some(4) - && let Some(inks) = ImageBlob::new("image/jpeg", jpeg.to_vec()).cmyk() - { - return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); - } - let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; - if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } -} - -/// CCITT fax data (ITU-T T.4 Group 3, one- or two-dimensional, and T.6 Group 4) decoded to rows -/// of 1-bit samples, each padded to a whole byte, and how many bytes of `raw` it took. With -/// `EndOfBlock` the rows end at the end-of-block code (EOFB, or RTC in Group 3) regardless of -/// `Rows`; without it they end after `Rows` rows, or at the end of the data when `Rows` is 0. -/// Damaged data ends the rows: those decoded before it are kept, and damage in the first row is an -/// `ioerror`. Rows that would take more than `MAX_DATA` bytes are refused. -fn fax(raw: &[u8], settings: DecodeSettings) -> Res<(Vec, usize)> { - let row = (settings.columns as usize).div_ceil(8).max(1); - let most = u32::try_from(MAX_DATA / row).unwrap_or(u32::MAX); - if !settings.end_of_block && settings.rows > most { - return Err(PsError::Limit("decoded data is too large")); - } - // Without a row count that ends the data, a row past `most` shows that the data is too large. - let rows = if settings.end_of_block || settings.rows == 0 { most.saturating_add(1) } else { settings.rows }; - // The decoder reads the RTC of Group 3 two-dimensional data as rows: it gets the data before it. - let rtc = match settings.encoding { - EncodingMode::Group3_2D { .. } if settings.end_of_block => rtc(raw), - _ => None, - }; - let data = rtc.and_then(|(cut, _)| raw.get(..cut)).unwrap_or(raw); - let mut out = FaxRows::default(); - let read = hayro_ccitt::decode(data, &mut out, &mut DecoderContext::new(DecodeSettings { rows, ..settings })); - if out.rows > most { - return Err(PsError::Limit("decoded data is too large")); - } - let used = match read { - Ok(used) => used, - Err(_) if out.rows > 0 => { - out.data.truncate(out.rows as usize * row); - // The whole rows decoded again, for how many bytes they took. - let used = hayro_ccitt::decode(data, &mut Dropped, &mut DecoderContext::new(DecodeSettings { rows: out.rows, ..settings })); - used.unwrap_or(data.len()) - } - Err(_) => return io("CCITTFaxDecode"), - }; - Ok((out.data, rtc.map_or(used, |(_, end)| end))) -} - -/// The RTC that ends Group 3 two-dimensional data (six EOLs, each followed by a 1 tag bit, ITU-T -/// T.4 §4.2.4): the byte that holds the 1 bit of its first EOL, and the byte after its last tag -/// bit. The first EOL with a 1 tag bit that another EOL follows starts it. -fn rtc(raw: &[u8]) -> Option<(usize, usize)> { - let bits = raw.len().saturating_mul(8); - let bit = |i: usize| raw.get(i / 8).is_some_and(|b| b & (0x80 >> (i % 8)) != 0); - let zeros = |i: usize| (i..bits).take_while(|j| !bit(*j)).count(); - // An EOL (its fill bits first) and a 1 tag bit from `i`: the EOL's 1 bit and where they end. - let eol = |i: usize| { - let one = i + zeros(i); - (one - i >= 11 && bit(one) && bit(one + 1)).then_some((one, one + 2)) - }; - let mut i = 0; - while i < bits { - if let Some((one, next)) = eol(i) - && let Some((_, mut end)) = eol(next) - { - for _ in 2..6 { - match eol(end) { - Some((_, e)) => end = e, - None => break, - } - } - return Some((one / 8, end.div_ceil(8))); - } - i += zeros(i) + 1; - } - None -} - -/// Rows of 1-bit samples from a CCITT decoder, each padded to a whole byte: 1 is white, or black -/// with `BlackIs1`. -#[derive(Default)] -struct FaxRows { - data: Vec, - /// Samples in the row being decoded. - x: u32, - /// Whole rows decoded. - rows: u32, -} - -impl Decoder for FaxRows { - fn push_pixel(&mut self, white: bool) { - if self.x.is_multiple_of(8) { - self.data.push(0); - } - if white && let Some(b) = self.data.last_mut() { - *b |= 0x80 >> (self.x % 8); - } - self.x = self.x.saturating_add(1); - } - - fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) { - self.data.extend(std::iter::repeat_n(if white { 0xff } else { 0 }, chunk_count as usize)); - self.x = self.x.saturating_add(chunk_count.saturating_mul(8)); - } - - fn next_line(&mut self) { - self.x = 0; - self.rows = self.rows.saturating_add(1); - } -} - -/// A CCITT decoder that drops what it decodes. -struct Dropped; - -impl Decoder for Dropped { - fn push_pixel(&mut self, _: bool) {} - - fn push_pixel_chunk(&mut self, _: bool, _: u32) {} - - fn next_line(&mut self) {} -} - -/// A decoding filter and its parameters. -#[derive(Clone, Debug)] -pub(crate) struct Filter { - name: Rc, - /// `SubFileDecode`'s count and end-of-data string. - sub: (usize, Vec), - /// `FlateDecode`'s and `LZWDecode`'s `Predictor`: what it undoes. - predictor: Option, - /// `CCITTFaxDecode`'s parameters. - fax: Option, -} - -/// A `Predictor` (2: TIFF, 10–15: PNG) with its `Colors`, `BitsPerComponent` and `Columns`. -#[derive(Clone, Copy, Debug)] -struct Predictor { - kind: u8, - colors: usize, - bpc: usize, - columns: usize, -} - -impl Predictor { - /// The predictor of a filter's parameters, if it has one. - fn of(d: &DictRef) -> Res> { - let num = |k: &str, default: f64| d.borrow().get(&Key::name(k)).and_then(Obj::as_num).unwrap_or(default); - let kind = num("Predictor", 1.0); - if kind <= 1.0 { - return Ok(None); - } - let (colors, bpc, columns) = (num("Colors", 1.0), num("BitsPerComponent", 8.0), num("Columns", 1.0)); - if !(kind == 2.0 || (10.0..=15.0).contains(&kind)) - || !(1.0..=32.0).contains(&colors) - || ![1.0, 2.0, 4.0, 8.0, 16.0].contains(&bpc) - || !(1.0..=1e6).contains(&columns) - { - return ps_err("rangecheck", "Predictor"); - } - Ok(Some(Self { kind: kind as u8, colors: colors as usize, bpc: bpc as usize, columns: columns as usize })) - } - - /// The data the predictor made `data` from. - fn undo(self, data: &[u8]) -> Vec { - // A row longer than the data is the data. - let row = (self.colors * self.bpc * self.columns).div_ceil(8).min(data.len().max(1)); - // Bytes per pixel (at least one) is how far back the left neighbour is. - let bpp = (self.colors * self.bpc).div_ceil(8).max(1); - if self.kind == 2 { - // Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its - // difference from the same component of the pixel to its left, wrapping within its bit - // width. Each row starts again, and the padding bits at a row's end stay as they are. - let mut out = data.to_vec(); - for line in out.chunks_mut(row.max(1)) { - match self.bpc { - 8 => { - for i in bpp..line.len() { - let left = line.get(i - bpp).copied().unwrap_or(0); - if let Some(b) = line.get_mut(i) { - *b = b.wrapping_add(left); - } - } - } - // 16-bit components are big-endian and add as 16-bit numbers. - 16 => { - let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0); - for i in (bpp..line.len().saturating_sub(1)).step_by(2) { - let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]); - let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left); - if let Some(w) = line.get_mut(i..i + 2) { - w.copy_from_slice(&sum.to_be_bytes()); - } - } - } - // 1, 2 or 4 bits: components are packed from each byte's high bit. - bits @ (1 | 2 | 4) => { - let mask = (1u8 << bits) - 1; - let shift = |k: usize| 8 - bits - k * bits % 8; - let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask); - let samples = (line.len() * 8 / bits).min(self.colors * self.columns); - for k in self.colors..samples { - let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask; - if let Some(b) = line.get_mut(k * bits / 8) { - *b = (*b & !(mask << shift(k))) | (sum << shift(k)); - } - } - } - // `Predictor::of` accepts no other depth. - _ => {} - } - } - return out; - } - // PNG: each row starts with its filter type. - let mut out: Vec = Vec::with_capacity(data.len()); - let mut prev = vec![0u8; row]; - for chunk in data.chunks(row + 1) { - let Some((&kind, line)) = chunk.split_first() else { break }; - let mut cur = line.to_vec(); - cur.resize(row, 0); - for i in 0..row { - let a = if i >= bpp { cur.get(i - bpp).copied().unwrap_or(0) } else { 0 }; - let b = prev.get(i).copied().unwrap_or(0); - let c = if i >= bpp { prev.get(i - bpp).copied().unwrap_or(0) } else { 0 }; - let add = match kind { - 1 => a, - 2 => b, - 3 => ((u16::from(a) + u16::from(b)) / 2) as u8, - 4 => paeth(a, b, c), - _ => 0, - }; - if let Some(v) = cur.get_mut(i) { - *v = v.wrapping_add(add); - } - } - out.extend_from_slice(cur.get(..line.len().min(row)).unwrap_or_default()); - prev = cur; - } - out - } -} - -/// The PNG Paeth predictor. -fn paeth(a: u8, b: u8, c: u8) -> u8 { - let (ia, ib, ic) = (i16::from(a), i16::from(b), i16::from(c)); - let p = ia + ib - ic; - let (pa, pb, pc) = ((p - ia).abs(), (p - ib).abs(), (p - ic).abs()); - if pa <= pb && pa <= pc { - a - } else if pb <= pc { - b - } else { - c - } -} - -/// `CCITTFaxDecode`'s parameters (PLRM 3rd ed., §3.13, `CCITTFaxDecode` parameters; ISO 32000-2, -/// §7.4.6): `K` (below 0 Group 4, 0 Group 3 one-dimensional, above 0 Group 3 mixed), `Columns`, -/// `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`, with their defaults where -/// `d` leaves them out. `Columns` outside 1 to 65,535 and `Rows` below 0 are a `rangecheck`. -fn fax_settings(d: Option<&DictRef>) -> Res { - let get = |k: &str| d.and_then(|d| d.borrow().get(&Key::name(k)).cloned()); - let num = |k: &str, default: f64| get(k).and_then(|o| o.as_num()).unwrap_or(default); - let flag = |k: &str, default: bool| match get(k) { - Some(Obj::Bool(b)) => b, - _ => default, - }; - let (k, columns, rows) = (num("K", 0.0), num("Columns", 1728.0), num("Rows", 0.0)); - if !k.is_finite() || !(1.0..=65_535.0).contains(&columns) || !(0.0..=f64::from(u32::MAX)).contains(&rows) { - return ps_err("rangecheck", "CCITTFaxDecode"); - } - let encoding = if k < 0.0 { - EncodingMode::Group4 - } else if k == 0.0 { - EncodingMode::Group3_1D - } else { - EncodingMode::Group3_2D { k: k.min(f64::from(u32::MAX)) as u32 } - }; - Ok(DecodeSettings { - columns: columns as u32, - rows: rows as u32, - end_of_block: flag("EndOfBlock", true), - end_of_line: flag("EndOfLine", false), - rows_are_byte_aligned: flag("EncodedByteAlign", false), - encoding, - invert_black: flag("BlackIs1", false), - }) -} - -/// The bytes `f` decodes from `raw`, how many it read, and the channels of a decoded JPEG. -fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec, usize, Option)> { - let (data, used) = match &*f.name { - "ASCIIHexDecode" => { - let end = raw.iter().position(|b| *b == b'>'); - let body = raw.get(..end.unwrap_or(raw.len())).unwrap_or_default(); - (hex_decode(body).ok_or(PsError::Ps("ioerror", "ASCIIHexDecode".into()))?, end.map_or(raw.len(), |e| e + 1)) - } - "ASCII85Decode" => { - let end = find(raw, b"~>"); - let mut text = String::from_utf8_lossy(raw.get(..end.unwrap_or(raw.len())).unwrap_or_default()).into_owned(); - text.push_str("~>"); - (ps::ascii85_decode(&text).ok_or(PsError::Ps("ioerror", "ASCII85Decode".into()))?, end.map_or(raw.len(), |e| e + 2)) - } - "RunLengthDecode" => run_length(raw)?, - "FlateDecode" | "LZWDecode" => { - let (data, used) = if &*f.name == "FlateDecode" { flate(raw)? } else { lzw(raw)? }; - match f.predictor { - Some(p) => (p.undo(&data), used), - None => (data, used), - } - } - "DCTDecode" => { - let (d, used, channels) = dct(raw)?; - return Ok((d, used, Some(channels))); - } - "CCITTFaxDecode" => match f.fax { - Some(settings) => fax(raw, settings)?, - None => return io("CCITTFaxDecode"), - }, - "SubFileDecode" => { - let (count, eod) = &f.sub; - if eod.is_empty() { - // A count of 0 without an end string sets no end: the data passes through whole. - let n = if *count == 0 { raw.len() } else { (*count).min(raw.len()) }; - (raw.get(..n).unwrap_or_default().to_vec(), n) - } else { - let (mut at, mut seen) = (0, 0); - loop { - match find(raw.get(at..).unwrap_or_default(), eod) { - Some(i) if seen < *count => { - seen += 1; - at += i + eod.len(); - } - Some(i) => break (raw.get(..at + i).unwrap_or_default().to_vec(), at + i + eod.len()), - None => break (raw.to_vec(), raw.len()), - } - } - } - } - // Reads all of its source (up to the end of the filters below it) and keeps it. - "NullEncode" | "ReusableStreamDecode" => (raw.to_vec(), raw.len()), - other => return ps_err("undefined", other), - }; - Ok((data, used, None)) -} - -/// What a source of image data gives. -#[derive(Clone)] -enum Source { - File(Rc>), - Str(Vec), - Proc(Obj), -} - -impl Interp<'_> { - pub fn data_op(&mut self, op: Op) -> Res { - use Op::*; - match op { - CurrentFile => self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Current)), exec: false })?, - Filter => self.filter()?, - ReadHexString | ReadString => { - let s = self.pop_str()?; - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let n = s.len(); - let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? }; - self.fill(s, &got)?; - self.push(Obj::Bool(got.len() == n))?; - } - ReadLine => { - let s = self.pop_str()?; - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let n = s.len(); - let mut line = vec![]; - let mut ended = false; - while line.len() < n { - match self.read(&f, 1)?.first() { - Some(b'\n') | None => { - ended = true; - break; - } - Some(b'\r') => { - self.skip_lf(&f); - ended = true; - break; - } - Some(b) => line.push(*b), - } - } - self.fill(s, &line)?; - self.push(Obj::Bool(ended))?; - } - FlushFile => { - // An input file is read to its end: a filter over the program's data skips it - // (metadata read with `flushfile`). The program's own file goes on being run. - if let Obj::File { stream, .. } = self.pop()? { - self.materialize(&stream)?; - } - } - CloseFile => { - self.pop()?; - } - File => { - self.pop()?; - let name = self.pop()?.text().unwrap_or_else(|| Rc::from("")); - // The standard files take what programs print (and give nothing); there is no - // file system to open others in. - if !matches!(&*name, "%stdout" | "%stderr" | "%stdin" | "%lineedit" | "%statementedit") { - return ps_err("invalidfileaccess", "file"); - } - self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Buf { data: vec![], pos: 0, rgb: None })), exec: false })?; - } - Status => match self.pop()? { - Obj::File { .. } => self.push(Obj::Bool(true))?, - // A file name: there are no files. - Obj::Str(_) => self.push(Obj::Bool(false))?, - _ => return ps_err("typecheck", ""), - }, - BytesAvailable => { - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - self.materialize(&f)?; - let n = match &*f.borrow() { - Stream::Current => self.lex.rest().len(), - Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), - Stream::Pending(_) => 0, - }; - self.push(Obj::Int(n as i64))?; - } - ResetFile => { - // A decoded (reusable) stream reads again from its start. - if let Obj::File { stream: f, .. } = self.pop()? - && let Stream::Buf { pos, .. } = &mut *f.borrow_mut() - { - *pos = 0; - } - } - Write | WriteString | WriteHexString => { - self.pop()?; - self.pop()?; - } - Read => { - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - match self.read(&f, 1)?.first() { - Some(b) => { - self.push(Obj::Int(i64::from(*b)))?; - self.push(Obj::Bool(true))?; - } - None => self.push(Obj::Bool(false))?, - } - } - FilePosition => { - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let Stream::Buf { pos, .. } = &*f.borrow() else { return ps_err("ioerror", "") }; - self.push(Obj::Int(*pos as i64))?; - } - SetFilePosition => { - let at = self.pop_count()?; - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let Stream::Buf { data, pos, .. } = &mut *f.borrow_mut() else { return ps_err("ioerror", "") }; - *pos = at.min(data.len()); - } - // There are no files to delete, rename or run. - DeleteFile | Run => { - self.pop_str()?; - return ps_err("undefinedfilename", ""); - } - RenameFile => { - self.pop_str()?; - self.pop_str()?; - return ps_err("undefinedfilename", ""); - } - Token => self.token()?, - Eexec => self.eexec()?, - Image | ImageMask | ColorImage => self.image(op)?, - _ => self.text_op(op)?, - } - Ok(()) - } - - /// `token`: the next object read from a string (and the rest of it) or a file. - fn token(&mut self) -> Res { - match self.pop()? { - Obj::Str(s) => { - let bytes = s.to_vec(); - let mut lex = Lexer::new(&bytes); - let Some(o) = lex.next()? else { return self.push(Obj::Bool(false)) }; - let o = self.scanned(o, lex.immediate)?; - // The whitespace after the token is read with it. - let used = bytes.len() - lex.data().len(); - self.push_interval(Obj::Str(s), used, bytes.len() - used)?; - self.push(o)?; - } - Obj::File { stream: f, .. } => { - self.materialize(&f)?; - let (o, immediate) = match &mut *f.borrow_mut() { - Stream::Current => (self.lex.next()?, self.lex.immediate), - Stream::Buf { data, pos, .. } => { - let rest = data.get(*pos..).unwrap_or_default(); - let mut lex = Lexer::new(rest); - let o = lex.next()?; - *pos += rest.len() - lex.data().len(); - (o, lex.immediate) - } - Stream::Pending(_) => (None, false), - }; - let Some(o) = o else { return self.push(Obj::Bool(false)) }; - let o = self.scanned(o, immediate)?; - self.push(o)?; - } - _ => return ps_err("typecheck", "token"), - } - self.push(Obj::Bool(true)) - } - - /// Up to `n` bytes of file `f`. - fn read(&mut self, f: &Rc>, n: usize) -> Res> { - self.materialize(f)?; - Ok(match &mut *f.borrow_mut() { - Stream::Current => { - let rest = self.lex.data(); - let k = n.min(rest.len()); - let v = rest.get(..k).unwrap_or_default().to_vec(); - self.lex.advance(k); - v - } - Stream::Buf { data, pos, .. } => { - let start = (*pos).min(data.len()); - let end = start.saturating_add(n).min(data.len()); - *pos = end; - data.get(start..end).unwrap_or_default().to_vec() - } - Stream::Pending(_) => vec![], - }) - } - - /// After `readline` consumes CR, consume only an immediately following LF. PostScript - /// treats CR/LF as one line ending (PLRM §3.8); raw reads must keep every other byte. - /// The preceding `read` has already materialized any pending filter. - fn skip_lf(&mut self, f: &Rc>) { - match &mut *f.borrow_mut() { - Stream::Current => { - if self.lex.rest().first() == Some(&b'\n') { - self.lex.advance(1); - } - } - Stream::Buf { data, pos, .. } => { - if data.get(*pos) == Some(&b'\n') { - *pos += 1; - } - } - Stream::Pending(_) => {} - } - } - - /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). - fn read_hex(&mut self, f: &Rc>, n: usize) -> Res> { - let mut out = Vec::with_capacity(n); - let mut high: Option = None; - while out.len() < n { - let Some(&b) = self.read(f, 1)?.first() else { break }; - let Some(v) = char::from(b).to_digit(16) else { continue }; - match high.take() { - Some(h) => out.push(h << 4 | v as u8), - None => high = Some(v as u8), - } - } - Ok(out) - } - - /// Read bytes `got` into string `s` and push the part of `s` they fill. - fn fill(&mut self, s: Shared, got: &[u8]) -> Res { - if !s.write(0, got) { - return ps_err("rangecheck", ""); - } - self.push_interval(Obj::Str(s), 0, got.len()) - } - - /// Count `n` decoded bytes against the memory budget, and the time to decode them. - fn charge_decoded(&mut self, n: usize) -> Res { - self.alloc(n)?; - self.spend((n / 4096) as u64) - } - - /// `filter`: a file that decodes its source. Over the program's own data it decodes when - /// first read (the data follows the operator that reads it); over other data, at once. - fn filter(&mut self) -> Res { - let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?; - let sub = if &*name == "SubFileDecode" { - let eod = self.pop_str()?.to_vec(); - let count = self.pop_count()?; - (count, eod) - } else { - (0, vec![]) - }; - let (mut predictor, mut params) = (None, None); - if let Some(Obj::Dict(d)) = self.stack.last().cloned() { - self.pop()?; - predictor = Predictor::of(&d)?; - params = Some(d); - } - let fax = if &*name == "CCITTFaxDecode" { Some(fax_settings(params.as_ref())?) } else { None }; - let spec_name = name.clone(); - let spec = Filter { name, sub, predictor, fax }; - let stream = match self.pop()? { - Obj::File { stream: f, .. } => { - if let Stream::Buf { data, pos, .. } = &*f.borrow() { - self.alloc(data.len().saturating_sub(*pos))?; - } - let chain = match &*f.borrow() { - Stream::Current => Some(vec![]), - Stream::Pending(c) => Some(c.clone()), - Stream::Buf { .. } => None, - }; - match chain { - Some(mut c) => { - // The new filter reads a pending one's data: it has none left of its own - // (`flushfile` on it skips nothing more). - if !c.is_empty() { - *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; - } - c.push(spec); - Stream::Pending(c) - } - None => { - let mut b = f.borrow_mut(); - let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; - let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; - self.charge_decoded(out.len())?; - *pos = pos.saturating_add(used); - Stream::Buf { data: out, pos: 0, rgb } - } - } - } - Obj::Str(s) => { - self.alloc(s.borrow().len())?; - let (data, _, rgb) = decode(&spec, &s.borrow())?; - self.charge_decoded(data.len())?; - Stream::Buf { data, pos: 0, rgb } - } - p @ Obj::Array { .. } => { - let raw = self.gather(&p, MAX_DATA)?; - let (data, _, rgb) = decode(&spec, &raw)?; - self.charge_decoded(data.len())?; - Stream::Buf { data, pos: 0, rgb } - } - _ => return ps_err("typecheck", "filter"), - }; - let stream = Rc::new(RefCell::new(stream)); - // A reusable stream reads its source when it is made: the data follows the operator. - if &*spec_name == "ReusableStreamDecode" { - self.materialize(&stream)?; - } - self.push(Obj::File { stream, exec: false }) - } - - /// The data an executable file runs as a program: what is left of it. `None` for the - /// program's own file, which is being run already. - pub(super) fn program_of(&mut self, f: &Rc>) -> Res>> { - if matches!(&*f.borrow(), Stream::Current) { - return Ok(None); - } - self.read(f, MAX_DATA).map(Some) - } - - /// The decoded data left in file `f`, without reading it (a shading's or function's data). - pub(super) fn peek_all(&mut self, f: &Rc>) -> Res> { - self.materialize(f)?; - Ok(match &*f.borrow() { - Stream::Buf { data, pos, .. } => data.get(*pos..).unwrap_or_default().to_vec(), - _ => vec![], - }) - } - - /// Decode the filters a file waits to run over the program's data, from where it is now. - fn materialize(&mut self, f: &Rc>) -> Res { - let chain = match &*f.borrow() { - Stream::Pending(c) => c.clone(), - _ => return Ok(()), - }; - let mut buf: Option<(Vec, Option)> = None; - for spec in &chain { - buf = Some(match buf.take() { - None => { - let raw = self.lex.data(); - let (data, used, rgb) = decode(spec, raw)?; - self.charge_decoded(data.len())?; - self.lex.advance(used); - (data, rgb) - } - Some((prev, was)) => { - let (data, _, rgb) = decode(spec, &prev)?; - self.charge_decoded(data.len())?; - (data, rgb.or(was)) - } - }); - } - let (data, rgb) = buf.unwrap_or_default(); - *f.borrow_mut() = Stream::Buf { data, pos: 0, rgb }; - Ok(()) - } - - /// The bytes a data procedure gives, called until it gives an empty string or `n` bytes. - fn gather(&mut self, p: &Obj, n: usize) -> Res> { - Ok(self.read_sources(&[Source::Proc(p.clone())], n, n)?.pop().unwrap_or_default()) - } - - /// `n` bytes of image data from `src` (zeros past its end). - fn source_bytes(&mut self, src: &Source, n: usize) -> Res> { - let mut v = self.read_sources(std::slice::from_ref(src), n, n)?.pop().unwrap_or_default(); - v.resize(n, 0); - Ok(v) - } - - /// Up to `n` bytes from each of `sources`, read in turn as an image reads several data sources - /// (PLRM 3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8: the sources are read - /// in turn, the same amount from each): each procedure called once a round (its string, - /// whatever its length), each file and string giving `step` bytes a round, until each has - /// given `n` bytes or ended. Procedures that read one file get their own rows that way. - fn read_sources(&mut self, sources: &[Source], n: usize, step: usize) -> Res>> { - let mut out: Vec> = vec![vec![]; sources.len()]; - let mut ended = vec![false; sources.len()]; - while out.iter().zip(&ended).any(|(o, e)| o.len() < n && !e) { - for ((src, o), e) in sources.iter().zip(out.iter_mut()).zip(ended.iter_mut()) { - if *e || o.len() >= n { - continue; - } - let want = step.min(n - o.len()); - let chunk = match src { - Source::File(f) => self.read(f, want)?, - // A string gives its bytes over and over. - Source::Str(s) => { - let at = o.len() % s.len().max(1); - s.get(at..).unwrap_or_default().iter().chain(s.iter().cycle()).take(want).copied().collect() - } - Source::Proc(p) => { - self.call(p.clone())?; - let s = self.pop_str()?.to_vec(); - self.alloc(s.len())?; - s - } - }; - *e = chunk.is_empty(); - o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default()); - } - } - Ok(out) - } - - /// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's - /// fonts, by name): the font dictionary it would complete is defined as it is. - fn eexec(&mut self) -> Res { - self.pop()?; - if let Some(Obj::Dict(d)) = self.stack.last().cloned() { - let name = d.borrow().get(&Key::name("FontName")).cloned(); - if let Some(k) = name.and_then(|n| n.key()) { - self.pop()?; - self.fonts.borrow_mut().insert(k, Obj::Dict(d)); - } - } - let rest = self.lex.data(); - let at = find(rest, b"cleartomark").ok_or(PsError::Ps("ioerror", "eexec".into()))?; - self.lex.advance(at); - self.push(Obj::Mark) - } - - // ---------- images ---------- - - fn image(&mut self, op: Op) -> Res { - let mask = op == Op::ImageMask; - let img = match self.stack.last() { - Some(Obj::Dict(_)) if op != Op::ColorImage => { - let d = self.pop_dict()?; - let kind = d.borrow().get(&Key::name("ImageType")).and_then(Obj::as_num); - if kind == Some(3.0) && !mask { - self.masked_image(&d)? - } else { - let spec = self.image_dict(&d, mask)?; - self.decode_image(spec)? - } - } - _ => { - let spec = self.image_operands(op)?; - self.decode_image(spec)? - } - }; - let Some(img) = img else { return Ok(()) }; - self.place_image(img) - } - - /// ImageType 3: an image and the mask that says where it paints (where the mask's samples - /// read below one half through its `Decode`, as an image mask paints its 0 bits), in separate - /// sources (`InterleaveType` 3) or interleaved with the image's by row (2) or by sample (1). - fn masked_image(&mut self, d: &DictRef) -> Res> { - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let (Some(Obj::Dict(data)), Some(Obj::Dict(mask))) = (get("DataDict"), get("MaskDict")) else { - return ps_err("typecheck", "ImageType 3"); - }; - let interleave = get("InterleaveType").and_then(|o| o.as_num()).unwrap_or(1.0); - let mut image = self.image_dict(&data, false)?; - let mut stencil = self.image_dict(&mask, false)?; - stencil.space = Space::Gray; - let n = image.space.n(); - if image.w.saturating_mul(image.h) > MAX_PIXELS || stencil.w.saturating_mul(stencil.h) > MAX_PIXELS { - self.out.warn(TOO_LARGE); - return Ok(None); - } - if interleave != 3.0 { - let src = image.sources.first().cloned().ok_or(PsError::Ps("undefined", "DataSource".into()))?; - let (pixels, samples) = if interleave == 2.0 { - let (mrow, drow) = (stencil.row_bytes(1), image.row_bytes(n)); - let total = mrow.saturating_mul(stencil.h).saturating_add(drow.saturating_mul(image.h)); - self.alloc(total)?; - let raw = self.source_bytes(&src, total.min(MAX_DATA))?; - by_rows(&raw, (mrow, stencil.h), (drow, image.h)) - } else { - // Each sample: the mask's value, then the colour's, as many bits each. - stencil.bpc = image.bpc; - (stencil.w, stencil.h) = (image.w, image.h); - let row = (image.w * (n + 1) * usize::from(image.bpc)).div_ceil(8); - self.alloc(row.saturating_mul(image.h))?; - let raw = self.source_bytes(&src, row.saturating_mul(image.h).min(MAX_DATA))?; - by_samples(&raw, image.w, image.h, n, image.bpc) - }; - stencil.sources = vec![Source::Str(pixels)]; - image.sources = vec![Source::Str(samples)]; - } - let Some(mut img) = self.decode_image(image)? else { return Ok(None) }; - let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) }; - let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize); - for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - // The mask's pixel over this one (in 64 bits: rows times rows overflow 32). - let at = (y as u64 * mh as u64 / h.max(1) as u64) * mw as u64 + x as u64 * mw as u64 / w.max(1) as u64; - if usize::try_from(at * 4).ok().and_then(|i| m.rgba.get(i)).is_none_or(|v| *v >= 128) - && let Some(a) = px.get_mut(3) - { - *a = 0; - } - } - Ok(Some(img)) - } - - /// The dictionary form of `image` and `imagemask`. - fn image_dict(&mut self, d: &DictRef, mask: bool) -> Res { - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let num = |k: &str| get(k).and_then(|o| o.as_num()); - let kind = num("ImageType").unwrap_or(1.0); - if kind != 1.0 && kind != 4.0 { - return ps_err("rangecheck", "ImageType"); - } - let size = |k: &str| num(k).filter(|v| *v >= 1.0 && *v <= 65_535.0).map(|v| v as usize).ok_or(PsError::Ps("rangecheck", k.to_string())); - let (w, h) = (size("Width")?, size("Height")?); - let bpc = if mask { 1 } else { num("BitsPerComponent").unwrap_or(8.0) as u8 }; - let matrix = - get("ImageMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).ok_or(PsError::Ps("typecheck", "ImageMatrix".into()))?; - let decode: Vec = get("Decode").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); - let space = if mask { Space::Gray } else { (*self.g.space).clone() }; - let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); - let sources = match (get("DataSource"), multi) { - (None, _) => vec![], - (Some(Obj::Array { items, exec: false }), true) => items.borrow().iter().map(source).collect::>>()?, - (Some(src), _) => vec![source(&src)?], - }; - let mask_color: Option> = (kind == 4.0) - .then(|| { - get("MaskColor").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(|v| v.as_num().map(|v| v.max(0.0) as u32)).collect())) - }) - .flatten(); - // An image mask paints its 0 bits with Decode [0 1], its 1 bits with [1 0]. - let stencil = mask.then(|| decode.first().is_some_and(|v| *v > 0.5)); - Ok(Spec { w, h, bpc, space, decode, matrix, sources, mask_color, stencil }) - } - - /// The operand forms: `w h bpc matrix src image`, `w h polarity matrix src imagemask`, and - /// `w h bpc matrix src… multi ncomp colorimage`. - fn image_operands(&mut self, op: Op) -> Res { - let (n, multi) = if op == Op::ColorImage { - let n = self.pop_count()?; - (n, self.pop_bool()?) - } else { - (1, false) - }; - let space = match n { - 1 => Space::Gray, - 3 => Space::Rgb, - 4 => Space::Cmyk, - _ => return ps_err("rangecheck", "colorimage"), - }; - let mut sources = vec![]; - for _ in 0..if multi { n } else { 1 } { - let o = self.pop()?; - sources.push(source(&o)?); - } - sources.reverse(); - let matrix = self.pop_matrix()?; - let (bpc, stencil) = if op == Op::ImageMask { (1, Some(self.pop_bool()?)) } else { (self.pop_int()?.clamp(1, 16) as u8, None) }; - let size = |v: i64| usize::try_from(v).ok().filter(|v| (1..=65_535).contains(v)).ok_or(PsError::Ps("rangecheck", "image".into())); - let h = size(self.pop_int()?)?; - let w = size(self.pop_int()?)?; - Ok(Spec { w, h, bpc, space, decode: vec![], matrix, sources, mask_color: None, stencil }) - } - - /// The image's pixels, straight RGBA, rows from the first one read, and a CMYK image's ink - /// amounts. - fn decode_image(&mut self, s: Spec) -> Res> { - if !matches!(s.bpc, 1 | 2 | 4 | 8 | 12 | 16) { - return ps_err("rangecheck", "BitsPerComponent"); - } - if s.w.saturating_mul(s.h) > MAX_PIXELS { - self.out.warn(TOO_LARGE); - return Ok(None); - } - self.alloc(s.w * s.h * 4)?; - let n = if s.stencil.is_some() { 1 } else { s.space.n() }; - let bpc = usize::from(s.bpc); - for src in &s.sources { - if let Source::File(f) = src { - self.materialize(f)?; - } - } - // A decoded JPEG gives 8-bit gray, RGB or CMYK samples whatever the color space says. - let jpeg = s.sources.iter().find_map(|src| match src { - Source::File(f) => match &*f.borrow() { - Stream::Buf { rgb, .. } => *rgb, - _ => None, - }, - _ => None, - }); - let (n, bpc, space) = match jpeg { - Some(3) => (3, 8, Space::Rgb), - Some(4) => (4, 8, Space::Cmyk), - Some(_) => (1, 8, Space::Gray), - None => (n, bpc, s.space.clone()), - }; - let multi = s.sources.len() > 1; - let row_bits = if multi { s.w * bpc } else { s.w * n * bpc }; - let row = row_bits.div_ceil(8); - // Several sources are read in turn, a row at a time each (zeros past their ends). - let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? }; - if sources.len() > n { - return ps_err("rangecheck", "DataSource"); - } - let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; - self.alloc(plane.saturating_mul(sources.len()))?; - let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; - for p in &mut planes { - p.resize(row * s.h, 0); - } - let max = ((1u32 << bpc.min(16)) - 1) as f64; - let sample = |plane: &[u8], y: usize, i: usize| -> u32 { - let bit = y * row * 8 + i * bpc; - let byte = bit / 8; - match bpc { - 8 => u32::from(plane.get(byte).copied().unwrap_or(0)), - 16 => u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)), - 12 => { - let v = u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)); - if bit.is_multiple_of(8) { v >> 4 } else { v & 0xfff } - } - _ => (u32::from(plane.get(byte).copied().unwrap_or(0)) >> (8 - bpc - bit % 8)) & ((1 << bpc) - 1), - } - }; - let default_decode: Vec = match &space { - Space::Indexed { .. } => vec![0.0, max], - _ => (0..n).flat_map(|_| [0.0, 1.0]).collect(), - }; - // The Decode array applies to a CMYK JPEG's samples as stored; gray and RGB JPEGs are read - // with the default Decode array. - let decode = if s.decode.len() >= 2 * n && jpeg.is_none_or(|c| c == 4) { s.decode.clone() } else { default_decode }; - let paint = self.g.paint.color().map(|c| c.to_rgba8(1.0)); - let mut rgba = Vec::with_capacity(s.w * s.h * 4); - // CMYK samples keep their ink amounts, as the Decode array reads them. - let mut inks = if matches!(space, Space::Cmyk) && s.stencil.is_none() { - self.alloc(s.w * s.h * 4)?; - Some(Vec::with_capacity(s.w * s.h * 4)) - } else { - None - }; - let mut cache: HashMap, [u8; 3]> = HashMap::new(); - let mut raw = vec![0u32; n]; - let mut comps = vec![0f64; n]; - for y in 0..s.h { - for x in 0..s.w { - for (c, slot) in raw.iter_mut().enumerate() { - *slot = match planes.as_slice() { - [one] => sample(one, y, x * n + c), - many => many.get(c).map_or(0, |p| sample(p, y, x)), - }; - } - if let Some(paint_ones) = s.stencil { - let on = (raw.first().copied().unwrap_or(0) == 1) == paint_ones; - let [r, g, b, _] = paint.unwrap_or([0, 0, 0, 255]); - rgba.extend([r, g, b, if on { 255 } else { 0 }]); - continue; - } - let masked = s.mask_color.as_ref().is_some_and(|m| masked(m, &raw)); - for (c, slot) in comps.iter_mut().enumerate() { - let (d0, d1) = (decode.get(2 * c).copied().unwrap_or(0.0), decode.get(2 * c + 1).copied().unwrap_or(1.0)); - *slot = d0 + f64::from(*raw.get(c).unwrap_or(&0)) * (d1 - d0) / max; - } - if let Some(inks) = inks.as_mut() { - inks.extend(comps.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); - } - let rgb = match (&space, raw.as_slice()) { - (Space::Gray, [v]) if bpc == 8 && decode == [0.0, 1.0] => [*v as u8; 3], - (Space::Rgb, [r, g, b]) if bpc == 8 && decode == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] => [*r as u8, *g as u8, *b as u8], - _ => match cache.get(&raw) { - Some(c) => *c, - None => { - let c = self.sample_rgb(&space, &comps)?; - if cache.len() < MAX_TINTS { - cache.insert(raw.clone(), c); - } - c - } - }, - }; - rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); - } - } - Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) - } - - /// One pixel's colour in `space` as display RGB. - fn sample_rgb(&mut self, space: &Space, comps: &[f64]) -> Res<[u8; 3]> { - let color = match space { - Space::Gray | Space::Rgb | Space::Cmyk => process(space, comps), - _ => match self.paint_of(space, comps)? { - Paint::Solid { color, .. } => Some(color), - _ => None, - }, - }; - let [r, g, b, _] = color.unwrap_or(Color::BLACK).to_rgba8(1.0); - Ok([r, g, b]) - } - - /// Place decoded pixels in user space (its unit square through the image matrix). - fn place_image(&mut self, img: Pixels) -> Res { - let m = img.matrix; - if m.determinant().abs() < 1e-12 { - return Ok(()); - } - let xf = self.xf() * m.inverse(); - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - return Ok(()); - } - if self.g.null { - return Ok(()); - } - // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps an 8- or - // 16-bit CMYK image's samples, and transparent pixels (`MaskColor`, an ImageType 3 mask) - // become its opacity mask. - if let Some(blob) = img.inks.and_then(|i| Inks::new(img.w, img.h, i)).and_then(|i| ImageBlob::cmyk_tiff(&i)) { - let alpha: Vec = img.rgba.iter().skip(3).step_by(4).copied().collect(); - let mask = self.alpha_mask(&alpha, img.w, img.h, xf); - return self.add_image(blob, img.w, img.h, xf, mask); - } - let Some(rgba) = image::RgbaImage::from_raw(img.w, img.h, img.rgba) else { return Ok(()) }; - let mut png = vec![]; - if rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - self.out.warn("an image that couldn't be read was left out"); - return Ok(()); - } - self.add_image(ImageBlob::new("image/png", png), img.w, img.h, xf, None) - } - - /// Add image `blob` (`w` × `h` pixels, mapped into user space by `xf`) with `mask` as its - /// opacity mask. - fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option) -> Res { - let key = blob.content_key(); - self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); - Ok(()) - } - - /// An image's alpha (`w` × `h` bytes) as a grayscale image over the image (`xf`): its opacity - /// mask, as in PDF import. `None` when it hides nothing. - fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { - if alpha.iter().all(|v| *v == 255) { - return None; - } - let mut png = Vec::new(); - image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - let blob = ImageBlob::new("image/png", png); - let key = blob.content_key(); - self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; - Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) - } -} - -/// An image to decode. -struct Spec { - w: usize, - h: usize, - bpc: u8, - space: Space, - decode: Vec, - /// User space → image space. - matrix: Affine, - /// One source, or one per component. - sources: Vec, - /// `MaskColor` (ImageType 4): samples that are transparent. - mask_color: Option>, - /// An image mask: whether its 1 bits paint. - stencil: Option, -} - -impl Spec { - /// Bytes a row of `n` components takes. - fn row_bytes(&self, n: usize) -> usize { - (self.w * n * usize::from(self.bpc)).div_ceil(8) - } -} - -/// Data of an ImageType 3 interleaved by row, split into the mask's and the image's: the one -/// with fewer rows gives one row before each run of the other's (`(row bytes, rows)` each). -fn by_rows(raw: &[u8], (mrow, mh): (usize, usize), (drow, dh): (usize, usize)) -> (Vec, Vec) { - let room = |row: usize, rows: usize| row.saturating_mul(rows).min(raw.len()); - let (mut mask, mut data) = (Vec::with_capacity(room(mrow, mh)), Vec::with_capacity(room(drow, dh))); - let mut at = 0; - let mut take = |into: &mut Vec, n: usize| { - into.extend_from_slice(raw.get(at..at + n).unwrap_or_default()); - at += n; - }; - let (blocks, masks, rows) = if mh >= dh { (dh, mh / dh.max(1), 1) } else { (mh, 1, dh / mh.max(1)) }; - for _ in 0..blocks { - for _ in 0..masks { - take(&mut mask, mrow); - } - for _ in 0..rows { - take(&mut data, drow); - } - } - (mask, data) -} - -/// Data of an ImageType 3 interleaved by sample (the mask's value first, then `n` colour -/// components, `bpc` bits each, rows from whole bytes), split into the mask's and the image's. -fn by_samples(raw: &[u8], w: usize, h: usize, n: usize, bpc: u8) -> (Vec, Vec) { - let bpc = usize::from(bpc); - let bit = |at: usize| raw.get(at / 8).is_some_and(|b| b & (0x80 >> (at % 8)) != 0); - let (mrow, drow, row) = ((w * bpc).div_ceil(8), (w * n * bpc).div_ceil(8), (w * (n + 1) * bpc).div_ceil(8)); - let (mut mask, mut data) = (vec![0u8; mrow * h], vec![0u8; drow * h]); - let put = |into: &mut [u8], at: usize| { - if let Some(b) = into.get_mut(at / 8) { - *b |= 0x80 >> (at % 8); - } - }; - for y in 0..h { - for x in 0..w { - let from = y * row * 8 + x * (n + 1) * bpc; - for k in 0..bpc { - if bit(from + k) { - put(&mut mask, y * mrow * 8 + x * bpc + k); - } - } - for k in 0..n * bpc { - if bit(from + bpc + k) { - put(&mut data, y * drow * 8 + x * n * bpc + k); - } - } - } - } - (mask, data) -} - -/// Decoded pixels. -struct Pixels { - w: u32, - h: u32, - rgba: Vec, - /// A CMYK image's ink amounts: C, M, Y and K bytes a pixel, 0 is no ink. - inks: Option>, - matrix: Affine, -} - -fn source(o: &Obj) -> Res { - Ok(match o { - Obj::File { stream: f, .. } => Source::File(f.clone()), - Obj::Str(s) => Source::Str(s.to_vec()), - Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }), - _ => return ps_err("typecheck", "DataSource"), - }) -} - -/// Do samples `raw` match `MaskColor` `m` (one value per component, or a range)? -fn masked(m: &[u32], raw: &[u32]) -> bool { - if m.len() == raw.len() { - return m.iter().zip(raw).all(|(a, b)| a == b); - } - m.len() == raw.len() * 2 - && raw.iter().enumerate().all(|(i, v)| (m.get(2 * i).copied().unwrap_or(0)..=m.get(2 * i + 1).copied().unwrap_or(0)).contains(v)) -} +pub(crate) use filters::Stream; diff --git a/crates/eps/src/import/data/filters.rs b/crates/eps/src/import/data/filters.rs new file mode 100644 index 000000000..088dd1b3a --- /dev/null +++ b/crates/eps/src/import/data/filters.rs @@ -0,0 +1,477 @@ +use super::ops::MAX_DATA; +use super::*; + +/// A file object: the program itself, or data a filter decoded. +#[derive(Debug)] +pub(crate) enum Stream { + Current, + /// Filters over the program's own data, decoded when first read. + Pending(Vec), + Buf { + data: Vec, + pos: usize, + /// The samples are 8-bit gray, RGB or CMYK (1, 3 or 4 channels) whatever the image's + /// color space: a decoded JPEG. + rgb: Option, + }, +} + +pub(super) fn io(at: &str) -> Res { + ps_err("ioerror", at) +} + +/// The bytes `RunLengthDecode` makes of `raw`, and how many it read. +fn run_length(raw: &[u8]) -> Res<(Vec, usize)> { + let mut out = vec![]; + let mut i = 0; + while let Some(&n) = raw.get(i) { + i += 1; + match n { + 128 => break, + 0..=127 => { + let len = usize::from(n) + 1; + out.extend_from_slice(raw.get(i..i + len).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?); + i += len; + } + _ => { + let b = *raw.get(i).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?; + i += 1; + out.extend(std::iter::repeat_n(b, 257 - usize::from(n))); + } + } + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + } + Ok((out, i)) +} + +/// zlib data inflated, and how many bytes of `raw` it took. +fn flate(raw: &[u8]) -> Res<(Vec, usize)> { + let mut z = flate2::Decompress::new(true); + let mut out = Vec::with_capacity(raw.len().saturating_mul(3).min(MAX_DATA)); + loop { + let before = (z.total_in(), z.total_out()); + if out.capacity() - out.len() < 64 * 1024 { + out.reserve(256 * 1024); + } + let input = raw.get(z.total_in() as usize..).unwrap_or_default(); + let status = z.decompress_vec(input, &mut out, flate2::FlushDecompress::None).map_err(|_| PsError::Ps("ioerror", "FlateDecode".into()))?; + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + match status { + flate2::Status::StreamEnd => break, + _ if (z.total_in(), z.total_out()) == before => break, + _ => {} + } + } + Ok((out, z.total_in() as usize)) +} + +/// LZW data (`EarlyChange` 1) decoded, and how many bytes of `raw` it took. +fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { + let mut table: Vec> = (0..=255u8).map(|b| vec![b]).collect(); + table.push(vec![]); + table.push(vec![]); + let (mut out, mut prev): (Vec, Option>) = (vec![], None); + let (mut acc, mut bits, mut width, mut i) = (0u32, 0u32, 9u32, 0usize); + loop { + while bits < width { + let Some(&b) = raw.get(i) else { return Ok((out, i)) }; + i += 1; + acc = (acc << 8) | u32::from(b); + bits += 8; + } + let code = ((acc >> (bits - width)) & ((1 << width) - 1)) as usize; + bits -= width; + match code { + 256 => { + table.truncate(258); + width = 9; + prev = None; + } + 257 => return Ok((out, i)), + _ => { + let entry = match (table.get(code), &prev) { + (Some(e), _) if !e.is_empty() => e.clone(), + (_, Some(p)) if code == table.len() => { + let mut e = p.clone(); + e.extend(p.first()); + e + } + _ => return io("LZWDecode"), + }; + out.extend_from_slice(&entry); + if let Some(mut p) = prev.take() + && table.len() < 4096 + { + p.extend(entry.first()); + table.push(p); + } + width = match table.len() + 1 { + n if n >= 2048 => 12, + n if n >= 1024 => 11, + n if n >= 512 => 10, + _ => 9, + }; + prev = Some(entry); + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + } + } + } +} + +/// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of +/// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read +/// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). +fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { + let end = find(raw, &[0xFF, 0xD9]).map_or(raw.len(), |e| e + 2); + let jpeg = raw.get(..end).unwrap_or_default(); + // `cmyk` gives the ink amounts of inverted samples: inverted back, they are the samples. + if jpeg_components(jpeg) == Some(4) + && let Some(inks) = ImageBlob::new("image/jpeg", jpeg.to_vec()).cmyk() + { + return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); + } + let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; + if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } +} + +/// CCITT fax data (ITU-T T.4 Group 3, one- or two-dimensional, and T.6 Group 4) decoded to rows +/// of 1-bit samples, each padded to a whole byte, and how many bytes of `raw` it took. With +/// `EndOfBlock` the rows end at the end-of-block code (EOFB, or RTC in Group 3) regardless of +/// `Rows`; without it they end after `Rows` rows, or at the end of the data when `Rows` is 0. +/// Damaged data ends the rows: those decoded before it are kept, and damage in the first row is an +/// `ioerror`. Rows that would take more than `MAX_DATA` bytes are refused. +fn fax(raw: &[u8], settings: DecodeSettings) -> Res<(Vec, usize)> { + let row = (settings.columns as usize).div_ceil(8).max(1); + let most = u32::try_from(MAX_DATA / row).unwrap_or(u32::MAX); + if !settings.end_of_block && settings.rows > most { + return Err(PsError::Limit("decoded data is too large")); + } + // Without a row count that ends the data, a row past `most` shows that the data is too large. + let rows = if settings.end_of_block || settings.rows == 0 { most.saturating_add(1) } else { settings.rows }; + // The decoder reads the RTC of Group 3 two-dimensional data as rows: it gets the data before it. + let rtc = match settings.encoding { + EncodingMode::Group3_2D { .. } if settings.end_of_block => rtc(raw), + _ => None, + }; + let data = rtc.and_then(|(cut, _)| raw.get(..cut)).unwrap_or(raw); + let mut out = FaxRows::default(); + let read = hayro_ccitt::decode(data, &mut out, &mut DecoderContext::new(DecodeSettings { rows, ..settings })); + if out.rows > most { + return Err(PsError::Limit("decoded data is too large")); + } + let used = match read { + Ok(used) => used, + Err(_) if out.rows > 0 => { + out.data.truncate(out.rows as usize * row); + // The whole rows decoded again, for how many bytes they took. + let used = hayro_ccitt::decode(data, &mut Dropped, &mut DecoderContext::new(DecodeSettings { rows: out.rows, ..settings })); + used.unwrap_or(data.len()) + } + Err(_) => return io("CCITTFaxDecode"), + }; + Ok((out.data, rtc.map_or(used, |(_, end)| end))) +} + +/// The RTC that ends Group 3 two-dimensional data (six EOLs, each followed by a 1 tag bit, ITU-T +/// T.4 §4.2.4): the byte that holds the 1 bit of its first EOL, and the byte after its last tag +/// bit. The first EOL with a 1 tag bit that another EOL follows starts it. +fn rtc(raw: &[u8]) -> Option<(usize, usize)> { + let bits = raw.len().saturating_mul(8); + let bit = |i: usize| raw.get(i / 8).is_some_and(|b| b & (0x80 >> (i % 8)) != 0); + let zeros = |i: usize| (i..bits).take_while(|j| !bit(*j)).count(); + // An EOL (its fill bits first) and a 1 tag bit from `i`: the EOL's 1 bit and where they end. + let eol = |i: usize| { + let one = i + zeros(i); + (one - i >= 11 && bit(one) && bit(one + 1)).then_some((one, one + 2)) + }; + let mut i = 0; + while i < bits { + if let Some((one, next)) = eol(i) + && let Some((_, mut end)) = eol(next) + { + for _ in 2..6 { + match eol(end) { + Some((_, e)) => end = e, + None => break, + } + } + return Some((one / 8, end.div_ceil(8))); + } + i += zeros(i) + 1; + } + None +} + +/// Rows of 1-bit samples from a CCITT decoder, each padded to a whole byte: 1 is white, or black +/// with `BlackIs1`. +#[derive(Default)] +struct FaxRows { + data: Vec, + /// Samples in the row being decoded. + x: u32, + /// Whole rows decoded. + rows: u32, +} + +impl Decoder for FaxRows { + fn push_pixel(&mut self, white: bool) { + if self.x.is_multiple_of(8) { + self.data.push(0); + } + if white && let Some(b) = self.data.last_mut() { + *b |= 0x80 >> (self.x % 8); + } + self.x = self.x.saturating_add(1); + } + + fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) { + self.data.extend(std::iter::repeat_n(if white { 0xff } else { 0 }, chunk_count as usize)); + self.x = self.x.saturating_add(chunk_count.saturating_mul(8)); + } + + fn next_line(&mut self) { + self.x = 0; + self.rows = self.rows.saturating_add(1); + } +} + +/// A CCITT decoder that drops what it decodes. +struct Dropped; + +impl Decoder for Dropped { + fn push_pixel(&mut self, _: bool) {} + + fn push_pixel_chunk(&mut self, _: bool, _: u32) {} + + fn next_line(&mut self) {} +} + +/// A decoding filter and its parameters. +#[derive(Clone, Debug)] +pub(crate) struct Filter { + pub(super) name: Rc, + /// `SubFileDecode`'s count and end-of-data string. + pub(super) sub: (usize, Vec), + /// `FlateDecode`'s and `LZWDecode`'s `Predictor`: what it undoes. + pub(super) predictor: Option, + /// `CCITTFaxDecode`'s parameters. + pub(super) fax: Option, +} + +/// A `Predictor` (2: TIFF, 10–15: PNG) with its `Colors`, `BitsPerComponent` and `Columns`. +#[derive(Clone, Copy, Debug)] +pub(super) struct Predictor { + kind: u8, + colors: usize, + bpc: usize, + columns: usize, +} + +impl Predictor { + /// The predictor of a filter's parameters, if it has one. + pub(super) fn of(d: &DictRef) -> Res> { + let num = |k: &str, default: f64| d.borrow().get(&Key::name(k)).and_then(Obj::as_num).unwrap_or(default); + let kind = num("Predictor", 1.0); + if kind <= 1.0 { + return Ok(None); + } + let (colors, bpc, columns) = (num("Colors", 1.0), num("BitsPerComponent", 8.0), num("Columns", 1.0)); + if !(kind == 2.0 || (10.0..=15.0).contains(&kind)) + || !(1.0..=32.0).contains(&colors) + || ![1.0, 2.0, 4.0, 8.0, 16.0].contains(&bpc) + || !(1.0..=1e6).contains(&columns) + { + return ps_err("rangecheck", "Predictor"); + } + Ok(Some(Self { kind: kind as u8, colors: colors as usize, bpc: bpc as usize, columns: columns as usize })) + } + + /// The data the predictor made `data` from. + fn undo(self, data: &[u8]) -> Vec { + // A row longer than the data is the data. + let row = (self.colors * self.bpc * self.columns).div_ceil(8).min(data.len().max(1)); + // Bytes per pixel (at least one) is how far back the left neighbour is. + let bpp = (self.colors * self.bpc).div_ceil(8).max(1); + if self.kind == 2 { + // Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its + // difference from the same component of the pixel to its left, wrapping within its bit + // width. Each row starts again, and the padding bits at a row's end stay as they are. + let mut out = data.to_vec(); + for line in out.chunks_mut(row.max(1)) { + match self.bpc { + 8 => { + for i in bpp..line.len() { + let left = line.get(i - bpp).copied().unwrap_or(0); + if let Some(b) = line.get_mut(i) { + *b = b.wrapping_add(left); + } + } + } + // 16-bit components are big-endian and add as 16-bit numbers. + 16 => { + let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0); + for i in (bpp..line.len().saturating_sub(1)).step_by(2) { + let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]); + let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left); + if let Some(w) = line.get_mut(i..i + 2) { + w.copy_from_slice(&sum.to_be_bytes()); + } + } + } + // 1, 2 or 4 bits: components are packed from each byte's high bit. + bits @ (1 | 2 | 4) => { + let mask = (1u8 << bits) - 1; + let shift = |k: usize| 8 - bits - k * bits % 8; + let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask); + let samples = (line.len() * 8 / bits).min(self.colors * self.columns); + for k in self.colors..samples { + let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask; + if let Some(b) = line.get_mut(k * bits / 8) { + *b = (*b & !(mask << shift(k))) | (sum << shift(k)); + } + } + } + // `Predictor::of` accepts no other depth. + _ => {} + } + } + return out; + } + // PNG: each row starts with its filter type. + let mut out: Vec = Vec::with_capacity(data.len()); + let mut prev = vec![0u8; row]; + for chunk in data.chunks(row + 1) { + let Some((&kind, line)) = chunk.split_first() else { break }; + let mut cur = line.to_vec(); + cur.resize(row, 0); + for i in 0..row { + let a = if i >= bpp { cur.get(i - bpp).copied().unwrap_or(0) } else { 0 }; + let b = prev.get(i).copied().unwrap_or(0); + let c = if i >= bpp { prev.get(i - bpp).copied().unwrap_or(0) } else { 0 }; + let add = match kind { + 1 => a, + 2 => b, + 3 => ((u16::from(a) + u16::from(b)) / 2) as u8, + 4 => paeth(a, b, c), + _ => 0, + }; + if let Some(v) = cur.get_mut(i) { + *v = v.wrapping_add(add); + } + } + out.extend_from_slice(cur.get(..line.len().min(row)).unwrap_or_default()); + prev = cur; + } + out + } +} + +/// The PNG Paeth predictor. +fn paeth(a: u8, b: u8, c: u8) -> u8 { + let (ia, ib, ic) = (i16::from(a), i16::from(b), i16::from(c)); + let p = ia + ib - ic; + let (pa, pb, pc) = ((p - ia).abs(), (p - ib).abs(), (p - ic).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } +} + +/// `CCITTFaxDecode`'s parameters (PLRM 3rd ed., §3.13, `CCITTFaxDecode` parameters; ISO 32000-2, +/// §7.4.6): `K` (below 0 Group 4, 0 Group 3 one-dimensional, above 0 Group 3 mixed), `Columns`, +/// `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`, with their defaults where +/// `d` leaves them out. `Columns` outside 1 to 65,535 and `Rows` below 0 are a `rangecheck`. +pub(super) fn fax_settings(d: Option<&DictRef>) -> Res { + let get = |k: &str| d.and_then(|d| d.borrow().get(&Key::name(k)).cloned()); + let num = |k: &str, default: f64| get(k).and_then(|o| o.as_num()).unwrap_or(default); + let flag = |k: &str, default: bool| match get(k) { + Some(Obj::Bool(b)) => b, + _ => default, + }; + let (k, columns, rows) = (num("K", 0.0), num("Columns", 1728.0), num("Rows", 0.0)); + if !k.is_finite() || !(1.0..=65_535.0).contains(&columns) || !(0.0..=f64::from(u32::MAX)).contains(&rows) { + return ps_err("rangecheck", "CCITTFaxDecode"); + } + let encoding = if k < 0.0 { + EncodingMode::Group4 + } else if k == 0.0 { + EncodingMode::Group3_1D + } else { + EncodingMode::Group3_2D { k: k.min(f64::from(u32::MAX)) as u32 } + }; + Ok(DecodeSettings { + columns: columns as u32, + rows: rows as u32, + end_of_block: flag("EndOfBlock", true), + end_of_line: flag("EndOfLine", false), + rows_are_byte_aligned: flag("EncodedByteAlign", false), + encoding, + invert_black: flag("BlackIs1", false), + }) +} + +/// The bytes `f` decodes from `raw`, how many it read, and the channels of a decoded JPEG. +pub(super) fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec, usize, Option)> { + let (data, used) = match &*f.name { + "ASCIIHexDecode" => { + let end = raw.iter().position(|b| *b == b'>'); + let body = raw.get(..end.unwrap_or(raw.len())).unwrap_or_default(); + (hex_decode(body).ok_or(PsError::Ps("ioerror", "ASCIIHexDecode".into()))?, end.map_or(raw.len(), |e| e + 1)) + } + "ASCII85Decode" => { + let end = find(raw, b"~>"); + let mut text = String::from_utf8_lossy(raw.get(..end.unwrap_or(raw.len())).unwrap_or_default()).into_owned(); + text.push_str("~>"); + (ps::ascii85_decode(&text).ok_or(PsError::Ps("ioerror", "ASCII85Decode".into()))?, end.map_or(raw.len(), |e| e + 2)) + } + "RunLengthDecode" => run_length(raw)?, + "FlateDecode" | "LZWDecode" => { + let (data, used) = if &*f.name == "FlateDecode" { flate(raw)? } else { lzw(raw)? }; + match f.predictor { + Some(p) => (p.undo(&data), used), + None => (data, used), + } + } + "DCTDecode" => { + let (d, used, channels) = dct(raw)?; + return Ok((d, used, Some(channels))); + } + "CCITTFaxDecode" => match f.fax { + Some(settings) => fax(raw, settings)?, + None => return io("CCITTFaxDecode"), + }, + "SubFileDecode" => { + let (count, eod) = &f.sub; + if eod.is_empty() { + // A count of 0 without an end string sets no end: the data passes through whole. + let n = if *count == 0 { raw.len() } else { (*count).min(raw.len()) }; + (raw.get(..n).unwrap_or_default().to_vec(), n) + } else { + let (mut at, mut seen) = (0, 0); + loop { + match find(raw.get(at..).unwrap_or_default(), eod) { + Some(i) if seen < *count => { + seen += 1; + at += i + eod.len(); + } + Some(i) => break (raw.get(..at + i).unwrap_or_default().to_vec(), at + i + eod.len()), + None => break (raw.to_vec(), raw.len()), + } + } + } + } + // Reads all of its source (up to the end of the filters below it) and keeps it. + "NullEncode" | "ReusableStreamDecode" => (raw.to_vec(), raw.len()), + other => return ps_err("undefined", other), + }; + Ok((data, used, None)) +} diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs new file mode 100644 index 000000000..63db3611f --- /dev/null +++ b/crates/eps/src/import/data/ops.rs @@ -0,0 +1,761 @@ +use super::filters::{Filter, Predictor, Stream, decode, fax_settings, io}; +use super::pixels::{Pixels, Spec, by_rows, by_samples, masked, source}; +use super::*; + +/// Most bytes one filter or image reads. +pub(super) const MAX_DATA: usize = 1 << 28; + +/// Most pixels an image may have. +const MAX_PIXELS: usize = 1 << 26; + +/// Why an image is missing. +const TOO_LARGE: &str = "images too large to read were left out"; + +/// Most distinct colours an image converts through a tint transform. +const MAX_TINTS: usize = 1 << 16; + +/// What a source of image data gives. +#[derive(Clone)] +pub(super) enum Source { + File(Rc>), + Str(Vec), + Proc(Obj), +} + +impl Interp<'_> { + pub fn data_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + CurrentFile => self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Current)), exec: false })?, + Filter => self.filter()?, + ReadHexString | ReadString => { + let s = self.pop_str()?; + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let n = s.len(); + let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? }; + self.fill(s, &got)?; + self.push(Obj::Bool(got.len() == n))?; + } + ReadLine => { + let s = self.pop_str()?; + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let n = s.len(); + let mut line = vec![]; + let mut ended = false; + while line.len() < n { + match self.read(&f, 1)?.first() { + Some(b'\n') | None => { + ended = true; + break; + } + Some(b'\r') => { + self.skip_lf(&f); + ended = true; + break; + } + Some(b) => line.push(*b), + } + } + self.fill(s, &line)?; + self.push(Obj::Bool(ended))?; + } + FlushFile => { + // An input file is read to its end: a filter over the program's data skips it + // (metadata read with `flushfile`). The program's own file goes on being run. + if let Obj::File { stream, .. } = self.pop()? { + self.materialize(&stream)?; + } + } + CloseFile => { + self.pop()?; + } + File => { + self.pop()?; + let name = self.pop()?.text().unwrap_or_else(|| Rc::from("")); + // The standard files take what programs print (and give nothing); there is no + // file system to open others in. + if !matches!(&*name, "%stdout" | "%stderr" | "%stdin" | "%lineedit" | "%statementedit") { + return ps_err("invalidfileaccess", "file"); + } + self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Buf { data: vec![], pos: 0, rgb: None })), exec: false })?; + } + Status => match self.pop()? { + Obj::File { .. } => self.push(Obj::Bool(true))?, + // A file name: there are no files. + Obj::Str(_) => self.push(Obj::Bool(false))?, + _ => return ps_err("typecheck", ""), + }, + BytesAvailable => { + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + self.materialize(&f)?; + let n = match &*f.borrow() { + Stream::Current => self.lex.rest().len(), + Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), + Stream::Pending(_) => 0, + }; + self.push(Obj::Int(n as i64))?; + } + ResetFile => { + // A decoded (reusable) stream reads again from its start. + if let Obj::File { stream: f, .. } = self.pop()? + && let Stream::Buf { pos, .. } = &mut *f.borrow_mut() + { + *pos = 0; + } + } + Write | WriteString | WriteHexString => { + self.pop()?; + self.pop()?; + } + Read => { + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + match self.read(&f, 1)?.first() { + Some(b) => { + self.push(Obj::Int(i64::from(*b)))?; + self.push(Obj::Bool(true))?; + } + None => self.push(Obj::Bool(false))?, + } + } + FilePosition => { + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let Stream::Buf { pos, .. } = &*f.borrow() else { return ps_err("ioerror", "") }; + self.push(Obj::Int(*pos as i64))?; + } + SetFilePosition => { + let at = self.pop_count()?; + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let Stream::Buf { data, pos, .. } = &mut *f.borrow_mut() else { return ps_err("ioerror", "") }; + *pos = at.min(data.len()); + } + // There are no files to delete, rename or run. + DeleteFile | Run => { + self.pop_str()?; + return ps_err("undefinedfilename", ""); + } + RenameFile => { + self.pop_str()?; + self.pop_str()?; + return ps_err("undefinedfilename", ""); + } + Token => self.token()?, + Eexec => self.eexec()?, + Image | ImageMask | ColorImage => self.image(op)?, + _ => self.text_op(op)?, + } + Ok(()) + } + + /// `token`: the next object read from a string (and the rest of it) or a file. + fn token(&mut self) -> Res { + match self.pop()? { + Obj::Str(s) => { + let bytes = s.to_vec(); + let mut lex = Lexer::new(&bytes); + let Some(o) = lex.next()? else { return self.push(Obj::Bool(false)) }; + let o = self.scanned(o, lex.immediate)?; + // The whitespace after the token is read with it. + let used = bytes.len() - lex.data().len(); + self.push_interval(Obj::Str(s), used, bytes.len() - used)?; + self.push(o)?; + } + Obj::File { stream: f, .. } => { + self.materialize(&f)?; + let (o, immediate) = match &mut *f.borrow_mut() { + Stream::Current => (self.lex.next()?, self.lex.immediate), + Stream::Buf { data, pos, .. } => { + let rest = data.get(*pos..).unwrap_or_default(); + let mut lex = Lexer::new(rest); + let o = lex.next()?; + *pos += rest.len() - lex.data().len(); + (o, lex.immediate) + } + Stream::Pending(_) => (None, false), + }; + let Some(o) = o else { return self.push(Obj::Bool(false)) }; + let o = self.scanned(o, immediate)?; + self.push(o)?; + } + _ => return ps_err("typecheck", "token"), + } + self.push(Obj::Bool(true)) + } + + /// Up to `n` bytes of file `f`. + fn read(&mut self, f: &Rc>, n: usize) -> Res> { + self.materialize(f)?; + Ok(match &mut *f.borrow_mut() { + Stream::Current => { + let rest = self.lex.data(); + let k = n.min(rest.len()); + let v = rest.get(..k).unwrap_or_default().to_vec(); + self.lex.advance(k); + v + } + Stream::Buf { data, pos, .. } => { + let start = (*pos).min(data.len()); + let end = start.saturating_add(n).min(data.len()); + *pos = end; + data.get(start..end).unwrap_or_default().to_vec() + } + Stream::Pending(_) => vec![], + }) + } + + /// After `readline` consumes CR, consume only an immediately following LF. PostScript + /// treats CR/LF as one line ending (PLRM §3.8); raw reads must keep every other byte. + /// The preceding `read` has already materialized any pending filter. + fn skip_lf(&mut self, f: &Rc>) { + match &mut *f.borrow_mut() { + Stream::Current => { + if self.lex.rest().first() == Some(&b'\n') { + self.lex.advance(1); + } + } + Stream::Buf { data, pos, .. } => { + if data.get(*pos) == Some(&b'\n') { + *pos += 1; + } + } + Stream::Pending(_) => {} + } + } + + /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). + fn read_hex(&mut self, f: &Rc>, n: usize) -> Res> { + let mut out = Vec::with_capacity(n); + let mut high: Option = None; + while out.len() < n { + let Some(&b) = self.read(f, 1)?.first() else { break }; + let Some(v) = char::from(b).to_digit(16) else { continue }; + match high.take() { + Some(h) => out.push(h << 4 | v as u8), + None => high = Some(v as u8), + } + } + Ok(out) + } + + /// Read bytes `got` into string `s` and push the part of `s` they fill. + fn fill(&mut self, s: Shared, got: &[u8]) -> Res { + if !s.write(0, got) { + return ps_err("rangecheck", ""); + } + self.push_interval(Obj::Str(s), 0, got.len()) + } + + /// Count `n` decoded bytes against the memory budget, and the time to decode them. + fn charge_decoded(&mut self, n: usize) -> Res { + self.alloc(n)?; + self.spend((n / 4096) as u64) + } + + /// `filter`: a file that decodes its source. Over the program's own data it decodes when + /// first read (the data follows the operator that reads it); over other data, at once. + fn filter(&mut self) -> Res { + let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?; + let sub = if &*name == "SubFileDecode" { + let eod = self.pop_str()?.to_vec(); + let count = self.pop_count()?; + (count, eod) + } else { + (0, vec![]) + }; + let (mut predictor, mut params) = (None, None); + if let Some(Obj::Dict(d)) = self.stack.last().cloned() { + self.pop()?; + predictor = Predictor::of(&d)?; + params = Some(d); + } + let fax = if &*name == "CCITTFaxDecode" { Some(fax_settings(params.as_ref())?) } else { None }; + let spec_name = name.clone(); + let spec = Filter { name, sub, predictor, fax }; + let stream = match self.pop()? { + Obj::File { stream: f, .. } => { + if let Stream::Buf { data, pos, .. } = &*f.borrow() { + self.alloc(data.len().saturating_sub(*pos))?; + } + let chain = match &*f.borrow() { + Stream::Current => Some(vec![]), + Stream::Pending(c) => Some(c.clone()), + Stream::Buf { .. } => None, + }; + match chain { + Some(mut c) => { + // The new filter reads a pending one's data: it has none left of its own + // (`flushfile` on it skips nothing more). + if !c.is_empty() { + *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; + } + c.push(spec); + Stream::Pending(c) + } + None => { + let mut b = f.borrow_mut(); + let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; + let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; + self.charge_decoded(out.len())?; + *pos = pos.saturating_add(used); + Stream::Buf { data: out, pos: 0, rgb } + } + } + } + Obj::Str(s) => { + self.alloc(s.borrow().len())?; + let (data, _, rgb) = decode(&spec, &s.borrow())?; + self.charge_decoded(data.len())?; + Stream::Buf { data, pos: 0, rgb } + } + p @ Obj::Array { .. } => { + let raw = self.gather(&p, MAX_DATA)?; + let (data, _, rgb) = decode(&spec, &raw)?; + self.charge_decoded(data.len())?; + Stream::Buf { data, pos: 0, rgb } + } + _ => return ps_err("typecheck", "filter"), + }; + let stream = Rc::new(RefCell::new(stream)); + // A reusable stream reads its source when it is made: the data follows the operator. + if &*spec_name == "ReusableStreamDecode" { + self.materialize(&stream)?; + } + self.push(Obj::File { stream, exec: false }) + } + + /// The data an executable file runs as a program: what is left of it. `None` for the + /// program's own file, which is being run already. + pub(in super::super) fn program_of(&mut self, f: &Rc>) -> Res>> { + if matches!(&*f.borrow(), Stream::Current) { + return Ok(None); + } + self.read(f, MAX_DATA).map(Some) + } + + /// The decoded data left in file `f`, without reading it (a shading's or function's data). + pub(in super::super) fn peek_all(&mut self, f: &Rc>) -> Res> { + self.materialize(f)?; + Ok(match &*f.borrow() { + Stream::Buf { data, pos, .. } => data.get(*pos..).unwrap_or_default().to_vec(), + _ => vec![], + }) + } + + /// Decode the filters a file waits to run over the program's data, from where it is now. + fn materialize(&mut self, f: &Rc>) -> Res { + let chain = match &*f.borrow() { + Stream::Pending(c) => c.clone(), + _ => return Ok(()), + }; + let mut buf: Option<(Vec, Option)> = None; + for spec in &chain { + buf = Some(match buf.take() { + None => { + let raw = self.lex.data(); + let (data, used, rgb) = decode(spec, raw)?; + self.charge_decoded(data.len())?; + self.lex.advance(used); + (data, rgb) + } + Some((prev, was)) => { + let (data, _, rgb) = decode(spec, &prev)?; + self.charge_decoded(data.len())?; + (data, rgb.or(was)) + } + }); + } + let (data, rgb) = buf.unwrap_or_default(); + *f.borrow_mut() = Stream::Buf { data, pos: 0, rgb }; + Ok(()) + } + + /// The bytes a data procedure gives, called until it gives an empty string or `n` bytes. + fn gather(&mut self, p: &Obj, n: usize) -> Res> { + Ok(self.read_sources(&[Source::Proc(p.clone())], n, n)?.pop().unwrap_or_default()) + } + + /// `n` bytes of image data from `src` (zeros past its end). + fn source_bytes(&mut self, src: &Source, n: usize) -> Res> { + let mut v = self.read_sources(std::slice::from_ref(src), n, n)?.pop().unwrap_or_default(); + v.resize(n, 0); + Ok(v) + } + + /// Up to `n` bytes from each of `sources`, read in turn as an image reads several data sources + /// (PLRM 3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8: the sources are read + /// in turn, the same amount from each): each procedure called once a round (its string, + /// whatever its length), each file and string giving `step` bytes a round, until each has + /// given `n` bytes or ended. Procedures that read one file get their own rows that way. + fn read_sources(&mut self, sources: &[Source], n: usize, step: usize) -> Res>> { + let mut out: Vec> = vec![vec![]; sources.len()]; + let mut ended = vec![false; sources.len()]; + while out.iter().zip(&ended).any(|(o, e)| o.len() < n && !e) { + for ((src, o), e) in sources.iter().zip(out.iter_mut()).zip(ended.iter_mut()) { + if *e || o.len() >= n { + continue; + } + let want = step.min(n - o.len()); + let chunk = match src { + Source::File(f) => self.read(f, want)?, + // A string gives its bytes over and over. + Source::Str(s) => { + let at = o.len() % s.len().max(1); + s.get(at..).unwrap_or_default().iter().chain(s.iter().cycle()).take(want).copied().collect() + } + Source::Proc(p) => { + self.call(p.clone())?; + let s = self.pop_str()?.to_vec(); + self.alloc(s.len())?; + s + } + }; + *e = chunk.is_empty(); + o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default()); + } + } + Ok(out) + } + + /// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's + /// fonts, by name): the font dictionary it would complete is defined as it is. + fn eexec(&mut self) -> Res { + self.pop()?; + if let Some(Obj::Dict(d)) = self.stack.last().cloned() { + let name = d.borrow().get(&Key::name("FontName")).cloned(); + if let Some(k) = name.and_then(|n| n.key()) { + self.pop()?; + self.fonts.borrow_mut().insert(k, Obj::Dict(d)); + } + } + let rest = self.lex.data(); + let at = find(rest, b"cleartomark").ok_or(PsError::Ps("ioerror", "eexec".into()))?; + self.lex.advance(at); + self.push(Obj::Mark) + } + + // ---------- images ---------- + + fn image(&mut self, op: Op) -> Res { + let mask = op == Op::ImageMask; + let img = match self.stack.last() { + Some(Obj::Dict(_)) if op != Op::ColorImage => { + let d = self.pop_dict()?; + let kind = d.borrow().get(&Key::name("ImageType")).and_then(Obj::as_num); + if kind == Some(3.0) && !mask { + self.masked_image(&d)? + } else { + let spec = self.image_dict(&d, mask)?; + self.decode_image(spec)? + } + } + _ => { + let spec = self.image_operands(op)?; + self.decode_image(spec)? + } + }; + let Some(img) = img else { return Ok(()) }; + self.place_image(img) + } + + /// ImageType 3: an image and the mask that says where it paints (where the mask's samples + /// read below one half through its `Decode`, as an image mask paints its 0 bits), in separate + /// sources (`InterleaveType` 3) or interleaved with the image's by row (2) or by sample (1). + fn masked_image(&mut self, d: &DictRef) -> Res> { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let (Some(Obj::Dict(data)), Some(Obj::Dict(mask))) = (get("DataDict"), get("MaskDict")) else { + return ps_err("typecheck", "ImageType 3"); + }; + let interleave = get("InterleaveType").and_then(|o| o.as_num()).unwrap_or(1.0); + let mut image = self.image_dict(&data, false)?; + let mut stencil = self.image_dict(&mask, false)?; + stencil.space = Space::Gray; + let n = image.space.n(); + if image.w.saturating_mul(image.h) > MAX_PIXELS || stencil.w.saturating_mul(stencil.h) > MAX_PIXELS { + self.out.warn(TOO_LARGE); + return Ok(None); + } + if interleave != 3.0 { + let src = image.sources.first().cloned().ok_or(PsError::Ps("undefined", "DataSource".into()))?; + let (pixels, samples) = if interleave == 2.0 { + let (mrow, drow) = (stencil.row_bytes(1), image.row_bytes(n)); + let total = mrow.saturating_mul(stencil.h).saturating_add(drow.saturating_mul(image.h)); + self.alloc(total)?; + let raw = self.source_bytes(&src, total.min(MAX_DATA))?; + by_rows(&raw, (mrow, stencil.h), (drow, image.h)) + } else { + // Each sample: the mask's value, then the colour's, as many bits each. + stencil.bpc = image.bpc; + (stencil.w, stencil.h) = (image.w, image.h); + let row = (image.w * (n + 1) * usize::from(image.bpc)).div_ceil(8); + self.alloc(row.saturating_mul(image.h))?; + let raw = self.source_bytes(&src, row.saturating_mul(image.h).min(MAX_DATA))?; + by_samples(&raw, image.w, image.h, n, image.bpc) + }; + stencil.sources = vec![Source::Str(pixels)]; + image.sources = vec![Source::Str(samples)]; + } + let Some(mut img) = self.decode_image(image)? else { return Ok(None) }; + let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) }; + let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize); + for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + // The mask's pixel over this one (in 64 bits: rows times rows overflow 32). + let at = (y as u64 * mh as u64 / h.max(1) as u64) * mw as u64 + x as u64 * mw as u64 / w.max(1) as u64; + if usize::try_from(at * 4).ok().and_then(|i| m.rgba.get(i)).is_none_or(|v| *v >= 128) + && let Some(a) = px.get_mut(3) + { + *a = 0; + } + } + Ok(Some(img)) + } + + /// The dictionary form of `image` and `imagemask`. + fn image_dict(&mut self, d: &DictRef, mask: bool) -> Res { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let num = |k: &str| get(k).and_then(|o| o.as_num()); + let kind = num("ImageType").unwrap_or(1.0); + if kind != 1.0 && kind != 4.0 { + return ps_err("rangecheck", "ImageType"); + } + let size = |k: &str| num(k).filter(|v| *v >= 1.0 && *v <= 65_535.0).map(|v| v as usize).ok_or(PsError::Ps("rangecheck", k.to_string())); + let (w, h) = (size("Width")?, size("Height")?); + let bpc = if mask { 1 } else { num("BitsPerComponent").unwrap_or(8.0) as u8 }; + let matrix = + get("ImageMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).ok_or(PsError::Ps("typecheck", "ImageMatrix".into()))?; + let decode: Vec = get("Decode").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let space = if mask { Space::Gray } else { (*self.g.space).clone() }; + let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); + let sources = match (get("DataSource"), multi) { + (None, _) => vec![], + (Some(Obj::Array { items, exec: false }), true) => items.borrow().iter().map(source).collect::>>()?, + (Some(src), _) => vec![source(&src)?], + }; + let mask_color: Option> = (kind == 4.0) + .then(|| { + get("MaskColor").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(|v| v.as_num().map(|v| v.max(0.0) as u32)).collect())) + }) + .flatten(); + // An image mask paints its 0 bits with Decode [0 1], its 1 bits with [1 0]. + let stencil = mask.then(|| decode.first().is_some_and(|v| *v > 0.5)); + Ok(Spec { w, h, bpc, space, decode, matrix, sources, mask_color, stencil }) + } + + /// The operand forms: `w h bpc matrix src image`, `w h polarity matrix src imagemask`, and + /// `w h bpc matrix src… multi ncomp colorimage`. + fn image_operands(&mut self, op: Op) -> Res { + let (n, multi) = if op == Op::ColorImage { + let n = self.pop_count()?; + (n, self.pop_bool()?) + } else { + (1, false) + }; + let space = match n { + 1 => Space::Gray, + 3 => Space::Rgb, + 4 => Space::Cmyk, + _ => return ps_err("rangecheck", "colorimage"), + }; + let mut sources = vec![]; + for _ in 0..if multi { n } else { 1 } { + let o = self.pop()?; + sources.push(source(&o)?); + } + sources.reverse(); + let matrix = self.pop_matrix()?; + let (bpc, stencil) = if op == Op::ImageMask { (1, Some(self.pop_bool()?)) } else { (self.pop_int()?.clamp(1, 16) as u8, None) }; + let size = |v: i64| usize::try_from(v).ok().filter(|v| (1..=65_535).contains(v)).ok_or(PsError::Ps("rangecheck", "image".into())); + let h = size(self.pop_int()?)?; + let w = size(self.pop_int()?)?; + Ok(Spec { w, h, bpc, space, decode: vec![], matrix, sources, mask_color: None, stencil }) + } + + /// The image's pixels, straight RGBA, rows from the first one read, and a CMYK image's ink + /// amounts. + fn decode_image(&mut self, s: Spec) -> Res> { + if !matches!(s.bpc, 1 | 2 | 4 | 8 | 12 | 16) { + return ps_err("rangecheck", "BitsPerComponent"); + } + if s.w.saturating_mul(s.h) > MAX_PIXELS { + self.out.warn(TOO_LARGE); + return Ok(None); + } + self.alloc(s.w * s.h * 4)?; + let n = if s.stencil.is_some() { 1 } else { s.space.n() }; + let bpc = usize::from(s.bpc); + for src in &s.sources { + if let Source::File(f) = src { + self.materialize(f)?; + } + } + // A decoded JPEG gives 8-bit gray, RGB or CMYK samples whatever the color space says. + let jpeg = s.sources.iter().find_map(|src| match src { + Source::File(f) => match &*f.borrow() { + Stream::Buf { rgb, .. } => *rgb, + _ => None, + }, + _ => None, + }); + let (n, bpc, space) = match jpeg { + Some(3) => (3, 8, Space::Rgb), + Some(4) => (4, 8, Space::Cmyk), + Some(_) => (1, 8, Space::Gray), + None => (n, bpc, s.space.clone()), + }; + let multi = s.sources.len() > 1; + let row_bits = if multi { s.w * bpc } else { s.w * n * bpc }; + let row = row_bits.div_ceil(8); + // Several sources are read in turn, a row at a time each (zeros past their ends). + let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? }; + if sources.len() > n { + return ps_err("rangecheck", "DataSource"); + } + let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; + self.alloc(plane.saturating_mul(sources.len()))?; + let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; + for p in &mut planes { + p.resize(row * s.h, 0); + } + let max = ((1u32 << bpc.min(16)) - 1) as f64; + let sample = |plane: &[u8], y: usize, i: usize| -> u32 { + let bit = y * row * 8 + i * bpc; + let byte = bit / 8; + match bpc { + 8 => u32::from(plane.get(byte).copied().unwrap_or(0)), + 16 => u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)), + 12 => { + let v = u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)); + if bit.is_multiple_of(8) { v >> 4 } else { v & 0xfff } + } + _ => (u32::from(plane.get(byte).copied().unwrap_or(0)) >> (8 - bpc - bit % 8)) & ((1 << bpc) - 1), + } + }; + let default_decode: Vec = match &space { + Space::Indexed { .. } => vec![0.0, max], + _ => (0..n).flat_map(|_| [0.0, 1.0]).collect(), + }; + // The Decode array applies to a CMYK JPEG's samples as stored; gray and RGB JPEGs are read + // with the default Decode array. + let decode = if s.decode.len() >= 2 * n && jpeg.is_none_or(|c| c == 4) { s.decode.clone() } else { default_decode }; + let paint = self.g.paint.color().map(|c| c.to_rgba8(1.0)); + let mut rgba = Vec::with_capacity(s.w * s.h * 4); + // CMYK samples keep their ink amounts, as the Decode array reads them. + let mut inks = if matches!(space, Space::Cmyk) && s.stencil.is_none() { + self.alloc(s.w * s.h * 4)?; + Some(Vec::with_capacity(s.w * s.h * 4)) + } else { + None + }; + let mut cache: HashMap, [u8; 3]> = HashMap::new(); + let mut raw = vec![0u32; n]; + let mut comps = vec![0f64; n]; + for y in 0..s.h { + for x in 0..s.w { + for (c, slot) in raw.iter_mut().enumerate() { + *slot = match planes.as_slice() { + [one] => sample(one, y, x * n + c), + many => many.get(c).map_or(0, |p| sample(p, y, x)), + }; + } + if let Some(paint_ones) = s.stencil { + let on = (raw.first().copied().unwrap_or(0) == 1) == paint_ones; + let [r, g, b, _] = paint.unwrap_or([0, 0, 0, 255]); + rgba.extend([r, g, b, if on { 255 } else { 0 }]); + continue; + } + let masked = s.mask_color.as_ref().is_some_and(|m| masked(m, &raw)); + for (c, slot) in comps.iter_mut().enumerate() { + let (d0, d1) = (decode.get(2 * c).copied().unwrap_or(0.0), decode.get(2 * c + 1).copied().unwrap_or(1.0)); + *slot = d0 + f64::from(*raw.get(c).unwrap_or(&0)) * (d1 - d0) / max; + } + if let Some(inks) = inks.as_mut() { + inks.extend(comps.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + let rgb = match (&space, raw.as_slice()) { + (Space::Gray, [v]) if bpc == 8 && decode == [0.0, 1.0] => [*v as u8; 3], + (Space::Rgb, [r, g, b]) if bpc == 8 && decode == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] => [*r as u8, *g as u8, *b as u8], + _ => match cache.get(&raw) { + Some(c) => *c, + None => { + let c = self.sample_rgb(&space, &comps)?; + if cache.len() < MAX_TINTS { + cache.insert(raw.clone(), c); + } + c + } + }, + }; + rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); + } + } + Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) + } + + /// One pixel's colour in `space` as display RGB. + fn sample_rgb(&mut self, space: &Space, comps: &[f64]) -> Res<[u8; 3]> { + let color = match space { + Space::Gray | Space::Rgb | Space::Cmyk => process(space, comps), + _ => match self.paint_of(space, comps)? { + Paint::Solid { color, .. } => Some(color), + _ => None, + }, + }; + let [r, g, b, _] = color.unwrap_or(Color::BLACK).to_rgba8(1.0); + Ok([r, g, b]) + } + + /// Place decoded pixels in user space (its unit square through the image matrix). + fn place_image(&mut self, img: Pixels) -> Res { + let m = img.matrix; + if m.determinant().abs() < 1e-12 { + return Ok(()); + } + let xf = self.xf() * m.inverse(); + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return Ok(()); + } + if self.g.null { + return Ok(()); + } + // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps an 8- or + // 16-bit CMYK image's samples, and transparent pixels (`MaskColor`, an ImageType 3 mask) + // become its opacity mask. + if let Some(blob) = img.inks.and_then(|i| Inks::new(img.w, img.h, i)).and_then(|i| ImageBlob::cmyk_tiff(&i)) { + let alpha: Vec = img.rgba.iter().skip(3).step_by(4).copied().collect(); + let mask = self.alpha_mask(&alpha, img.w, img.h, xf); + return self.add_image(blob, img.w, img.h, xf, mask); + } + let Some(rgba) = image::RgbaImage::from_raw(img.w, img.h, img.rgba) else { return Ok(()) }; + let mut png = vec![]; + if rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + self.out.warn("an image that couldn't be read was left out"); + return Ok(()); + } + self.add_image(ImageBlob::new("image/png", png), img.w, img.h, xf, None) + } + + /// Add image `blob` (`w` × `h` pixels, mapped into user space by `xf`) with `mask` as its + /// opacity mask. + fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option) -> Res { + let key = blob.content_key(); + self.out.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); + Ok(()) + } + + /// An image's alpha (`w` × `h` bytes) as a grayscale image over the image (`xf`): its opacity + /// mask, as in PDF import. `None` when it hides nothing. + fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { + if alpha.iter().all(|v| *v == 255) { + return None; + } + let mut png = Vec::new(); + image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.out.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; + Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) + } +} diff --git a/crates/eps/src/import/data/pixels.rs b/crates/eps/src/import/data/pixels.rs new file mode 100644 index 000000000..f61d10448 --- /dev/null +++ b/crates/eps/src/import/data/pixels.rs @@ -0,0 +1,106 @@ +use super::ops::Source; +use super::*; + +/// An image to decode. +pub(super) struct Spec { + pub(super) w: usize, + pub(super) h: usize, + pub(super) bpc: u8, + pub(super) space: Space, + pub(super) decode: Vec, + /// User space → image space. + pub(super) matrix: Affine, + /// One source, or one per component. + pub(super) sources: Vec, + /// `MaskColor` (ImageType 4): samples that are transparent. + pub(super) mask_color: Option>, + /// An image mask: whether its 1 bits paint. + pub(super) stencil: Option, +} + +impl Spec { + /// Bytes a row of `n` components takes. + pub(super) fn row_bytes(&self, n: usize) -> usize { + (self.w * n * usize::from(self.bpc)).div_ceil(8) + } +} + +/// Data of an ImageType 3 interleaved by row, split into the mask's and the image's: the one +/// with fewer rows gives one row before each run of the other's (`(row bytes, rows)` each). +pub(super) fn by_rows(raw: &[u8], (mrow, mh): (usize, usize), (drow, dh): (usize, usize)) -> (Vec, Vec) { + let room = |row: usize, rows: usize| row.saturating_mul(rows).min(raw.len()); + let (mut mask, mut data) = (Vec::with_capacity(room(mrow, mh)), Vec::with_capacity(room(drow, dh))); + let mut at = 0; + let mut take = |into: &mut Vec, n: usize| { + into.extend_from_slice(raw.get(at..at + n).unwrap_or_default()); + at += n; + }; + let (blocks, masks, rows) = if mh >= dh { (dh, mh / dh.max(1), 1) } else { (mh, 1, dh / mh.max(1)) }; + for _ in 0..blocks { + for _ in 0..masks { + take(&mut mask, mrow); + } + for _ in 0..rows { + take(&mut data, drow); + } + } + (mask, data) +} + +/// Data of an ImageType 3 interleaved by sample (the mask's value first, then `n` colour +/// components, `bpc` bits each, rows from whole bytes), split into the mask's and the image's. +pub(super) fn by_samples(raw: &[u8], w: usize, h: usize, n: usize, bpc: u8) -> (Vec, Vec) { + let bpc = usize::from(bpc); + let bit = |at: usize| raw.get(at / 8).is_some_and(|b| b & (0x80 >> (at % 8)) != 0); + let (mrow, drow, row) = ((w * bpc).div_ceil(8), (w * n * bpc).div_ceil(8), (w * (n + 1) * bpc).div_ceil(8)); + let (mut mask, mut data) = (vec![0u8; mrow * h], vec![0u8; drow * h]); + let put = |into: &mut [u8], at: usize| { + if let Some(b) = into.get_mut(at / 8) { + *b |= 0x80 >> (at % 8); + } + }; + for y in 0..h { + for x in 0..w { + let from = y * row * 8 + x * (n + 1) * bpc; + for k in 0..bpc { + if bit(from + k) { + put(&mut mask, y * mrow * 8 + x * bpc + k); + } + } + for k in 0..n * bpc { + if bit(from + bpc + k) { + put(&mut data, y * drow * 8 + x * n * bpc + k); + } + } + } + } + (mask, data) +} + +/// Decoded pixels. +pub(super) struct Pixels { + pub(super) w: u32, + pub(super) h: u32, + pub(super) rgba: Vec, + /// A CMYK image's ink amounts: C, M, Y and K bytes a pixel, 0 is no ink. + pub(super) inks: Option>, + pub(super) matrix: Affine, +} + +pub(super) fn source(o: &Obj) -> Res { + Ok(match o { + Obj::File { stream: f, .. } => Source::File(f.clone()), + Obj::Str(s) => Source::Str(s.to_vec()), + Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }), + _ => return ps_err("typecheck", "DataSource"), + }) +} + +/// Do samples `raw` match `MaskColor` `m` (one value per component, or a range)? +pub(super) fn masked(m: &[u32], raw: &[u32]) -> bool { + if m.len() == raw.len() { + return m.iter().zip(raw).all(|(a, b)| a == b); + } + m.len() == raw.len() * 2 + && raw.iter().enumerate().all(|(i, v)| (m.get(2 * i).copied().unwrap_or(0)..=m.get(2 * i + 1).copied().unwrap_or(0)).contains(v)) +} diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 5d4f7b9fa..bd6f25287 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -17,6 +17,14 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use super::interp::{Interp, MAX_GSAVE, matrix_obj}; use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, clamp_between, ps_err}; +mod ops; +mod paint; +mod path; +mod state; + +pub(crate) use paint::collapse; +pub(crate) use state::{GState, Out, Space, process}; + /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; /// Objects reaching further than this from the page (points) come from damaged coordinates and @@ -61,1775 +69,3 @@ const NESTED_PATTERNS: &str = "patterns nested too deeply are filled with mid-gr pub(crate) const MAX_APART: u32 = 8; const UNKNOWN_FUNCTIONS: &str = "shadings whose functions are of an unknown type are filled with their middle colour"; const UNKNOWN_SHADINGS: &str = "shadings of an unknown type were left out"; - -/// A colour space. -#[derive(Clone, Debug)] -pub(crate) enum Space { - Gray, - Rgb, - Cmyk, - /// Separation and DeviceN: the ink names, the alternate space and the tint transform. - Inks { - names: Vec>, - alt: Rc, - tint: Obj, - }, - Indexed { - base: Rc, - table: Rc>, - }, - /// Patterns; an uncoloured one paints in a colour of the underlying space. - Pattern(Option>), -} - -impl Space { - /// Components a colour has in it. - pub fn n(&self) -> usize { - match self { - Self::Gray | Self::Indexed { .. } | Self::Pattern(_) => 1, - Self::Rgb => 3, - Self::Cmyk => 4, - Self::Inks { names, .. } => names.len().max(1), - } - } - - /// The colour `setcolorspace` starts with. - fn initial(&self) -> Vec { - match self { - Self::Cmyk => vec![0.0, 0.0, 0.0, 1.0], - Self::Inks { names, .. } => vec![1.0; names.len().max(1)], - s => vec![0.0; s.n()], - } - } -} - -/// A process colour from components in `space` (grey, RGB or CMYK), each clamped to 0–1. -pub(crate) fn process(space: &Space, c: &[f64]) -> Option { - let v = |i: usize| c.get(i).map_or(0.0, |x| if x.is_finite() { x.clamp(0.0, 1.0) as f32 } else { 0.0 }); - Some(match space { - Space::Gray => Color::gray(1.0 - v(0)), - Space::Rgb => Color::rgb(v(0), v(1), v(2)), - Space::Cmyk => Color::cmyk(v(0), v(1), v(2), v(3)), - _ => return None, - }) -} - -#[derive(Clone, Debug)] -pub(crate) struct GState { - /// User space → PostScript's default space. - pub ctm: Affine, - pub space: Rc, - /// The `setcolorspace` operand, for `currentcolorspace`. - pub space_obj: Obj, - pub comps: Vec, - pub paint: Paint, - pub width: f64, - pub cap: LineCap, - pub join: LineJoin, - pub miter: f64, - /// The dash pattern, shared by the states saved or copied from this one. - pub dash: Rc<[f64]>, - pub dash_offset: f64, - /// The current path, in document space. - pub path: BezPath, - pub cur: Option, - pub start: Option, - pub clips: Vec>, - /// The clips `clipsave` saved, for `cliprestore`. - pub saved_clips: Vec]>>, - pub overprint: bool, - pub font: Option, - /// `nulldevice`: painting draws nothing. - pub null: bool, - /// The colour is a shading pattern whose shading is a mesh: a fill paints it (the shading - /// and pattern space → the document). - pub mesh: Option<(DictRef, Affine)>, -} - -impl Default for GState { - fn default() -> Self { - Self { - ctm: Affine::IDENTITY, - space: Rc::new(Space::Gray), - space_obj: Obj::name("DeviceGray"), - comps: vec![0.0], - paint: Paint::solid(Color::gray(1.0)), - width: 1.0, - cap: LineCap::Butt, - join: LineJoin::Miter, - miter: 10.0, - dash: Rc::from([]), - dash_offset: 0.0, - path: BezPath::new(), - cur: None, - start: None, - clips: vec![], - saved_clips: vec![], - overprint: false, - font: None, - null: false, - mesh: None, - } - } -} - -/// What the program draws. -pub(crate) struct Out { - /// The document being made: ids, images and swatches. - pub doc: Document, - pub drawn: Drawn, - pub warnings: Vec, - /// PostScript's default space → document space. - pub page: Affine, - /// The page (the bounding box) in document space. - pub frame: Rect, - /// Clip and group ids are given in the order they open, so a chain of both sorts by id. - next_clip: u32, - /// The groups open (outermost first), and how many more opened past [`MAX_NEST`] (their art - /// goes in the innermost group kept). - groups: Vec>, - too_deep: usize, - /// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]). - elements: usize, - /// The path the last object was filled with: a stroke of the same path right after joins it. - merge: Option, - /// Process colours painted, by model (the document opens in CMYK when most are). - pub cmyk: usize, - pub rgb: usize, - /// Spot inks used: name and colour at full tint. - pub spots: Vec<(String, Color)>, - /// The color at full tint of each spot ink in [`Self::spots`], by name. - spot_colors: HashMap, Color>, - /// Rectangles painted with a shading (`shfill`): a clipping group of one is the clip path - /// filled with it. - pub shadings: HashSet, - /// Tiling patterns made into pattern swatches: the pattern's `PaintProc` (a pattern made - /// again with `makepattern` shares it) and cell, the colour an uncoloured one painted in, and - /// the swatch's name. Each list holds the patterns of one [`tiling_hash`]; a pattern whose - /// cell has a NaN corner is left out. - tilings: HashMap>, - /// How deep art drawn apart (a pattern's cell, a Type 3 glyph) is nested. - pub apart: u32, -} - -fn sane(r: Rect) -> bool { - [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) -} - -/// A tiling pattern made into a pattern swatch, as [`Out::tilings`] lists it. -type Tiling = (Shared, Rect, Option, String); - -/// A hash of what tells tiling patterns apart in [`Out::tilings`]. Patterns that compare equal -/// hash alike: each number goes in by its bits, and −0 goes in as 0. -fn tiling_hash(proc: &Shared, tile: Rect, under: Option<&Paint>) -> u64 { - let mut h = DefaultHasher::new(); - proc.hash(&mut h); - [tile.x0, tile.y0, tile.x1, tile.y1].map(|v| (v + 0.0).to_bits()).hash(&mut h); - if let Some(Paint::Solid { color, swatch, tint }) = under { - let c = match *color { - Color::Rgb { r, g, b } => [r, g, b, 0.0], - Color::Cmyk { c, m, y, k } => [c, m, y, k], - Color::Gray { k } => [k, 0.0, 0.0, 0.0], - Color::Lab { l, a, b } => [l, a, b, 0.0], - }; - c.map(|v| (v + 0.0).to_bits()).hash(&mut h); - swatch.hash(&mut h); - (tint + 0.0).to_bits().hash(&mut h); - } - h.finish() -} - -impl Out { - pub fn new(doc: Document, page: Affine, frame: Rect) -> Self { - Self { - doc, - drawn: vec![], - warnings: vec![], - page, - frame, - next_clip: 0, - groups: vec![], - too_deep: 0, - elements: 0, - merge: None, - cmyk: 0, - rgb: 0, - spots: vec![], - spot_colors: HashMap::new(), - shadings: HashSet::new(), - tilings: HashMap::new(), - apart: 0, - } - } - - pub fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - /// Open a group: what is drawn until it ends ([`Self::end_group`]) goes in it. - pub fn begin_group(&mut self) { - if self.groups.len() >= MAX_NEST { - self.too_deep += 1; - self.warn(&format!("groups nested more than {MAX_NEST} deep were read as part of the group around them")); - return; - } - self.next_clip += 1; - self.groups.push(Arc::new(Clip { id: self.next_clip, region: None })); - } - - /// End the innermost group open (an end without a beginning ends nothing). - pub fn end_group(&mut self) { - if self.too_deep > 0 { - self.too_deep -= 1; - } else { - self.groups.pop(); - } - } - - /// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the - /// order they opened. - pub fn chain(&self, clips: &[Arc]) -> Vec> { - if self.groups.is_empty() { - return clips.to_vec(); - } - let mut chain = Vec::with_capacity(clips.len() + self.groups.len()); - let (mut a, mut b) = (clips.iter().peekable(), self.groups.iter().peekable()); - while let (Some(x), Some(y)) = (a.peek(), b.peek()) { - let next = if x.id < y.id { a.next() } else { b.next() }; - chain.extend(next.cloned()); - } - chain.extend(a.chain(b).cloned()); - chain - } - - /// Add `node` under `clips` (in the groups open); `None` when it was left out. - pub fn push(&mut self, mut node: Node, clips: &[Arc]) -> Option { - self.merge = None; - let chain = self.chain(clips); - let size = node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()) + chain.len(); - self.elements = self.elements.saturating_add(size); - if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN { - self.warn(TOO_MUCH); - return None; - } - if !node.visual_bounds().is_none_or(sane) { - self.warn(FAR_AWAY); - return None; - } - node.id = self.doc.alloc_id(); - let id = node.id; - self.drawn.push((chain, node)); - Some(id) - } - - /// Count a paint's colour model. - fn count(&mut self, p: &Paint) { - match p { - Paint::Solid { color: Color::Cmyk { .. }, swatch: None, .. } => self.cmyk += 1, - Paint::Solid { color: Color::Rgb { .. }, swatch: None, .. } => self.rgb += 1, - _ => {} - } - } - - /// Fill `bp` with `paint`. - fn fill(&mut self, bp: BezPath, rule: FillRule, paint: Paint, overprint: bool, clips: &[Arc]) { - self.count(&paint); - let item = AppearanceItem::Fill(FillLayer { overprint, ..FillLayer::new(paint) }); - let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); - if let NodeKind::Path { rule: r, .. } = &mut n.kind { - *r = rule; - } - if self.push(n, clips).is_some() { - self.merge = Some(bp); - } - } - - /// Stroke `bp` with `st`: joins the object just filled with the same path, under the same - /// clips (`gsave fill grestore stroke`). - fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc]) { - self.count(&st.paint); - if self.merge.as_ref() == Some(&bp) { - let here = self.chain(clips); - if let Some((chain, last)) = self.drawn.last_mut() - && chain.iter().map(|c| c.id).eq(here.iter().map(|c| c.id)) - { - last.appearance.items.push(AppearanceItem::Stroke(st)); - self.merge = None; - return; - } - } - let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() }); - self.push(n, clips); - } - - /// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which - /// clips nothing. - fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { - // Only a polygon that really covers the page: a diamond touching its edges has the page as - // its bounding box but cuts its corners away (#1542). - if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) { - return None; - } - self.next_clip += 1; - Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) - } -} - -/// The smallest angle from `a` to `b` (radians, −π to π). -fn turn(a: f64, b: f64) -> f64 { - let d = (b - a).rem_euclid(std::f64::consts::TAU); - if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } -} - -/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left -/// of each after whole turns. -fn ccw_turn(a: f64, b: f64) -> f64 { - (b % 360.0 - a % 360.0).rem_euclid(360.0) -} - -impl Interp<'_> { - /// User space → document space. - pub fn xf(&self) -> Affine { - self.out.page * self.g.ctm - } - - /// How much user-space lengths grow in the document. - pub fn scale(&self) -> f64 { - let s = self.xf().determinant().abs().sqrt(); - if s.is_finite() { s } else { 0.0 } - } - - /// The current point in user space. - pub fn user_point(&self) -> Res { - let p = self.g.cur.ok_or(PsError::Ps("nocurrentpoint", String::new()))?; - let inv = self.xf(); - if inv.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - Ok(inv.inverse() * p) - } - - pub fn gsave(&mut self) -> Res { - if self.saved.len() >= MAX_GSAVE { - return Err(PsError::Limit("too many saved graphics states")); - } - self.alloc(std::mem::size_of_val(self.g.path.elements()))?; - self.saved.push(self.g.clone()); - Ok(()) - } - - pub fn grestore(&mut self) { - if let Some(g) = self.saved.pop() { - self.g = g; - } - } - - // ---------- paths ---------- - - /// Room for one more element in the current path. - pub fn grow(&self) -> Res { - if self.g.path.elements().len() >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - Ok(()) - } - - /// Count `n` path elements an operator makes, reads or copies against the operation budget, - /// an operation each: making or reading an element takes up to about as long as an operation, - /// and copying one takes less (measured). - fn spend_elements(&mut self, n: usize) -> Res { - self.spend(n as u64) - } - - fn move_to(&mut self, p: Point) -> Res { - self.grow()?; - // Consecutive movetos: the last one counts. - if let Some(PathEl::MoveTo(_)) = self.g.path.elements().last() { - self.g.path.pop(); - } - self.g.path.move_to(p); - self.g.cur = Some(p); - self.g.start = Some(p); - Ok(()) - } - - fn line_to(&mut self, p: Point) -> Res { - self.grow()?; - if self.g.cur.is_none() { - return ps_err("nocurrentpoint", ""); - } - self.g.path.line_to(p); - self.g.cur = Some(p); - Ok(()) - } - - /// Path elements in user space appended (the first move a line when there is a current point), - /// each counted against the operation budget. - fn append_user(&mut self, els: impl Iterator) -> Res { - let xf = self.xf(); - for el in els { - self.spend_elements(1)?; - match el { - PathEl::MoveTo(p) => { - let p = xf * p; - if self.g.cur.is_some() { self.line_to(p)? } else { self.move_to(p)? } - } - PathEl::LineTo(p) => self.line_to(xf * p)?, - PathEl::QuadTo(a, p) => { - self.grow()?; - self.g.path.quad_to(xf * a, xf * p); - self.g.cur = Some(xf * p); - } - PathEl::CurveTo(a, b, p) => { - self.grow()?; - self.g.path.curve_to(xf * a, xf * b, xf * p); - self.g.cur = Some(xf * p); - } - PathEl::ClosePath => { - self.grow()?; - self.g.path.close_path(); - } - } - } - Ok(()) - } - - /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). - fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { - // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it - // until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps, - // and the first endpoint, are worked out from what is left of each angle after whole turns. - let sweep = if ccw && a2 < a1 { - ccw_turn(a1, a2) - } else if !ccw && a2 > a1 { - -ccw_turn(a2, a1) - } else { - a2 - a1 - }; - let tau = std::f64::consts::TAU; - let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0); - let a1 = (a1 % 360.0).to_radians(); - let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); - self.append_user(std::iter::once(PathEl::MoveTo(start)))?; - let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); - let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); - self.append_user(arc.append_iter(tol)) - } - - /// `arct` / `arcto`: a line towards `p1` rounded with radius `r` into the line to `p2`; the - /// tangent points (user space). - fn arct(&mut self, p1: Point, p2: Point, r: f64) -> Res<[Point; 2]> { - let p0 = self.user_point()?; - let (u, v) = (p0 - p1, p2 - p1); - let (lu, lv) = (u.hypot(), v.hypot()); - let cross = u.cross(v); - if lu < 1e-12 || lv < 1e-12 || cross.abs() < 1e-12 || r <= 0.0 { - self.append_user(std::iter::once(PathEl::LineTo(p1)))?; - return Ok([p1, p1]); - } - let (u, v) = (u / lu, v / lv); - let half = u.dot(v).clamp(-1.0, 1.0).acos() / 2.0; - let dist = r / half.tan(); - let (t1, t2) = (p1 + u * dist, p1 + v * dist); - let bis = (u + v).normalize(); - let c = p1 + bis * (r / half.sin()); - let (a1, a2) = ((t1 - c).atan2(), (t2 - c).atan2()); - self.append_user(std::iter::once(PathEl::LineTo(t1)))?; - let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); - let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, turn(a1, a2), 0.0); - self.append_user(arc.append_iter(tol))?; - Ok([t1, t2]) - } - - fn rect_path(&self, x: f64, y: f64, w: f64, h: f64) -> BezPath { - let xf = self.xf(); - let mut bp = BezPath::new(); - bp.move_to(xf * Point::new(x, y)); - bp.line_to(xf * Point::new(x + w, y)); - bp.line_to(xf * Point::new(x + w, y + h)); - bp.line_to(xf * Point::new(x, y + h)); - bp.close_path(); - bp - } - - /// The rectangles of `rectfill`, `rectstroke` and `rectclip`: `x y w h` or an array of them. - fn rects(&mut self) -> Res { - let nums: Vec = match self.stack.last() { - Some(Obj::Array { .. }) => { - let items = self.pop_array()?; - // Five elements a rectangle, counted before the array is read. - self.spend_elements(items.len() / 4 * 5)?; - let v: Option> = items.borrow().iter().map(Obj::as_num).collect(); - v.ok_or(PsError::Ps("typecheck", String::new()))? - } - _ => self.nums::<4>()?.to_vec(), - }; - if nums.len() / 4 * 5 >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - let mut bp = BezPath::new(); - for [x, y, w, h] in nums.as_chunks::<4>().0 { - bp.extend(self.rect_path(*x, *y, *w, *h)); - } - Ok(bp) - } - - // ---------- painting ---------- - - fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res { - if bp.elements().is_empty() || self.g.null { - return Ok(()); - } - // A mesh pattern paints its shading inside the path. - if let Some((sh, m)) = self.g.mesh.clone() { - let clips = self.g.clips.clone(); - self.clip_with(bp, rule); - let r = self.mesh_fill(&sh, m); - self.g.clips = clips; - return r; - } - if self.g.paint.is_none() { - return Ok(()); - } - let (paint, overprint) = (self.g.paint.clone(), self.g.overprint); - self.out.fill(bp, rule, paint, overprint, &self.g.clips); - Ok(()) - } - - fn stroke_path(&mut self, bp: BezPath) -> Res { - if bp.elements().is_empty() || self.g.paint.is_none() || self.g.null { - return Ok(()); - } - let st = self.stroke_layer()?; - self.out.stroke(bp, st, &self.g.clips); - Ok(()) - } - - /// The stroke the graphics state draws, in document space. Each entry of the dash pattern it - /// copies counts as an operation. - fn stroke_layer(&mut self) -> Res { - self.spend(self.g.dash.len() as u64)?; - let k = self.scale(); - let w = self.g.width * k; - let mut st = StrokeLayer::new(self.g.paint.clone(), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); - st.cap = self.g.cap; - st.join = self.g.join; - st.miter_limit = self.g.miter.clamp(1.0, 500.0); - st.overprint = self.g.overprint; - let pattern: Vec = self.g.dash.iter().map(|d| d * k).collect(); - let dash = Dash { pattern, offset: self.g.dash_offset * k, align_corners: false }; - st.dash = dash.is_dashed().then_some(dash); - Ok(st) - } - - /// The current path in user space, each element as its operator and points (quadratic - /// segments as cubic ones). - fn user_elements(&self) -> Res)>> { - let xf = self.xf(); - if xf.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - let inv = xf.inverse(); - let mut last = Point::ZERO; - let mut out = Vec::with_capacity(self.g.path.elements().len()); - for el in self.g.path.elements() { - let (op, pts) = match *el { - PathEl::MoveTo(p) => (Op::MoveTo, vec![p]), - PathEl::LineTo(p) => (Op::LineTo, vec![p]), - PathEl::QuadTo(a, p) => (Op::CurveTo, vec![last + (a - last) * (2.0 / 3.0), p + (a - p) * (2.0 / 3.0), p]), - PathEl::CurveTo(a, b, p) => (Op::CurveTo, vec![a, b, p]), - PathEl::ClosePath => (Op::ClosePath, vec![]), - }; - last = pts.last().copied().unwrap_or(last); - out.push((op, pts.into_iter().map(|p| inv * p).collect())); - } - Ok(out) - } - - /// `pathforall`: the current path in user space, each element through its procedure. - fn path_for_all(&mut self) -> Res { - let close = self.pop_proc()?; - let curve = self.pop_proc()?; - let line = self.pop_proc()?; - let mv = self.pop_proc()?; - self.spend_elements(self.g.path.elements().len())?; - for (op, pts) in self.user_elements()? { - for p in pts { - self.push_num(p.x)?; - self.push_num(p.y)?; - } - let proc = match op { - Op::MoveTo => &mv, - Op::LineTo => &line, - Op::CurveTo => &curve, - _ => &close, - }; - if !self.body(proc)? { - break; - } - } - Ok(()) - } - - /// Append user path `up` to the current path: a procedure of path operators, or the encoded - /// form (numbers and a string of operator codes). - fn user_path(&mut self, up: Obj) -> Res { - let items = up.items().cloned().ok_or(PsError::Ps("typecheck", "a user path".into()))?; - let encoded = match (items.get(0), items.get(1), items.len()) { - (Some(nums @ (Obj::Array { .. } | Obj::Str(_))), Some(Obj::Str(ops)), 2) => Some((nums, ops.to_vec())), - _ => None, - }; - let Some((nums, ops)) = encoded else { return self.call(Obj::Array { items, exec: true }) }; - // The operators by code, with how many operands each takes. - const OPS: [(Op, usize); 12] = [ - (Op::SetBBox, 4), - (Op::MoveTo, 2), - (Op::RMoveTo, 2), - (Op::LineTo, 2), - (Op::RLineTo, 2), - (Op::CurveTo, 6), - (Op::RCurveTo, 6), - (Op::Arc, 5), - (Op::Arcn, 5), - (Op::Arct, 5), - (Op::ClosePath, 0), - (Op::UCache, 0), - ]; - let nums: Vec = match nums { - Obj::Array { items, .. } => { - // Each entry of the data array and each byte of the operator string counts as an - // element read. - self.spend_elements(items.len().saturating_add(ops.len()))?; - items.borrow().iter().filter_map(Obj::as_num).collect() - } - _ => return ps_err("typecheck", "an encoded user path"), - }; - let mut next = nums.into_iter(); - let mut repeat = 1usize; - for code in ops { - if code >= 32 { - repeat = usize::from(code - 32); - continue; - } - let &(op, n) = OPS.get(usize::from(code)).ok_or(PsError::Ps("rangecheck", "a user path".into()))?; - for _ in 0..std::mem::replace(&mut repeat, 1) { - for _ in 0..n { - let v = next.next().ok_or(PsError::Ps("rangecheck", "a user path".into()))?; - self.push_num(v)?; - } - // Each operator run counts as an operation. - self.spend(1)?; - self.op(op)?; - } - } - Ok(()) - } - - /// `ufill`, `ueofill`, `ustroke` (with an optional matrix): paint a user path, keeping the - /// current path. - fn paint_user_path(&mut self, op: Op) -> Res { - let m = match (op, self.stack.last()) { - (Op::UStroke, Some(Obj::Array { items, exec: false })) if items.len() == 6 && self.stack.len() >= 2 => Some(self.pop_matrix()?), - _ => None, - }; - let up = self.pop()?; - self.gsave()?; - let depth = self.saved.len(); - self.take_path(); - let r = self.user_path(up).and_then(|()| { - if let Some(m) = m { - self.g.ctm *= m; - } - let bp = self.take_path(); - match op { - Op::UStroke => self.stroke_path(bp), - _ => self.fill_path(bp, if op == Op::UEoFill { FillRule::EvenOdd } else { FillRule::NonZero }), - } - }); - self.saved.truncate(depth); - self.grestore(); - r - } - - /// `upath`: the current path as a user path (a procedure, in user space). - fn upath(&mut self) -> Res { - self.pop_bool()?; - let elements = self.user_elements()?; - let b = self.xf().inverse().transform_rect_bbox(self.g.path.bounding_box()); - let mut items: Vec = [b.x0, b.y0, b.x1, b.y1].into_iter().map(Obj::Real).collect(); - items.push(Obj::Op(Op::SetBBox)); - for (op, pts) in elements { - items.extend(pts.iter().flat_map(|p| [Obj::Real(p.x), Obj::Real(p.y)])); - items.push(Obj::Op(op)); - } - self.alloc(items.len() * std::mem::size_of::())?; - self.push(Obj::proc(items)) - } - - /// `execform`: the form's `PaintProc` run with its matrix, clipped to its box. - fn exec_form(&mut self) -> Res { - let d = self.pop_dict()?; - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let m = get("Matrix").and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow()))).unwrap_or(Affine::IDENTITY); - let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); - let paint = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; - self.gsave()?; - let depth = self.saved.len(); - self.g.ctm *= m; - if let [x0, y0, x1, y1] = bbox[..] { - let bp = self.rect_path(x0, y0, x1 - x0, y1 - y0); - self.take_path(); - self.clip_with(bp, FillRule::NonZero); - } - self.push(Obj::Dict(d))?; - let r = self.call(paint); - self.saved.truncate(depth); - self.grestore(); - r - } - - pub(super) fn take_path(&mut self) -> BezPath { - self.g.cur = None; - self.g.start = None; - std::mem::take(&mut self.g.path) - } - - fn clip_with(&mut self, path: BezPath, rule: FillRule) { - let Some(c) = self.out.clip(path, rule) else { return }; - if self.g.clips.len() < MAX_NEST { - self.g.clips.push(c); - } else { - self.out.warn(&deep_clips()); - } - } - - /// The area the clip leaves, in document space (the page without a clip). The clip path read - /// counts against the operation budget. - fn clip_bounds(&mut self) -> Res { - let (bounds, len) = - self.g.clips.iter().rev().find_map(|c| c.region.as_ref()).map_or((self.out.frame, 0), |(p, _)| (p.bounding_box(), p.elements().len())); - self.spend_elements(len)?; - Ok(bounds) - } - - // ---------- colour ---------- - - /// A colour space operand. - pub(super) fn space_of(&mut self, o: &Obj, depth: u32) -> Res { - if depth > 4 { - return ps_err("limitcheck", "setcolorspace"); - } - let (family, items) = match o { - Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]), - Obj::Array { items, .. } => { - let v = items.to_vec(); - let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?; - (n, v) - } - _ => return ps_err("typecheck", "setcolorspace"), - }; - let arg = |i: usize| items.get(i).cloned().ok_or(PsError::Ps("rangecheck", "setcolorspace".into())); - Ok(match &*family { - "DeviceGray" | "CIEBasedA" | "CalGray" => Space::Gray, - "DeviceRGB" | "CIEBasedABC" | "CalRGB" | "Lab" | "CIEBasedDEF" => Space::Rgb, - "DeviceCMYK" | "CIEBasedDEFG" => Space::Cmyk, - "Pattern" => Space::Pattern(match items.get(1) { - Some(base) => Some(Rc::new(self.space_of(base, depth + 1)?)), - None => None, - }), - "ICCBased" => match arg(1)? { - Obj::Dict(d) => match d.borrow().get(&Key::name("N")).and_then(Obj::as_num) { - Some(1.0) => Space::Gray, - Some(4.0) => Space::Cmyk, - _ => Space::Rgb, - }, - _ => Space::Rgb, - }, - "Separation" | "DeviceN" => { - let names: Vec> = match arg(1)? { - Obj::Array { items, .. } => items.borrow().iter().filter_map(Obj::text).collect(), - o => o.text().into_iter().collect(), - }; - if names.len() > MAX_INKS { - return ps_err("limitcheck", "DeviceN"); - } - let alt = self.space_of(&arg(2)?, depth + 1)?; - Space::Inks { names, alt: Rc::new(alt), tint: arg(3)? } - } - "Indexed" => { - let base = self.space_of(&arg(1)?, depth + 1)?; - let hival = - arg(2)?.as_num().filter(|v| (0.0..=MAX_HIVAL as f64).contains(v)).ok_or(PsError::Ps("rangecheck", "Indexed".into()))? as usize; - let n = base.n(); - let table: Vec = match arg(3)? { - Obj::Str(s) => s.borrow().iter().map(|b| f64::from(*b) / 255.0).collect(), - p @ Obj::Array { .. } => { - let mut t = Vec::with_capacity((hival + 1) * n); - for i in 0..=hival { - self.push(Obj::Int(i as i64))?; - self.call(p.clone())?; - let mut c = vec![0.0; n]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - t.extend(c); - } - t - } - _ => return ps_err("typecheck", "Indexed"), - }; - Space::Indexed { base: Rc::new(base), table: Rc::new(table) } - } - _ => return ps_err("undefined", &family), - }) - } - - fn set_space(&mut self, o: Obj) -> Res { - let space = self.space_of(&o, 0)?; - let comps = space.initial(); - self.g.space = Rc::new(space); - self.g.space_obj = o; - self.set_comps(comps) - } - - /// Set the colour to `comps` in the current space. - fn set_comps(&mut self, comps: Vec) -> Res { - let space = self.g.space.clone(); - self.g.mesh = None; - self.g.paint = self.paint_of(&space, &comps)?; - self.g.comps = comps; - Ok(()) - } - - /// Set a process colour, its space with it. - fn set_process(&mut self, space: Space, name: &str, comps: Vec) -> Res { - self.g.space = Rc::new(space); - self.g.space_obj = Obj::name(name); - self.set_comps(comps) - } - - /// The paint of colour `comps` in `space`: a spot ink links to its swatch at the tint. - pub fn paint_of(&mut self, space: &Space, comps: &[f64]) -> Res { - Ok(match space { - Space::Inks { names, alt, tint } => { - let t = comps.first().copied().unwrap_or(1.0).clamp(0.0, 1.0) as f32; - match names.as_slice() { - [one] if &**one == "None" => Paint::None, - [one] if &**one == "All" => { - Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), swatch: Some(REGISTRATION.into()), tint: t } - } - [one] => { - let full = match self.out.spot_colors.get(one) { - Some(c) => *c, - None => { - let c = self.tint_color(alt, tint, &[1.0])?; - self.out.spots.push((one.to_string(), c)); - // The tint transform may have listed this ink already: the color - // listed first stays. - self.out.spot_colors.entry(one.clone()).or_insert(c); - c - } - }; - Paint::Solid { color: full.tinted(t), swatch: Some(one.to_string()), tint: t } - } - _ => Paint::solid(self.tint_color(alt, tint, comps)?), - } - } - Space::Indexed { base, table } => { - let n = base.n(); - let i = comps.first().copied().unwrap_or(0.0).round().max(0.0) as usize; - let c = table.get(i * n..i * n + n).ok_or(PsError::Ps("rangecheck", "setcolor".into()))?.to_vec(); - Paint::solid(process(base, &c).unwrap_or(Color::BLACK)) - } - Space::Pattern(_) => Paint::None, - s => Paint::solid(process(s, comps).unwrap_or(Color::BLACK)), - }) - } - - /// The alternate colour of inks at `comps`, through the tint transform. - pub fn tint_color(&mut self, alt: &Space, tint: &Obj, comps: &[f64]) -> Res { - let base = self.stack.len(); - for c in comps { - self.push_num(*c)?; - } - self.call(tint.clone())?; - let n = alt.n(); - let at = self.stack.len().checked_sub(n).filter(|a| *a >= base).ok_or(PsError::Ps("stackunderflow", "tint transform".into()))?; - let v: Vec = self.stack.split_off(at).iter().map(|o| o.as_num().unwrap_or(0.0)).collect(); - self.stack.truncate(base); - Ok(process(alt, &v).unwrap_or(Color::BLACK)) - } - - /// The current colour as grey, RGB or CMYK components (`currentgray` …). - fn current_rgb(&self) -> [f32; 3] { - self.g.paint.color().map_or([0.0; 3], |c| c.to_rgb_uncalibrated()) - } - - /// `setcolor` in a pattern space: the pattern operand, and before it an uncoloured - /// pattern's colour in the underlying space. - fn set_pattern(&mut self, base: Option>) -> Res { - let Obj::Dict(d) = self.pop()? else { return ps_err("typecheck", "") }; - let uncoloured = d.borrow().get(&Key::name("PaintType")).and_then(Obj::as_num) == Some(2.0); - let under = match base.filter(|_| uncoloured) { - Some(base) => { - let mut c = vec![0.0; base.n()]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - Some(self.paint_of(&base, &c)?) - } - None => None, - }; - self.g.mesh = None; - self.g.paint = self.pattern_paint(&d, under)?; - Ok(()) - } - - /// A pattern colour: a shading pattern's gradient, a tiling pattern's swatch (`under`: the - /// colour an uncoloured one paints in). - fn pattern_paint(&mut self, d: &DictRef, under: Option) -> Res { - let kind = d.borrow().get(&Key::name("PatternType")).and_then(Obj::as_num); - let sh = d.borrow().get(&Key::name("Shading")).cloned(); - let m = d.borrow().get(&Key::name("VCMatrix")).and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow()))); - // Pattern space → the document. - let m = self.out.page * m.unwrap_or(self.g.ctm); - match (kind, sh) { - (Some(2.0), Some(Obj::Dict(sh))) if is_mesh(&sh) => { - self.g.mesh = Some((sh, m)); - Ok(Paint::None) - } - (Some(2.0), Some(Obj::Dict(sh))) => Ok(self.gradient(&sh, m)?.map_or(Paint::None, |g| Paint::Gradient(Box::new(g)))), - (Some(2.0), _) => ps_err("typecheck", "Shading"), - _ => self.tiling(d, under, m), - } - } - - /// A tiling pattern as a pattern swatch (made the first time): its `PaintProc` drawn in - /// pattern space, one cell of `XStep` × `YStep` from the corner of its `BBox`; `m` maps pattern - /// space onto the document. - fn tiling(&mut self, d: &DictRef, under: Option, m: Affine) -> Res { - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); - let [x0, y0, x1, y1] = bbox[..] else { return ps_err("rangecheck", "BBox") }; - let step = |k: &str| get(k).and_then(|o| o.as_num()).map(f64::abs).filter(|v| v.is_finite() && *v > 1e-9 && *v < LIMIT); - let (Some(xs), Some(ys)) = (step("XStep"), step("YStep")) else { return ps_err("rangecheck", "XStep") }; - let tile = Rect::from_origin_size((x0.min(x1), y0.min(y1)), (xs, ys)); - let xf = m * Affine::translate(tile.origin().to_vec2()); - let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; - let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; - let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); - if let Some((.., name)) = made { - return Ok(Paint::Pattern { pattern: name.clone(), xf }); - } - if self.out.apart >= MAX_APART { - self.out.warn(NESTED_PATTERNS); - return Ok(Paint::solid(Color::gray(0.5))); - } - let paint = under.clone(); - let art = self.draw_apart(Rect::new(x0, y0, x1, y1).abs(), |it| { - if let Some(p) = paint { - it.g.paint = p; - } - it.push(Obj::Dict(d.clone()))?; - it.call(proc) - })?; - if art.is_empty() { - return Ok(Paint::None); - } - // The document gets its patterns only here, so the n made so far are Pattern 1 to - // Pattern n. - let name = format!("Pattern {}", self.out.doc.patterns.len() + 1); - let mut def = PatternDef::new(&name, art); - def.tile = tile; - self.out.doc.patterns.push(def); - // A cell with a NaN corner equals no cell, so the lookup above could never find it. - if !tile.is_nan() { - self.out.tilings.entry(hash).or_insert_with(|| Vec::with_capacity(1)).push((key, tile, under, name.clone())); - } - Ok(Paint::Pattern { pattern: name, xf }) - } - - /// The art `draw` draws on its own, in its own space (user space is the document's, the page - /// `frame`), from a fresh graphics state with the current font: a pattern's cell, a glyph. - pub fn draw_apart(&mut self, frame: Rect, draw: impl FnOnce(&mut Self) -> Res) -> Res>> { - let fresh = GState { font: self.g.font.clone(), ..GState::default() }; - let g = std::mem::replace(&mut self.g, fresh); - let saved = std::mem::take(&mut self.saved); - let drawn = std::mem::take(&mut self.out.drawn); - let page = std::mem::replace(&mut self.out.page, Affine::IDENTITY); - let frame = std::mem::replace(&mut self.out.frame, frame); - let merge = self.out.merge.take(); - // The art isn't in the groups open around it. - let groups = std::mem::take(&mut self.out.groups); - let too_deep = std::mem::take(&mut self.out.too_deep); - self.out.apart += 1; - let r = draw(self); - self.out.apart -= 1; - let art = std::mem::replace(&mut self.out.drawn, drawn); - (self.out.page, self.out.frame, self.out.merge) = (page, frame, merge); - (self.out.groups, self.out.too_deep) = (groups, too_deep); - (self.g, self.saved) = (g, saved); - r?; - let mut nodes = vectorcraft_doc::clipnest::nest(&mut self.out.doc, art); - if !self.out.shadings.is_empty() { - collapse(&mut nodes, &self.out.shadings); - } - Ok(nodes) - } - - /// An axial or radial shading as a gradient, `m` mapping its space onto the document. - fn gradient(&mut self, sh: &DictRef, m: Affine) -> Res> { - let get = |k: &str| sh.borrow().get(&Key::name(k)).cloned(); - let kind = get("ShadingType").and_then(|o| o.as_num()).unwrap_or(0.0); - if !is_gradient(sh) { - self.out.warn(UNKNOWN_SHADINGS); - return Ok(None); - } - let space = self.space_of(&get("ColorSpace").ok_or(PsError::Ps("undefined", "ColorSpace".into()))?, 0)?; - let nums = |o: Option| -> Vec { - o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() - }; - let coords = nums(get("Coords")); - let domain = nums(get("Domain")); - let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); - let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?; - // Where the colour changes: the ends, and the bounds of stitched linear functions (unless - // there are too many). - let mut visits = 0; - let points = linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS); - let stitched = points.is_some(); - let ts: Vec = match points { - Some(mut pts) => { - pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); - pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect() - } - None => (0..=SHADING_SAMPLES).map(|i| i as f64 / SHADING_SAMPLES as f64).collect(), - }; - let mut ts = ts; - ts.sort_by(f64::total_cmp); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - let radial = kind == 3.0; - let c = |i: usize| coords.get(i).copied().unwrap_or(0.0); - let (r0, r1) = (c(2).max(0.0), c(5).max(1e-6)); - let mut stops = Vec::with_capacity(ts.len()); - let stop = |paint: Paint, offset: f64| match paint { - Paint::Solid { color, swatch, tint } => GradientStop { swatch, tint, ..GradientStop::new(offset as f32, color) }, - _ => GradientStop::new(offset as f32, Color::BLACK), - }; - for t in ts { - let comps = self.eval(&f, &[d0 + (d1 - d0) * t], 0)?; - let paint = self.paint_of(&space, &comps)?; - let offset = if radial { (r0 + (r1 - r0) * t) / r1 } else { t }; - // At a bound of a stitched function the pieces need not meet (a hard colour change): - // the colour just before the bound is a stop at the same offset, ahead of the one - // after it. - if stitched && t > 0.0 && t < 1.0 { - let comps = self.eval(&f, &[d0 + (d1 - d0) * (t - 1e-9)], 0)?; - let before = stop(self.paint_of(&space, &comps)?, offset); - if before.color != stop(paint.clone(), offset).color { - stops.push(before); - } - } - stops.push(stop(paint, offset)); - } - let (gk, geom) = if radial { - let centre = Point::new(c(3), c(4)); - let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1, 0.0), aspect: 1.0, focal: None }; - geom.set_focal(Some(Point::new(c(0), c(1)))); - geom.transform(m, GradientKind::Radial); - (GradientKind::Radial, geom) - } else { - (GradientKind::Linear, GradientGeom { start: m * Point::new(c(0), c(1)), end: m * Point::new(c(2), c(3)), aspect: 1.0, focal: None }) - }; - let mut g = GradientPaint::new(Gradient::new(gk, stops)); - g.geom = Some(geom); - Ok(Some(g)) - } - - /// A function (types 0, 2 and 3; an array of them, one per component) at `x` (types 2 and 3 - /// take one input). - pub fn eval(&mut self, f: &Obj, x: &[f64], depth: u32) -> Res> { - self.eval_inner(f, x, depth, &mut 0) - } - - /// [`Self::eval`], with `visits` (shared by the whole recursion, incremented on every call) - /// counting the functions visited against [`MAX_FUNCTION_VISITS`], as [`linear_points`] counts - /// its nodes. The depth limit alone stops a function that refers to itself, but not one that - /// fans out through shared arrays: when four levels of arrays each have 100 entries that all - /// refer to the same array of the level below (at the last level, the same function), one - /// evaluation would visit 101,010,101 functions. Each function visited also counts - /// [`FUNCTION_OPS`] against the operation budget. - fn eval_inner(&mut self, f: &Obj, x: &[f64], depth: u32, visits: &mut usize) -> Res> { - *visits += 1; - if depth > 8 || *visits > MAX_FUNCTION_VISITS { - return ps_err("limitcheck", "Function"); - } - self.spend(FUNCTION_OPS)?; - let d = match f { - Obj::Dict(d) => d.clone(), - Obj::Array { items, .. } => { - // Each entry is at least one more function: an array with more entries than the - // visits left raises `limitcheck` before it is copied. - if items.len() > MAX_FUNCTION_VISITS - *visits { - return ps_err("limitcheck", "Function"); - } - let fs = items.to_vec(); - let mut out = vec![]; - for g in &fs { - out.extend(self.eval_inner(g, x, depth + 1, visits)?.first().copied()); - } - return Ok(out); - } - _ => return ps_err("typecheck", "Function"), - }; - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let nums = |o: Option| -> Vec { - o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() - }; - let domain = nums(get("Domain")); - let t = x.first().copied().unwrap_or(0.0); - let t = match domain.as_slice() { - [a, b, ..] => clamp_between(t, *a, *b), - _ => t, - }; - match get("FunctionType").and_then(|o| o.as_num()) { - Some(0.0) => self.sampled(&d, x), - Some(2.0) => { - let c0 = nums(get("C0")); - let c1 = nums(get("C1")); - let (c0, c1) = (if c0.is_empty() { vec![0.0] } else { c0 }, if c1.is_empty() { vec![1.0] } else { c1 }); - let n = get("N").and_then(|o| o.as_num()).unwrap_or(1.0); - let x = t.max(0.0).powf(n); - Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect()) - } - Some(3.0) => { - // Read the pieces through the borrow and clone only the one we recurse into: a - // `Functions` array of millions (shared, or reached again through a cycle) must - // not be copied on every call and at every live recursion level. - let functions = get("Functions"); - let fs = functions.as_ref().and_then(Obj::items); - let n = fs.map_or(0, Shared::len); - let bounds = nums(get("Bounds")); - let encode = nums(get("Encode")); - let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); - let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1)); - let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) }; - let b = bounds.get(k).copied().unwrap_or(hi); - let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); - let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 }; - let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?; - self.eval_inner(&g, &[u], depth + 1, visits) - } - _ => { - self.out.warn(UNKNOWN_FUNCTIONS); - let r = nums(get("Range")); - Ok(r.chunks(2).map(|p| p.iter().sum::() / 2.0).collect()) - } - } - } - - // ---------- the operators ---------- - - pub fn graphics_op(&mut self, op: Op) -> Res { - use Op::*; - match op { - Gsave => self.gsave()?, - Grestore => self.grestore(), - GrestoreAll => { - if let Some(g) = self.saved.first().cloned() { - self.saved.clear(); - self.g = g; - } - } - InitGraphics => { - let font = self.g.font.take(); - self.g = GState { font, null: self.g.null, ..GState::default() }; - } - NullDevice => { - self.g.null = true; - self.g.ctm = Affine::IDENTITY; - self.g.clips.clear(); - } - ExecForm => self.exec_form()?, - StrokePath => { - let bp = self.take_path(); - let mut st = self.stroke_layer()?; - // A dash pattern far finer than the path would make countless pieces. - let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum()); - let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 }; - if st.dash.is_some() && !(period > 0.0 && periods < MAX_DASHES) { - st.dash = None; - } - let tol = (0.01 / self.scale().max(1e-9)).clamp(1e-4, 1.0); - let outline = vectorcraft_effects::stroke::line_outline(&bp, &st, st.width, tol); - // The path, each entry of the dash pattern in each period, and the outline count - // as elements. - let dashes = st.dash.as_ref().map_or(0.0, |d| periods * d.pattern.len() as f64) as usize; - self.spend_elements(bp.elements().len().saturating_add(dashes).saturating_add(outline.elements().len()))?; - if outline.elements().len() >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - self.g.cur = outline.elements().iter().rev().find_map(|e| e.end_point()); - self.g.start = self.g.cur; - self.g.path = outline; - } - PathForAll => self.path_for_all()?, - UFill | UEoFill | UStroke => self.paint_user_path(op)?, - UAppend => { - let up = self.pop()?; - self.user_path(up)?; - } - UPath => self.upath()?, - SetBBox => { - self.nums::<4>()?; - } - CurrentHsbColor => { - let [r, g, b] = self.current_rgb(); - let (max, min) = (r.max(g).max(b), r.min(g).min(b)); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - ((g - b) / d).rem_euclid(6.0) / 6.0 - } else if max == g { - ((b - r) / d + 2.0) / 6.0 - } else { - ((r - g) / d + 4.0) / 6.0 - }; - let s = if max > 0.0 { d / max } else { 0.0 }; - for v in [h, s, max] { - self.push_num(f64::from(v))?; - } - } - CurrentColorRendering => { - let d = super::obj::Dict::from([(Key::name("ColorRenderingType"), Obj::Int(1))]); - self.push(Obj::dict(d))?; - } - FindColorRendering => { - self.pop()?; - self.push(Obj::name("DefaultColorRendering"))?; - self.push(Obj::Bool(false))?; - } - CurrentColorScreen => { - for _ in 0..4 { - self.push(Obj::Real(60.0))?; - self.push(Obj::Real(45.0))?; - self.push(Obj::proc(vec![]))?; - } - } - CurrentSmoothness => self.push(Obj::Real(0.02))?, - SetHalftonePhase => { - self.nums::<2>()?; - } - CurrentHalftonePhase => { - self.push(Obj::Int(0))?; - self.push(Obj::Int(0))?; - } - GStateNew => { - self.spend_elements(self.g.path.elements().len())?; - self.alloc(std::mem::size_of_val(self.g.path.elements()))?; - self.push(Obj::GState(Rc::new(self.g.clone())))?; - } - CurrentGState => { - self.pop()?; - self.spend_elements(self.g.path.elements().len())?; - self.alloc(std::mem::size_of_val(self.g.path.elements()))?; - self.push(Obj::GState(Rc::new(self.g.clone())))?; - } - SetGState => match self.pop()? { - Obj::GState(g) => { - self.spend_elements(g.path.elements().len())?; - self.g = (*g).clone(); - } - _ => return ps_err("typecheck", ""), - }, - SetLineWidth => self.g.width = self.pop_num()?.abs(), - CurrentLineWidth => self.push_num(self.g.width)?, - SetLineCap => { - self.g.cap = match self.pop_int()? { - 1 => LineCap::Round, - 2 => LineCap::Square, - _ => LineCap::Butt, - } - } - CurrentLineCap => self.push(Obj::Int(self.g.cap as i64))?, - SetLineJoin => { - self.g.join = match self.pop_int()? { - 1 => LineJoin::Round, - 2 => LineJoin::Bevel, - _ => LineJoin::Miter, - } - } - CurrentLineJoin => self.push(Obj::Int(self.g.join as i64))?, - SetMiterLimit => self.g.miter = self.pop_num()?, - CurrentMiterLimit => self.push_num(self.g.miter)?, - SetDash => { - let offset = self.pop_num()?; - let items = self.pop_array()?; - if items.len() > MAX_DASH_ENTRIES { - return ps_err("limitcheck", ""); - } - // Each entry read counts as an operation. - self.spend(items.len() as u64)?; - let v: Option> = items.borrow().iter().map(|o| o.as_num().filter(|v| v.is_finite() && *v >= 0.0)).collect(); - self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?.into(); - self.g.dash_offset = offset; - } - CurrentDash => { - // The array made counts against the memory budget. - self.alloc(self.g.dash.len() * std::mem::size_of::())?; - let d = self.g.dash.iter().map(|v| Obj::Real(*v)).collect(); - self.push(Obj::array(d))?; - self.push_num(self.g.dash_offset)?; - } - SetGray => { - let [g] = self.nums()?; - self.set_process(Space::Gray, "DeviceGray", vec![g])?; - } - SetRgbColor => { - let c = self.nums::<3>()?; - self.set_process(Space::Rgb, "DeviceRGB", c.to_vec())?; - } - SetHsbColor => { - let [h, s, b] = self.nums()?; - let [r, g, bl] = - Color::from_hsb((h.rem_euclid(1.0) * 360.0) as f32, s.clamp(0.0, 1.0) as f32, b.clamp(0.0, 1.0) as f32).to_rgb_uncalibrated(); - self.set_process(Space::Rgb, "DeviceRGB", vec![f64::from(r), f64::from(g), f64::from(bl)])?; - } - SetCmykColor => { - let c = self.nums::<4>()?; - self.set_process(Space::Cmyk, "DeviceCMYK", c.to_vec())?; - } - CurrentGray => { - let [r, g, b] = self.current_rgb(); - self.push_num(f64::from(0.3 * r + 0.59 * g + 0.11 * b))?; - } - CurrentRgbColor => { - for v in self.current_rgb() { - self.push_num(f64::from(v))?; - } - } - CurrentCmykColor => { - let c = self.g.paint.color().map_or([0.0, 0.0, 0.0, 1.0], |c| c.to_cmyk()); - for v in c { - self.push_num(f64::from(v))?; - } - } - SetColorSpace => { - let o = self.pop()?; - self.set_space(o)?; - } - CurrentColorSpace => { - let o = match &self.g.space_obj { - o @ Obj::Array { .. } => o.clone(), - o => Obj::array(vec![o.clone()]), - }; - self.push(o)?; - } - SetColor => { - if let Space::Pattern(base) = &*self.g.space { - self.set_pattern(base.clone())?; - } else { - let n = self.g.space.n(); - let mut c = vec![0.0; n]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - self.set_comps(c)?; - } - } - CurrentColor => { - for v in self.g.comps.clone() { - self.push_num(v)?; - } - } - // `[/Pattern ] setcolorspace setcolor`, unless in a pattern space already. - SetPattern => { - if !matches!(*self.g.space, Space::Pattern(_)) { - self.g.space = Rc::new(Space::Pattern(Some(self.g.space.clone()))); - self.g.space_obj = Obj::array(vec![Obj::name("Pattern"), self.g.space_obj.clone()]); - } - let Space::Pattern(base) = &*self.g.space else { return ps_err("typecheck", "") }; - self.set_pattern(base.clone())?; - } - MakePattern => { - let m = self.pop_matrix()?; - let d = self.pop_dict()?; - let mut copy = d.borrow().clone(); - copy.insert(Key::name("VCMatrix"), matrix_obj(self.g.ctm * m)); - self.push(Obj::dict(copy))?; - } - SetOverprint => self.g.overprint = self.pop_bool()?, - CurrentOverprint => self.push(Obj::Bool(self.g.overprint))?, - SetFlat | SetHalftone | SetTransfer | SetBlackGeneration | SetUnderColorRemoval | SetColorRendering | SetSmoothness | SetStrokeAdjust - | SetPageDevice | SetUserParams | SetSystemParams => { - self.pop()?; - } - SetScreen => { - for _ in 0..3 { - self.pop()?; - } - } - SetColorTransfer => { - for _ in 0..4 { - self.pop()?; - } - } - SetColorScreen => { - for _ in 0..12 { - self.pop()?; - } - } - SetCacheLimit => { - self.pop()?; - } - SetUCacheParams => { - let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; - self.stack.truncate(at); - } - UCache => {} - CurrentScreen => { - self.push(Obj::Real(60.0))?; - self.push(Obj::Real(45.0))?; - self.push(Obj::proc(vec![]))?; - } - CurrentTransfer | CurrentBlackGeneration | CurrentUnderColorRemoval => self.push(Obj::proc(vec![]))?, - CurrentColorTransfer => { - for _ in 0..4 { - self.push(Obj::proc(vec![]))?; - } - } - CurrentHalftone => { - let mut d = super::obj::Dict::new(); - d.insert(Key::name("HalftoneType"), Obj::Int(1)); - d.insert(Key::name("Frequency"), Obj::Real(60.0)); - d.insert(Key::name("Angle"), Obj::Real(45.0)); - d.insert(Key::name("SpotFunction"), Obj::proc(vec![])); - self.push(Obj::dict(d))?; - } - FindEncoding => { - self.pop()?; - self.push(Obj::array(vec![Obj::name(".notdef"); 256]))?; - } - CurrentFlat => self.push(Obj::Real(1.0))?, - CurrentStrokeAdjust => self.push(Obj::Bool(false))?, - CurrentPageDevice => { - let mut d = super::obj::Dict::new(); - let f = self.out.frame; - d.insert(Key::name("PageSize"), Obj::array(vec![Obj::Real(f.width()), Obj::Real(f.height())])); - self.push(Obj::dict(d))?; - } - // The cache and stack sizes programs read (they `get` them without asking first). - CurrentUserParams | CurrentSystemParams => { - let keys: &[&str] = if op == CurrentUserParams { - &["MaxFontItem", "MaxFormItem", "MaxPatternItem", "MaxUPathItem", "MaxOpStack", "MaxDictStack", "MaxExecStack"] - } else { - &["MaxFontCache", "MaxFormCache", "MaxPatternCache", "MaxUPathCache", "MaxScreenStorage", "MaxDisplayList"] - }; - let d: super::obj::Dict = keys.iter().map(|k| (Key::name(k), Obj::Int(1 << 20))).collect(); - self.push(Obj::dict(d))?; - } - ShowPage => return Err(PsError::Quit), - CopyPage | ErasePage => {} - Matrix => self.push(matrix_obj(Affine::IDENTITY))?, - IdentMatrix | DefaultMatrix => { - let items = self.pop_array()?; - self.put_matrix(items, Affine::IDENTITY)?; - } - CurrentMatrix => { - let items = self.pop_array()?; - self.put_matrix(items, self.g.ctm)?; - } - SetMatrix => self.g.ctm = self.pop_matrix()?, - InitMatrix => self.g.ctm = Affine::IDENTITY, - Concat => { - let m = self.pop_matrix()?; - self.g.ctm *= m; - } - ConcatMatrix => { - let out = self.pop_array()?; - let (m2, m1) = (self.pop_matrix()?, self.pop_matrix()?); - self.put_matrix(out, m2 * m1)?; - } - Translate | Scale | Rotate => { - let into = match self.stack.last() { - Some(Obj::Array { .. }) => Some(self.pop_array()?), - _ => None, - }; - let m = match op { - Rotate => Affine::rotate(self.pop_num()?.to_radians()), - Translate => { - let [x, y] = self.nums()?; - Affine::translate((x, y)) - } - _ => { - let [x, y] = self.nums()?; - Affine::scale_non_uniform(x, y) - } - }; - match into { - Some(items) => self.put_matrix(items, m)?, - None => self.g.ctm *= m, - } - } - Transform | ITransform | DTransform | IDTransform => { - let m = match self.stack.last() { - Some(Obj::Array { .. }) => self.pop_matrix()?, - _ => self.g.ctm, - }; - let [x, y] = self.nums()?; - let inverse = matches!(op, ITransform | IDTransform); - if inverse && m.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - let m = if inverse { m.inverse() } else { m }; - let p = if matches!(op, DTransform | IDTransform) { - let v = m * Point::new(x, y) - m * Point::ZERO; - Point::new(v.x, v.y) - } else { - m * Point::new(x, y) - }; - self.push_num(p.x)?; - self.push_num(p.y)?; - } - InvertMatrix => { - let out = self.pop_array()?; - let m = self.pop_matrix()?; - if m.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - self.put_matrix(out, m.inverse())?; - } - NewPath => { - self.take_path(); - } - CurrentPoint => { - let p = self.user_point()?; - self.push_num(p.x)?; - self.push_num(p.y)?; - } - MoveTo => { - let [x, y] = self.nums()?; - let p = self.xf() * Point::new(x, y); - self.move_to(p)?; - } - RMoveTo => { - let [dx, dy] = self.nums()?; - let p = self.user_point()? + Vec2::new(dx, dy); - let p = self.xf() * p; - self.move_to(p)?; - } - LineTo => { - let [x, y] = self.nums()?; - self.line_to(self.xf() * Point::new(x, y))?; - } - RLineTo => { - let [dx, dy] = self.nums()?; - let p = self.user_point()? + Vec2::new(dx, dy); - self.line_to(self.xf() * p)?; - } - CurveTo | RCurveTo => { - let v = self.nums::<6>()?; - let o = if op == RCurveTo { self.user_point()?.to_vec2() } else { Vec2::ZERO }; - if self.g.cur.is_none() { - return ps_err("nocurrentpoint", ""); - } - let xf = self.xf(); - let [a, b, p] = [0, 2, 4].map(|i| xf * (Point::new(v[i], v[i + 1]) + o)); - self.grow()?; - self.g.path.curve_to(a, b, p); - self.g.cur = Some(p); - } - Arc | Arcn => { - let [x, y, r, a1, a2] = self.nums()?; - self.arc(Point::new(x, y), r, a1, a2, op == Arc)?; - } - Arct | Arcto => { - let [x1, y1, x2, y2, r] = self.nums()?; - let [t1, t2] = self.arct(Point::new(x1, y1), Point::new(x2, y2), r)?; - if op == Arcto { - for v in [t1.x, t1.y, t2.x, t2.y] { - self.push_num(v)?; - } - } - } - ClosePath => { - if self.g.cur.is_some() { - self.grow()?; - self.g.path.close_path(); - self.g.cur = self.g.start; - } - } - FlattenPath | ReversePath => {} - PathBBox => { - if self.g.path.elements().is_empty() { - return ps_err("nocurrentpoint", ""); - } - let xf = self.xf(); - if xf.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - self.spend_elements(self.g.path.elements().len())?; - let b = xf.inverse().transform_rect_bbox(self.g.path.bounding_box()); - for v in [b.x0, b.y0, b.x1, b.y1] { - self.push_num(v)?; - } - } - ClipPath => { - let b = self.clip_bounds()?; - self.take_path(); - self.g.path = b.to_path(0.1); - self.g.cur = Some(b.origin()); - self.g.start = self.g.cur; - } - InitClip => self.g.clips.clear(), - ClipSave => { - if self.g.saved_clips.len() >= MAX_GSAVE { - return Err(PsError::Limit("too many saved clips")); - } - self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?; - let clips = self.g.clips.as_slice().into(); - self.g.saved_clips.push(clips); - } - ClipRestore => { - if let Some(clips) = self.g.saved_clips.pop() { - self.g.clips = clips.to_vec(); - } - } - Clip | EoClip => { - self.spend_elements(self.g.path.elements().len())?; - let bp = self.g.path.clone(); - self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero }); - } - RectClip => { - let bp = self.rects()?; - self.take_path(); - self.clip_with(bp, FillRule::NonZero); - } - Fill | EoFill => { - let bp = self.take_path(); - self.fill_path(bp, if op == EoFill { FillRule::EvenOdd } else { FillRule::NonZero })?; - } - Stroke => { - let bp = self.take_path(); - self.stroke_path(bp)?; - } - RectFill => { - let bp = self.rects()?; - self.fill_path(bp, FillRule::NonZero)?; - } - RectStroke => { - let m = match self.stack.last() { - Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?), - _ => None, - }; - let mut bp = self.rects()?; - if let Some(m) = m { - let xf = self.xf(); - bp.apply_affine(xf * m * xf.inverse()); - } - self.stroke_path(bp)?; - } - ShFill => { - let d = self.pop_dict()?; - if self.g.null { - return Ok(()); - } - if is_mesh(&d) { - let m = self.xf(); - return self.mesh_fill(&d, m); - } - let m = self.xf(); - if let Some(g) = self.gradient(&d, m)? { - let area = self.clip_bounds()?; - let paint = Paint::Gradient(Box::new(g)); - let clips = self.g.clips.clone(); - self.out.fill(area.to_path(0.1), FillRule::NonZero, paint, self.g.overprint, &clips); - if let Some((_, n)) = self.out.drawn.last() { - let id = n.id; - self.out.shadings.insert(id); - } - } - } - _ => self.data_op(op)?, - } - Ok(()) - } -} - -/// Is shading `sh` axial or radial (a gradient)? -fn is_gradient(sh: &DictRef) -> bool { - matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(2.0 | 3.0)) -} - -/// Is shading `sh` one [`Interp::mesh_fill`] paints? -fn is_mesh(sh: &DictRef) -> bool { - matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(1.0 | 4.0 | 5.0 | 6.0 | 7.0)) -} - -/// Where a stitched function of linear interpolations changes slope (its bounds), when it is -/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way -/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on -/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a -/// function that refers to itself in a cycle, but not one that fans out through shared children -/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A -/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it -/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off. -fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option> { - *visits += 1; - if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) { - return None; - } - let Obj::Dict(d) = f else { return None }; - let d = d.borrow(); - let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); - match num("FunctionType")? { - 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), - 3.0 => { - let items = d.get(&Key::name("Functions"))?.items()?; - // More pieces than the stop cap allows: sample the function instead (tested before - // `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop - // runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge - // `Functions` would still be O(stops * Functions) to find each piece without this. - if items.len() > MAX_STITCH_STOPS + 1 { - return None; - } - let fs = items.to_vec(); - if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) { - return None; - } - d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()) - } - _ => None, - } -} - -/// The clip path a clipping group of one shading rectangle (`shfill` through a clip) can be -/// filled with instead. -pub(crate) fn collapse(nodes: &mut [Arc], shadings: &HashSet) { - for n in nodes.iter_mut() { - let NodeKind::Group { children, clip: true } = &n.kind else { - if let Some(c) = Arc::make_mut(n).children_mut() { - collapse(c, shadings); - } - continue; - }; - if let [clip, fill] = children.as_slice() - && shadings.contains(&fill.id) - && let (NodeKind::Path { path, rule, clipping: true, .. }, NodeKind::Path { .. }) = (&clip.kind, &fill.kind) - { - let mut p = Node::path(fill.id, path.clone(), (*fill.appearance).clone()); - if let NodeKind::Path { rule: r, .. } = &mut p.kind { - *r = *rule; - } - *n = Arc::new(p); - continue; - } - if let Some(c) = Arc::make_mut(n).children_mut() { - collapse(c, shadings); - } - } -} diff --git a/crates/eps/src/import/graphics/ops.rs b/crates/eps/src/import/graphics/ops.rs new file mode 100644 index 000000000..608a62413 --- /dev/null +++ b/crates/eps/src/import/graphics/ops.rs @@ -0,0 +1,533 @@ +use super::paint::is_mesh; +use super::state::{GState, Space}; +use super::*; + +impl Interp<'_> { + // ---------- the operators ---------- + + pub fn graphics_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + Gsave => self.gsave()?, + Grestore => self.grestore(), + GrestoreAll => { + if let Some(g) = self.saved.first().cloned() { + self.saved.clear(); + self.g = g; + } + } + InitGraphics => { + let font = self.g.font.take(); + self.g = GState { font, null: self.g.null, ..GState::default() }; + } + NullDevice => { + self.g.null = true; + self.g.ctm = Affine::IDENTITY; + self.g.clips.clear(); + } + ExecForm => self.exec_form()?, + StrokePath => { + let bp = self.take_path(); + let mut st = self.stroke_layer()?; + // A dash pattern far finer than the path would make countless pieces. + let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum()); + let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 }; + if st.dash.is_some() && !(period > 0.0 && periods < MAX_DASHES) { + st.dash = None; + } + let tol = (0.01 / self.scale().max(1e-9)).clamp(1e-4, 1.0); + let outline = vectorcraft_effects::stroke::line_outline(&bp, &st, st.width, tol); + // The path, each entry of the dash pattern in each period, and the outline count + // as elements. + let dashes = st.dash.as_ref().map_or(0.0, |d| periods * d.pattern.len() as f64) as usize; + self.spend_elements(bp.elements().len().saturating_add(dashes).saturating_add(outline.elements().len()))?; + if outline.elements().len() >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + self.g.cur = outline.elements().iter().rev().find_map(|e| e.end_point()); + self.g.start = self.g.cur; + self.g.path = outline; + } + PathForAll => self.path_for_all()?, + UFill | UEoFill | UStroke => self.paint_user_path(op)?, + UAppend => { + let up = self.pop()?; + self.user_path(up)?; + } + UPath => self.upath()?, + SetBBox => { + self.nums::<4>()?; + } + CurrentHsbColor => { + let [r, g, b] = self.current_rgb(); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + ((g - b) / d).rem_euclid(6.0) / 6.0 + } else if max == g { + ((b - r) / d + 2.0) / 6.0 + } else { + ((r - g) / d + 4.0) / 6.0 + }; + let s = if max > 0.0 { d / max } else { 0.0 }; + for v in [h, s, max] { + self.push_num(f64::from(v))?; + } + } + CurrentColorRendering => { + let d = crate::import::obj::Dict::from([(Key::name("ColorRenderingType"), Obj::Int(1))]); + self.push(Obj::dict(d))?; + } + FindColorRendering => { + self.pop()?; + self.push(Obj::name("DefaultColorRendering"))?; + self.push(Obj::Bool(false))?; + } + CurrentColorScreen => { + for _ in 0..4 { + self.push(Obj::Real(60.0))?; + self.push(Obj::Real(45.0))?; + self.push(Obj::proc(vec![]))?; + } + } + CurrentSmoothness => self.push(Obj::Real(0.02))?, + SetHalftonePhase => { + self.nums::<2>()?; + } + CurrentHalftonePhase => { + self.push(Obj::Int(0))?; + self.push(Obj::Int(0))?; + } + GStateNew => { + self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.push(Obj::GState(Rc::new(self.g.clone())))?; + } + CurrentGState => { + self.pop()?; + self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.push(Obj::GState(Rc::new(self.g.clone())))?; + } + SetGState => match self.pop()? { + Obj::GState(g) => { + self.spend_elements(g.path.elements().len())?; + self.g = (*g).clone(); + } + _ => return ps_err("typecheck", ""), + }, + SetLineWidth => self.g.width = self.pop_num()?.abs(), + CurrentLineWidth => self.push_num(self.g.width)?, + SetLineCap => { + self.g.cap = match self.pop_int()? { + 1 => LineCap::Round, + 2 => LineCap::Square, + _ => LineCap::Butt, + } + } + CurrentLineCap => self.push(Obj::Int(self.g.cap as i64))?, + SetLineJoin => { + self.g.join = match self.pop_int()? { + 1 => LineJoin::Round, + 2 => LineJoin::Bevel, + _ => LineJoin::Miter, + } + } + CurrentLineJoin => self.push(Obj::Int(self.g.join as i64))?, + SetMiterLimit => self.g.miter = self.pop_num()?, + CurrentMiterLimit => self.push_num(self.g.miter)?, + SetDash => { + let offset = self.pop_num()?; + let items = self.pop_array()?; + if items.len() > MAX_DASH_ENTRIES { + return ps_err("limitcheck", ""); + } + // Each entry read counts as an operation. + self.spend(items.len() as u64)?; + let v: Option> = items.borrow().iter().map(|o| o.as_num().filter(|v| v.is_finite() && *v >= 0.0)).collect(); + self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?.into(); + self.g.dash_offset = offset; + } + CurrentDash => { + // The array made counts against the memory budget. + self.alloc(self.g.dash.len() * std::mem::size_of::())?; + let d = self.g.dash.iter().map(|v| Obj::Real(*v)).collect(); + self.push(Obj::array(d))?; + self.push_num(self.g.dash_offset)?; + } + SetGray => { + let [g] = self.nums()?; + self.set_process(Space::Gray, "DeviceGray", vec![g])?; + } + SetRgbColor => { + let c = self.nums::<3>()?; + self.set_process(Space::Rgb, "DeviceRGB", c.to_vec())?; + } + SetHsbColor => { + let [h, s, b] = self.nums()?; + let [r, g, bl] = + Color::from_hsb((h.rem_euclid(1.0) * 360.0) as f32, s.clamp(0.0, 1.0) as f32, b.clamp(0.0, 1.0) as f32).to_rgb_uncalibrated(); + self.set_process(Space::Rgb, "DeviceRGB", vec![f64::from(r), f64::from(g), f64::from(bl)])?; + } + SetCmykColor => { + let c = self.nums::<4>()?; + self.set_process(Space::Cmyk, "DeviceCMYK", c.to_vec())?; + } + CurrentGray => { + let [r, g, b] = self.current_rgb(); + self.push_num(f64::from(0.3 * r + 0.59 * g + 0.11 * b))?; + } + CurrentRgbColor => { + for v in self.current_rgb() { + self.push_num(f64::from(v))?; + } + } + CurrentCmykColor => { + let c = self.g.paint.color().map_or([0.0, 0.0, 0.0, 1.0], |c| c.to_cmyk()); + for v in c { + self.push_num(f64::from(v))?; + } + } + SetColorSpace => { + let o = self.pop()?; + self.set_space(o)?; + } + CurrentColorSpace => { + let o = match &self.g.space_obj { + o @ Obj::Array { .. } => o.clone(), + o => Obj::array(vec![o.clone()]), + }; + self.push(o)?; + } + SetColor => { + if let Space::Pattern(base) = &*self.g.space { + self.set_pattern(base.clone())?; + } else { + let n = self.g.space.n(); + let mut c = vec![0.0; n]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + self.set_comps(c)?; + } + } + CurrentColor => { + for v in self.g.comps.clone() { + self.push_num(v)?; + } + } + // `[/Pattern ] setcolorspace setcolor`, unless in a pattern space already. + SetPattern => { + if !matches!(*self.g.space, Space::Pattern(_)) { + self.g.space = Rc::new(Space::Pattern(Some(self.g.space.clone()))); + self.g.space_obj = Obj::array(vec![Obj::name("Pattern"), self.g.space_obj.clone()]); + } + let Space::Pattern(base) = &*self.g.space else { return ps_err("typecheck", "") }; + self.set_pattern(base.clone())?; + } + MakePattern => { + let m = self.pop_matrix()?; + let d = self.pop_dict()?; + let mut copy = d.borrow().clone(); + copy.insert(Key::name("VCMatrix"), matrix_obj(self.g.ctm * m)); + self.push(Obj::dict(copy))?; + } + SetOverprint => self.g.overprint = self.pop_bool()?, + CurrentOverprint => self.push(Obj::Bool(self.g.overprint))?, + SetFlat | SetHalftone | SetTransfer | SetBlackGeneration | SetUnderColorRemoval | SetColorRendering | SetSmoothness | SetStrokeAdjust + | SetPageDevice | SetUserParams | SetSystemParams => { + self.pop()?; + } + SetScreen => { + for _ in 0..3 { + self.pop()?; + } + } + SetColorTransfer => { + for _ in 0..4 { + self.pop()?; + } + } + SetColorScreen => { + for _ in 0..12 { + self.pop()?; + } + } + SetCacheLimit => { + self.pop()?; + } + SetUCacheParams => { + let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; + self.stack.truncate(at); + } + UCache => {} + CurrentScreen => { + self.push(Obj::Real(60.0))?; + self.push(Obj::Real(45.0))?; + self.push(Obj::proc(vec![]))?; + } + CurrentTransfer | CurrentBlackGeneration | CurrentUnderColorRemoval => self.push(Obj::proc(vec![]))?, + CurrentColorTransfer => { + for _ in 0..4 { + self.push(Obj::proc(vec![]))?; + } + } + CurrentHalftone => { + let mut d = crate::import::obj::Dict::new(); + d.insert(Key::name("HalftoneType"), Obj::Int(1)); + d.insert(Key::name("Frequency"), Obj::Real(60.0)); + d.insert(Key::name("Angle"), Obj::Real(45.0)); + d.insert(Key::name("SpotFunction"), Obj::proc(vec![])); + self.push(Obj::dict(d))?; + } + FindEncoding => { + self.pop()?; + self.push(Obj::array(vec![Obj::name(".notdef"); 256]))?; + } + CurrentFlat => self.push(Obj::Real(1.0))?, + CurrentStrokeAdjust => self.push(Obj::Bool(false))?, + CurrentPageDevice => { + let mut d = crate::import::obj::Dict::new(); + let f = self.out.frame; + d.insert(Key::name("PageSize"), Obj::array(vec![Obj::Real(f.width()), Obj::Real(f.height())])); + self.push(Obj::dict(d))?; + } + // The cache and stack sizes programs read (they `get` them without asking first). + CurrentUserParams | CurrentSystemParams => { + let keys: &[&str] = if op == CurrentUserParams { + &["MaxFontItem", "MaxFormItem", "MaxPatternItem", "MaxUPathItem", "MaxOpStack", "MaxDictStack", "MaxExecStack"] + } else { + &["MaxFontCache", "MaxFormCache", "MaxPatternCache", "MaxUPathCache", "MaxScreenStorage", "MaxDisplayList"] + }; + let d: crate::import::obj::Dict = keys.iter().map(|k| (Key::name(k), Obj::Int(1 << 20))).collect(); + self.push(Obj::dict(d))?; + } + ShowPage => return Err(PsError::Quit), + CopyPage | ErasePage => {} + Matrix => self.push(matrix_obj(Affine::IDENTITY))?, + IdentMatrix | DefaultMatrix => { + let items = self.pop_array()?; + self.put_matrix(items, Affine::IDENTITY)?; + } + CurrentMatrix => { + let items = self.pop_array()?; + self.put_matrix(items, self.g.ctm)?; + } + SetMatrix => self.g.ctm = self.pop_matrix()?, + InitMatrix => self.g.ctm = Affine::IDENTITY, + Concat => { + let m = self.pop_matrix()?; + self.g.ctm *= m; + } + ConcatMatrix => { + let out = self.pop_array()?; + let (m2, m1) = (self.pop_matrix()?, self.pop_matrix()?); + self.put_matrix(out, m2 * m1)?; + } + Translate | Scale | Rotate => { + let into = match self.stack.last() { + Some(Obj::Array { .. }) => Some(self.pop_array()?), + _ => None, + }; + let m = match op { + Rotate => Affine::rotate(self.pop_num()?.to_radians()), + Translate => { + let [x, y] = self.nums()?; + Affine::translate((x, y)) + } + _ => { + let [x, y] = self.nums()?; + Affine::scale_non_uniform(x, y) + } + }; + match into { + Some(items) => self.put_matrix(items, m)?, + None => self.g.ctm *= m, + } + } + Transform | ITransform | DTransform | IDTransform => { + let m = match self.stack.last() { + Some(Obj::Array { .. }) => self.pop_matrix()?, + _ => self.g.ctm, + }; + let [x, y] = self.nums()?; + let inverse = matches!(op, ITransform | IDTransform); + if inverse && m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let m = if inverse { m.inverse() } else { m }; + let p = if matches!(op, DTransform | IDTransform) { + let v = m * Point::new(x, y) - m * Point::ZERO; + Point::new(v.x, v.y) + } else { + m * Point::new(x, y) + }; + self.push_num(p.x)?; + self.push_num(p.y)?; + } + InvertMatrix => { + let out = self.pop_array()?; + let m = self.pop_matrix()?; + if m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + self.put_matrix(out, m.inverse())?; + } + NewPath => { + self.take_path(); + } + CurrentPoint => { + let p = self.user_point()?; + self.push_num(p.x)?; + self.push_num(p.y)?; + } + MoveTo => { + let [x, y] = self.nums()?; + let p = self.xf() * Point::new(x, y); + self.move_to(p)?; + } + RMoveTo => { + let [dx, dy] = self.nums()?; + let p = self.user_point()? + Vec2::new(dx, dy); + let p = self.xf() * p; + self.move_to(p)?; + } + LineTo => { + let [x, y] = self.nums()?; + self.line_to(self.xf() * Point::new(x, y))?; + } + RLineTo => { + let [dx, dy] = self.nums()?; + let p = self.user_point()? + Vec2::new(dx, dy); + self.line_to(self.xf() * p)?; + } + CurveTo | RCurveTo => { + let v = self.nums::<6>()?; + let o = if op == RCurveTo { self.user_point()?.to_vec2() } else { Vec2::ZERO }; + if self.g.cur.is_none() { + return ps_err("nocurrentpoint", ""); + } + let xf = self.xf(); + let [a, b, p] = [0, 2, 4].map(|i| xf * (Point::new(v[i], v[i + 1]) + o)); + self.grow()?; + self.g.path.curve_to(a, b, p); + self.g.cur = Some(p); + } + Arc | Arcn => { + let [x, y, r, a1, a2] = self.nums()?; + self.arc(Point::new(x, y), r, a1, a2, op == Arc)?; + } + Arct | Arcto => { + let [x1, y1, x2, y2, r] = self.nums()?; + let [t1, t2] = self.arct(Point::new(x1, y1), Point::new(x2, y2), r)?; + if op == Arcto { + for v in [t1.x, t1.y, t2.x, t2.y] { + self.push_num(v)?; + } + } + } + ClosePath => { + if self.g.cur.is_some() { + self.grow()?; + self.g.path.close_path(); + self.g.cur = self.g.start; + } + } + FlattenPath | ReversePath => {} + PathBBox => { + if self.g.path.elements().is_empty() { + return ps_err("nocurrentpoint", ""); + } + let xf = self.xf(); + if xf.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + self.spend_elements(self.g.path.elements().len())?; + let b = xf.inverse().transform_rect_bbox(self.g.path.bounding_box()); + for v in [b.x0, b.y0, b.x1, b.y1] { + self.push_num(v)?; + } + } + ClipPath => { + let b = self.clip_bounds()?; + self.take_path(); + self.g.path = b.to_path(0.1); + self.g.cur = Some(b.origin()); + self.g.start = self.g.cur; + } + InitClip => self.g.clips.clear(), + ClipSave => { + if self.g.saved_clips.len() >= MAX_GSAVE { + return Err(PsError::Limit("too many saved clips")); + } + self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?; + let clips = self.g.clips.as_slice().into(); + self.g.saved_clips.push(clips); + } + ClipRestore => { + if let Some(clips) = self.g.saved_clips.pop() { + self.g.clips = clips.to_vec(); + } + } + Clip | EoClip => { + self.spend_elements(self.g.path.elements().len())?; + let bp = self.g.path.clone(); + self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero }); + } + RectClip => { + let bp = self.rects()?; + self.take_path(); + self.clip_with(bp, FillRule::NonZero); + } + Fill | EoFill => { + let bp = self.take_path(); + self.fill_path(bp, if op == EoFill { FillRule::EvenOdd } else { FillRule::NonZero })?; + } + Stroke => { + let bp = self.take_path(); + self.stroke_path(bp)?; + } + RectFill => { + let bp = self.rects()?; + self.fill_path(bp, FillRule::NonZero)?; + } + RectStroke => { + let m = match self.stack.last() { + Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?), + _ => None, + }; + let mut bp = self.rects()?; + if let Some(m) = m { + let xf = self.xf(); + bp.apply_affine(xf * m * xf.inverse()); + } + self.stroke_path(bp)?; + } + ShFill => { + let d = self.pop_dict()?; + if self.g.null { + return Ok(()); + } + if is_mesh(&d) { + let m = self.xf(); + return self.mesh_fill(&d, m); + } + let m = self.xf(); + if let Some(g) = self.gradient(&d, m)? { + let area = self.clip_bounds()?; + let paint = Paint::Gradient(Box::new(g)); + let clips = self.g.clips.clone(); + self.out.fill(area.to_path(0.1), FillRule::NonZero, paint, self.g.overprint, &clips); + if let Some((_, n)) = self.out.drawn.last() { + let id = n.id; + self.out.shadings.insert(id); + } + } + } + _ => self.data_op(op)?, + } + Ok(()) + } +} diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs new file mode 100644 index 000000000..f2e343858 --- /dev/null +++ b/crates/eps/src/import/graphics/paint.rs @@ -0,0 +1,495 @@ +use super::state::{GState, Space, process, tiling_hash}; +use super::*; + +impl Interp<'_> { + // ---------- colour ---------- + + /// A colour space operand. + pub(in super::super) fn space_of(&mut self, o: &Obj, depth: u32) -> Res { + if depth > 4 { + return ps_err("limitcheck", "setcolorspace"); + } + let (family, items) = match o { + Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]), + Obj::Array { items, .. } => { + let v = items.to_vec(); + let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?; + (n, v) + } + _ => return ps_err("typecheck", "setcolorspace"), + }; + let arg = |i: usize| items.get(i).cloned().ok_or(PsError::Ps("rangecheck", "setcolorspace".into())); + Ok(match &*family { + "DeviceGray" | "CIEBasedA" | "CalGray" => Space::Gray, + "DeviceRGB" | "CIEBasedABC" | "CalRGB" | "Lab" | "CIEBasedDEF" => Space::Rgb, + "DeviceCMYK" | "CIEBasedDEFG" => Space::Cmyk, + "Pattern" => Space::Pattern(match items.get(1) { + Some(base) => Some(Rc::new(self.space_of(base, depth + 1)?)), + None => None, + }), + "ICCBased" => match arg(1)? { + Obj::Dict(d) => match d.borrow().get(&Key::name("N")).and_then(Obj::as_num) { + Some(1.0) => Space::Gray, + Some(4.0) => Space::Cmyk, + _ => Space::Rgb, + }, + _ => Space::Rgb, + }, + "Separation" | "DeviceN" => { + let names: Vec> = match arg(1)? { + Obj::Array { items, .. } => items.borrow().iter().filter_map(Obj::text).collect(), + o => o.text().into_iter().collect(), + }; + if names.len() > MAX_INKS { + return ps_err("limitcheck", "DeviceN"); + } + let alt = self.space_of(&arg(2)?, depth + 1)?; + Space::Inks { names, alt: Rc::new(alt), tint: arg(3)? } + } + "Indexed" => { + let base = self.space_of(&arg(1)?, depth + 1)?; + let hival = + arg(2)?.as_num().filter(|v| (0.0..=MAX_HIVAL as f64).contains(v)).ok_or(PsError::Ps("rangecheck", "Indexed".into()))? as usize; + let n = base.n(); + let table: Vec = match arg(3)? { + Obj::Str(s) => s.borrow().iter().map(|b| f64::from(*b) / 255.0).collect(), + p @ Obj::Array { .. } => { + let mut t = Vec::with_capacity((hival + 1) * n); + for i in 0..=hival { + self.push(Obj::Int(i as i64))?; + self.call(p.clone())?; + let mut c = vec![0.0; n]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + t.extend(c); + } + t + } + _ => return ps_err("typecheck", "Indexed"), + }; + Space::Indexed { base: Rc::new(base), table: Rc::new(table) } + } + _ => return ps_err("undefined", &family), + }) + } + + pub(super) fn set_space(&mut self, o: Obj) -> Res { + let space = self.space_of(&o, 0)?; + let comps = space.initial(); + self.g.space = Rc::new(space); + self.g.space_obj = o; + self.set_comps(comps) + } + + /// Set the colour to `comps` in the current space. + pub(super) fn set_comps(&mut self, comps: Vec) -> Res { + let space = self.g.space.clone(); + self.g.mesh = None; + self.g.paint = self.paint_of(&space, &comps)?; + self.g.comps = comps; + Ok(()) + } + + /// Set a process colour, its space with it. + pub(super) fn set_process(&mut self, space: Space, name: &str, comps: Vec) -> Res { + self.g.space = Rc::new(space); + self.g.space_obj = Obj::name(name); + self.set_comps(comps) + } + + /// The paint of colour `comps` in `space`: a spot ink links to its swatch at the tint. + pub fn paint_of(&mut self, space: &Space, comps: &[f64]) -> Res { + Ok(match space { + Space::Inks { names, alt, tint } => { + let t = comps.first().copied().unwrap_or(1.0).clamp(0.0, 1.0) as f32; + match names.as_slice() { + [one] if &**one == "None" => Paint::None, + [one] if &**one == "All" => { + Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), swatch: Some(REGISTRATION.into()), tint: t } + } + [one] => { + let full = match self.out.spot_colors.get(one) { + Some(c) => *c, + None => { + let c = self.tint_color(alt, tint, &[1.0])?; + self.out.spots.push((one.to_string(), c)); + // The tint transform may have listed this ink already: the color + // listed first stays. + self.out.spot_colors.entry(one.clone()).or_insert(c); + c + } + }; + Paint::Solid { color: full.tinted(t), swatch: Some(one.to_string()), tint: t } + } + _ => Paint::solid(self.tint_color(alt, tint, comps)?), + } + } + Space::Indexed { base, table } => { + let n = base.n(); + let i = comps.first().copied().unwrap_or(0.0).round().max(0.0) as usize; + let c = table.get(i * n..i * n + n).ok_or(PsError::Ps("rangecheck", "setcolor".into()))?.to_vec(); + Paint::solid(process(base, &c).unwrap_or(Color::BLACK)) + } + Space::Pattern(_) => Paint::None, + s => Paint::solid(process(s, comps).unwrap_or(Color::BLACK)), + }) + } + + /// The alternate colour of inks at `comps`, through the tint transform. + pub fn tint_color(&mut self, alt: &Space, tint: &Obj, comps: &[f64]) -> Res { + let base = self.stack.len(); + for c in comps { + self.push_num(*c)?; + } + self.call(tint.clone())?; + let n = alt.n(); + let at = self.stack.len().checked_sub(n).filter(|a| *a >= base).ok_or(PsError::Ps("stackunderflow", "tint transform".into()))?; + let v: Vec = self.stack.split_off(at).iter().map(|o| o.as_num().unwrap_or(0.0)).collect(); + self.stack.truncate(base); + Ok(process(alt, &v).unwrap_or(Color::BLACK)) + } + + /// The current colour as grey, RGB or CMYK components (`currentgray` …). + pub(super) fn current_rgb(&self) -> [f32; 3] { + self.g.paint.color().map_or([0.0; 3], |c| c.to_rgb_uncalibrated()) + } + + /// `setcolor` in a pattern space: the pattern operand, and before it an uncoloured + /// pattern's colour in the underlying space. + pub(super) fn set_pattern(&mut self, base: Option>) -> Res { + let Obj::Dict(d) = self.pop()? else { return ps_err("typecheck", "") }; + let uncoloured = d.borrow().get(&Key::name("PaintType")).and_then(Obj::as_num) == Some(2.0); + let under = match base.filter(|_| uncoloured) { + Some(base) => { + let mut c = vec![0.0; base.n()]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + Some(self.paint_of(&base, &c)?) + } + None => None, + }; + self.g.mesh = None; + self.g.paint = self.pattern_paint(&d, under)?; + Ok(()) + } + + /// A pattern colour: a shading pattern's gradient, a tiling pattern's swatch (`under`: the + /// colour an uncoloured one paints in). + fn pattern_paint(&mut self, d: &DictRef, under: Option) -> Res { + let kind = d.borrow().get(&Key::name("PatternType")).and_then(Obj::as_num); + let sh = d.borrow().get(&Key::name("Shading")).cloned(); + let m = d.borrow().get(&Key::name("VCMatrix")).and_then(|o| o.items().and_then(|i| crate::import::interp::matrix_of(&i.borrow()))); + // Pattern space → the document. + let m = self.out.page * m.unwrap_or(self.g.ctm); + match (kind, sh) { + (Some(2.0), Some(Obj::Dict(sh))) if is_mesh(&sh) => { + self.g.mesh = Some((sh, m)); + Ok(Paint::None) + } + (Some(2.0), Some(Obj::Dict(sh))) => Ok(self.gradient(&sh, m)?.map_or(Paint::None, |g| Paint::Gradient(Box::new(g)))), + (Some(2.0), _) => ps_err("typecheck", "Shading"), + _ => self.tiling(d, under, m), + } + } + + /// A tiling pattern as a pattern swatch (made the first time): its `PaintProc` drawn in + /// pattern space, one cell of `XStep` × `YStep` from the corner of its `BBox`; `m` maps pattern + /// space onto the document. + fn tiling(&mut self, d: &DictRef, under: Option, m: Affine) -> Res { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let [x0, y0, x1, y1] = bbox[..] else { return ps_err("rangecheck", "BBox") }; + let step = |k: &str| get(k).and_then(|o| o.as_num()).map(f64::abs).filter(|v| v.is_finite() && *v > 1e-9 && *v < LIMIT); + let (Some(xs), Some(ys)) = (step("XStep"), step("YStep")) else { return ps_err("rangecheck", "XStep") }; + let tile = Rect::from_origin_size((x0.min(x1), y0.min(y1)), (xs, ys)); + let xf = m * Affine::translate(tile.origin().to_vec2()); + let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; + let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; + let hash = tiling_hash(&key, tile, under.as_ref()); + let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + if let Some((.., name)) = made { + return Ok(Paint::Pattern { pattern: name.clone(), xf }); + } + if self.out.apart >= MAX_APART { + self.out.warn(NESTED_PATTERNS); + return Ok(Paint::solid(Color::gray(0.5))); + } + let paint = under.clone(); + let art = self.draw_apart(Rect::new(x0, y0, x1, y1).abs(), |it| { + if let Some(p) = paint { + it.g.paint = p; + } + it.push(Obj::Dict(d.clone()))?; + it.call(proc) + })?; + if art.is_empty() { + return Ok(Paint::None); + } + // The document gets its patterns only here, so the n made so far are Pattern 1 to + // Pattern n. + let name = format!("Pattern {}", self.out.doc.patterns.len() + 1); + let mut def = PatternDef::new(&name, art); + def.tile = tile; + self.out.doc.patterns.push(def); + // A cell with a NaN corner equals no cell, so the lookup above could never find it. + if !tile.is_nan() { + self.out.tilings.entry(hash).or_insert_with(|| Vec::with_capacity(1)).push((key, tile, under, name.clone())); + } + Ok(Paint::Pattern { pattern: name, xf }) + } + + /// The art `draw` draws on its own, in its own space (user space is the document's, the page + /// `frame`), from a fresh graphics state with the current font: a pattern's cell, a glyph. + pub fn draw_apart(&mut self, frame: Rect, draw: impl FnOnce(&mut Self) -> Res) -> Res>> { + let fresh = GState { font: self.g.font.clone(), ..GState::default() }; + let g = std::mem::replace(&mut self.g, fresh); + let saved = std::mem::take(&mut self.saved); + let drawn = std::mem::take(&mut self.out.drawn); + let page = std::mem::replace(&mut self.out.page, Affine::IDENTITY); + let frame = std::mem::replace(&mut self.out.frame, frame); + let merge = self.out.merge.take(); + // The art isn't in the groups open around it. + let groups = std::mem::take(&mut self.out.groups); + let too_deep = std::mem::take(&mut self.out.too_deep); + self.out.apart += 1; + let r = draw(self); + self.out.apart -= 1; + let art = std::mem::replace(&mut self.out.drawn, drawn); + (self.out.page, self.out.frame, self.out.merge) = (page, frame, merge); + (self.out.groups, self.out.too_deep) = (groups, too_deep); + (self.g, self.saved) = (g, saved); + r?; + let mut nodes = vectorcraft_doc::clipnest::nest(&mut self.out.doc, art); + if !self.out.shadings.is_empty() { + collapse(&mut nodes, &self.out.shadings); + } + Ok(nodes) + } + + /// An axial or radial shading as a gradient, `m` mapping its space onto the document. + pub(super) fn gradient(&mut self, sh: &DictRef, m: Affine) -> Res> { + let get = |k: &str| sh.borrow().get(&Key::name(k)).cloned(); + let kind = get("ShadingType").and_then(|o| o.as_num()).unwrap_or(0.0); + if !is_gradient(sh) { + self.out.warn(UNKNOWN_SHADINGS); + return Ok(None); + } + let space = self.space_of(&get("ColorSpace").ok_or(PsError::Ps("undefined", "ColorSpace".into()))?, 0)?; + let nums = |o: Option| -> Vec { + o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() + }; + let coords = nums(get("Coords")); + let domain = nums(get("Domain")); + let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); + let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?; + // Where the colour changes: the ends, and the bounds of stitched linear functions (unless + // there are too many). + let mut visits = 0; + let points = linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS); + let stitched = points.is_some(); + let ts: Vec = match points { + Some(mut pts) => { + pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); + pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect() + } + None => (0..=SHADING_SAMPLES).map(|i| i as f64 / SHADING_SAMPLES as f64).collect(), + }; + let mut ts = ts; + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let radial = kind == 3.0; + let c = |i: usize| coords.get(i).copied().unwrap_or(0.0); + let (r0, r1) = (c(2).max(0.0), c(5).max(1e-6)); + let mut stops = Vec::with_capacity(ts.len()); + let stop = |paint: Paint, offset: f64| match paint { + Paint::Solid { color, swatch, tint } => GradientStop { swatch, tint, ..GradientStop::new(offset as f32, color) }, + _ => GradientStop::new(offset as f32, Color::BLACK), + }; + for t in ts { + let comps = self.eval(&f, &[d0 + (d1 - d0) * t], 0)?; + let paint = self.paint_of(&space, &comps)?; + let offset = if radial { (r0 + (r1 - r0) * t) / r1 } else { t }; + // At a bound of a stitched function the pieces need not meet (a hard colour change): + // the colour just before the bound is a stop at the same offset, ahead of the one + // after it. + if stitched && t > 0.0 && t < 1.0 { + let comps = self.eval(&f, &[d0 + (d1 - d0) * (t - 1e-9)], 0)?; + let before = stop(self.paint_of(&space, &comps)?, offset); + if before.color != stop(paint.clone(), offset).color { + stops.push(before); + } + } + stops.push(stop(paint, offset)); + } + let (gk, geom) = if radial { + let centre = Point::new(c(3), c(4)); + let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1, 0.0), aspect: 1.0, focal: None }; + geom.set_focal(Some(Point::new(c(0), c(1)))); + geom.transform(m, GradientKind::Radial); + (GradientKind::Radial, geom) + } else { + (GradientKind::Linear, GradientGeom { start: m * Point::new(c(0), c(1)), end: m * Point::new(c(2), c(3)), aspect: 1.0, focal: None }) + }; + let mut g = GradientPaint::new(Gradient::new(gk, stops)); + g.geom = Some(geom); + Ok(Some(g)) + } + + /// A function (types 0, 2 and 3; an array of them, one per component) at `x` (types 2 and 3 + /// take one input). + pub fn eval(&mut self, f: &Obj, x: &[f64], depth: u32) -> Res> { + self.eval_inner(f, x, depth, &mut 0) + } + + /// [`Self::eval`], with `visits` (shared by the whole recursion, incremented on every call) + /// counting the functions visited against [`MAX_FUNCTION_VISITS`], as [`linear_points`] counts + /// its nodes. The depth limit alone stops a function that refers to itself, but not one that + /// fans out through shared arrays: when four levels of arrays each have 100 entries that all + /// refer to the same array of the level below (at the last level, the same function), one + /// evaluation would visit 101,010,101 functions. Each function visited also counts + /// [`FUNCTION_OPS`] against the operation budget. + fn eval_inner(&mut self, f: &Obj, x: &[f64], depth: u32, visits: &mut usize) -> Res> { + *visits += 1; + if depth > 8 || *visits > MAX_FUNCTION_VISITS { + return ps_err("limitcheck", "Function"); + } + self.spend(FUNCTION_OPS)?; + let d = match f { + Obj::Dict(d) => d.clone(), + Obj::Array { items, .. } => { + // Each entry is at least one more function: an array with more entries than the + // visits left raises `limitcheck` before it is copied. + if items.len() > MAX_FUNCTION_VISITS - *visits { + return ps_err("limitcheck", "Function"); + } + let fs = items.to_vec(); + let mut out = vec![]; + for g in &fs { + out.extend(self.eval_inner(g, x, depth + 1, visits)?.first().copied()); + } + return Ok(out); + } + _ => return ps_err("typecheck", "Function"), + }; + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let nums = |o: Option| -> Vec { + o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() + }; + let domain = nums(get("Domain")); + let t = x.first().copied().unwrap_or(0.0); + let t = match domain.as_slice() { + [a, b, ..] => clamp_between(t, *a, *b), + _ => t, + }; + match get("FunctionType").and_then(|o| o.as_num()) { + Some(0.0) => self.sampled(&d, x), + Some(2.0) => { + let c0 = nums(get("C0")); + let c1 = nums(get("C1")); + let (c0, c1) = (if c0.is_empty() { vec![0.0] } else { c0 }, if c1.is_empty() { vec![1.0] } else { c1 }); + let n = get("N").and_then(|o| o.as_num()).unwrap_or(1.0); + let x = t.max(0.0).powf(n); + Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect()) + } + Some(3.0) => { + // Read the pieces through the borrow and clone only the one we recurse into: a + // `Functions` array of millions (shared, or reached again through a cycle) must + // not be copied on every call and at every live recursion level. + let functions = get("Functions"); + let fs = functions.as_ref().and_then(Obj::items); + let n = fs.map_or(0, Shared::len); + let bounds = nums(get("Bounds")); + let encode = nums(get("Encode")); + let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); + let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1)); + let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) }; + let b = bounds.get(k).copied().unwrap_or(hi); + let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); + let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 }; + let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?; + self.eval_inner(&g, &[u], depth + 1, visits) + } + _ => { + self.out.warn(UNKNOWN_FUNCTIONS); + let r = nums(get("Range")); + Ok(r.chunks(2).map(|p| p.iter().sum::() / 2.0).collect()) + } + } + } +} + +/// Is shading `sh` axial or radial (a gradient)? +fn is_gradient(sh: &DictRef) -> bool { + matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(2.0 | 3.0)) +} + +/// Is shading `sh` one [`Interp::mesh_fill`] paints? +pub(super) fn is_mesh(sh: &DictRef) -> bool { + matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(1.0 | 4.0 | 5.0 | 6.0 | 7.0)) +} + +/// Where a stitched function of linear interpolations changes slope (its bounds), when it is +/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way +/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on +/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a +/// function that refers to itself in a cycle, but not one that fans out through shared children +/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A +/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it +/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off. +fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option> { + *visits += 1; + if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) { + return None; + } + let Obj::Dict(d) = f else { return None }; + let d = d.borrow(); + let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); + match num("FunctionType")? { + 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), + 3.0 => { + let items = d.get(&Key::name("Functions"))?.items()?; + // More pieces than the stop cap allows: sample the function instead (tested before + // `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop + // runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge + // `Functions` would still be O(stops * Functions) to find each piece without this. + if items.len() > MAX_STITCH_STOPS + 1 { + return None; + } + let fs = items.to_vec(); + if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) { + return None; + } + d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()) + } + _ => None, + } +} + +/// The clip path a clipping group of one shading rectangle (`shfill` through a clip) can be +/// filled with instead. +pub(crate) fn collapse(nodes: &mut [Arc], shadings: &HashSet) { + for n in nodes.iter_mut() { + let NodeKind::Group { children, clip: true } = &n.kind else { + if let Some(c) = Arc::make_mut(n).children_mut() { + collapse(c, shadings); + } + continue; + }; + if let [clip, fill] = children.as_slice() + && shadings.contains(&fill.id) + && let (NodeKind::Path { path, rule, clipping: true, .. }, NodeKind::Path { .. }) = (&clip.kind, &fill.kind) + { + let mut p = Node::path(fill.id, path.clone(), (*fill.appearance).clone()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = *rule; + } + *n = Arc::new(p); + continue; + } + if let Some(c) = Arc::make_mut(n).children_mut() { + collapse(c, shadings); + } + } +} diff --git a/crates/eps/src/import/graphics/path.rs b/crates/eps/src/import/graphics/path.rs new file mode 100644 index 000000000..3403e9add --- /dev/null +++ b/crates/eps/src/import/graphics/path.rs @@ -0,0 +1,428 @@ +use super::state::{ccw_turn, turn}; +use super::*; + +impl Interp<'_> { + /// User space → document space. + pub fn xf(&self) -> Affine { + self.out.page * self.g.ctm + } + + /// How much user-space lengths grow in the document. + pub fn scale(&self) -> f64 { + let s = self.xf().determinant().abs().sqrt(); + if s.is_finite() { s } else { 0.0 } + } + + /// The current point in user space. + pub fn user_point(&self) -> Res { + let p = self.g.cur.ok_or(PsError::Ps("nocurrentpoint", String::new()))?; + let inv = self.xf(); + if inv.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + Ok(inv.inverse() * p) + } + + pub fn gsave(&mut self) -> Res { + if self.saved.len() >= MAX_GSAVE { + return Err(PsError::Limit("too many saved graphics states")); + } + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.saved.push(self.g.clone()); + Ok(()) + } + + pub fn grestore(&mut self) { + if let Some(g) = self.saved.pop() { + self.g = g; + } + } + + // ---------- paths ---------- + + /// Room for one more element in the current path. + pub fn grow(&self) -> Res { + if self.g.path.elements().len() >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + Ok(()) + } + + /// Count `n` path elements an operator makes, reads or copies against the operation budget, + /// an operation each: making or reading an element takes up to about as long as an operation, + /// and copying one takes less (measured). + pub(super) fn spend_elements(&mut self, n: usize) -> Res { + self.spend(n as u64) + } + + pub(super) fn move_to(&mut self, p: Point) -> Res { + self.grow()?; + // Consecutive movetos: the last one counts. + if let Some(PathEl::MoveTo(_)) = self.g.path.elements().last() { + self.g.path.pop(); + } + self.g.path.move_to(p); + self.g.cur = Some(p); + self.g.start = Some(p); + Ok(()) + } + + pub(super) fn line_to(&mut self, p: Point) -> Res { + self.grow()?; + if self.g.cur.is_none() { + return ps_err("nocurrentpoint", ""); + } + self.g.path.line_to(p); + self.g.cur = Some(p); + Ok(()) + } + + /// Path elements in user space appended (the first move a line when there is a current point), + /// each counted against the operation budget. + fn append_user(&mut self, els: impl Iterator) -> Res { + let xf = self.xf(); + for el in els { + self.spend_elements(1)?; + match el { + PathEl::MoveTo(p) => { + let p = xf * p; + if self.g.cur.is_some() { self.line_to(p)? } else { self.move_to(p)? } + } + PathEl::LineTo(p) => self.line_to(xf * p)?, + PathEl::QuadTo(a, p) => { + self.grow()?; + self.g.path.quad_to(xf * a, xf * p); + self.g.cur = Some(xf * p); + } + PathEl::CurveTo(a, b, p) => { + self.grow()?; + self.g.path.curve_to(xf * a, xf * b, xf * p); + self.g.cur = Some(xf * p); + } + PathEl::ClosePath => { + self.grow()?; + self.g.path.close_path(); + } + } + } + Ok(()) + } + + /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). + pub(super) fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { + // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it + // until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps, + // and the first endpoint, are worked out from what is left of each angle after whole turns. + let sweep = if ccw && a2 < a1 { + ccw_turn(a1, a2) + } else if !ccw && a2 > a1 { + -ccw_turn(a2, a1) + } else { + a2 - a1 + }; + let tau = std::f64::consts::TAU; + let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0); + let a1 = (a1 % 360.0).to_radians(); + let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); + self.append_user(std::iter::once(PathEl::MoveTo(start)))?; + let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); + let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); + self.append_user(arc.append_iter(tol)) + } + + /// `arct` / `arcto`: a line towards `p1` rounded with radius `r` into the line to `p2`; the + /// tangent points (user space). + pub(super) fn arct(&mut self, p1: Point, p2: Point, r: f64) -> Res<[Point; 2]> { + let p0 = self.user_point()?; + let (u, v) = (p0 - p1, p2 - p1); + let (lu, lv) = (u.hypot(), v.hypot()); + let cross = u.cross(v); + if lu < 1e-12 || lv < 1e-12 || cross.abs() < 1e-12 || r <= 0.0 { + self.append_user(std::iter::once(PathEl::LineTo(p1)))?; + return Ok([p1, p1]); + } + let (u, v) = (u / lu, v / lv); + let half = u.dot(v).clamp(-1.0, 1.0).acos() / 2.0; + let dist = r / half.tan(); + let (t1, t2) = (p1 + u * dist, p1 + v * dist); + let bis = (u + v).normalize(); + let c = p1 + bis * (r / half.sin()); + let (a1, a2) = ((t1 - c).atan2(), (t2 - c).atan2()); + self.append_user(std::iter::once(PathEl::LineTo(t1)))?; + let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); + let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, turn(a1, a2), 0.0); + self.append_user(arc.append_iter(tol))?; + Ok([t1, t2]) + } + + fn rect_path(&self, x: f64, y: f64, w: f64, h: f64) -> BezPath { + let xf = self.xf(); + let mut bp = BezPath::new(); + bp.move_to(xf * Point::new(x, y)); + bp.line_to(xf * Point::new(x + w, y)); + bp.line_to(xf * Point::new(x + w, y + h)); + bp.line_to(xf * Point::new(x, y + h)); + bp.close_path(); + bp + } + + /// The rectangles of `rectfill`, `rectstroke` and `rectclip`: `x y w h` or an array of them. + pub(super) fn rects(&mut self) -> Res { + let nums: Vec = match self.stack.last() { + Some(Obj::Array { .. }) => { + let items = self.pop_array()?; + // Five elements a rectangle, counted before the array is read. + self.spend_elements(items.len() / 4 * 5)?; + let v: Option> = items.borrow().iter().map(Obj::as_num).collect(); + v.ok_or(PsError::Ps("typecheck", String::new()))? + } + _ => self.nums::<4>()?.to_vec(), + }; + if nums.len() / 4 * 5 >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + let mut bp = BezPath::new(); + for [x, y, w, h] in nums.as_chunks::<4>().0 { + bp.extend(self.rect_path(*x, *y, *w, *h)); + } + Ok(bp) + } + + // ---------- painting ---------- + + pub(super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res { + if bp.elements().is_empty() || self.g.null { + return Ok(()); + } + // A mesh pattern paints its shading inside the path. + if let Some((sh, m)) = self.g.mesh.clone() { + let clips = self.g.clips.clone(); + self.clip_with(bp, rule); + let r = self.mesh_fill(&sh, m); + self.g.clips = clips; + return r; + } + if self.g.paint.is_none() { + return Ok(()); + } + let (paint, overprint) = (self.g.paint.clone(), self.g.overprint); + self.out.fill(bp, rule, paint, overprint, &self.g.clips); + Ok(()) + } + + pub(super) fn stroke_path(&mut self, bp: BezPath) -> Res { + if bp.elements().is_empty() || self.g.paint.is_none() || self.g.null { + return Ok(()); + } + let st = self.stroke_layer()?; + self.out.stroke(bp, st, &self.g.clips); + Ok(()) + } + + /// The stroke the graphics state draws, in document space. Each entry of the dash pattern it + /// copies counts as an operation. + pub(super) fn stroke_layer(&mut self) -> Res { + self.spend(self.g.dash.len() as u64)?; + let k = self.scale(); + let w = self.g.width * k; + let mut st = StrokeLayer::new(self.g.paint.clone(), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); + st.cap = self.g.cap; + st.join = self.g.join; + st.miter_limit = self.g.miter.clamp(1.0, 500.0); + st.overprint = self.g.overprint; + let pattern: Vec = self.g.dash.iter().map(|d| d * k).collect(); + let dash = Dash { pattern, offset: self.g.dash_offset * k, align_corners: false }; + st.dash = dash.is_dashed().then_some(dash); + Ok(st) + } + + /// The current path in user space, each element as its operator and points (quadratic + /// segments as cubic ones). + fn user_elements(&self) -> Res)>> { + let xf = self.xf(); + if xf.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let inv = xf.inverse(); + let mut last = Point::ZERO; + let mut out = Vec::with_capacity(self.g.path.elements().len()); + for el in self.g.path.elements() { + let (op, pts) = match *el { + PathEl::MoveTo(p) => (Op::MoveTo, vec![p]), + PathEl::LineTo(p) => (Op::LineTo, vec![p]), + PathEl::QuadTo(a, p) => (Op::CurveTo, vec![last + (a - last) * (2.0 / 3.0), p + (a - p) * (2.0 / 3.0), p]), + PathEl::CurveTo(a, b, p) => (Op::CurveTo, vec![a, b, p]), + PathEl::ClosePath => (Op::ClosePath, vec![]), + }; + last = pts.last().copied().unwrap_or(last); + out.push((op, pts.into_iter().map(|p| inv * p).collect())); + } + Ok(out) + } + + /// `pathforall`: the current path in user space, each element through its procedure. + pub(super) fn path_for_all(&mut self) -> Res { + let close = self.pop_proc()?; + let curve = self.pop_proc()?; + let line = self.pop_proc()?; + let mv = self.pop_proc()?; + self.spend_elements(self.g.path.elements().len())?; + for (op, pts) in self.user_elements()? { + for p in pts { + self.push_num(p.x)?; + self.push_num(p.y)?; + } + let proc = match op { + Op::MoveTo => &mv, + Op::LineTo => &line, + Op::CurveTo => &curve, + _ => &close, + }; + if !self.body(proc)? { + break; + } + } + Ok(()) + } + + /// Append user path `up` to the current path: a procedure of path operators, or the encoded + /// form (numbers and a string of operator codes). + pub(super) fn user_path(&mut self, up: Obj) -> Res { + let items = up.items().cloned().ok_or(PsError::Ps("typecheck", "a user path".into()))?; + let encoded = match (items.get(0), items.get(1), items.len()) { + (Some(nums @ (Obj::Array { .. } | Obj::Str(_))), Some(Obj::Str(ops)), 2) => Some((nums, ops.to_vec())), + _ => None, + }; + let Some((nums, ops)) = encoded else { return self.call(Obj::Array { items, exec: true }) }; + // The operators by code, with how many operands each takes. + const OPS: [(Op, usize); 12] = [ + (Op::SetBBox, 4), + (Op::MoveTo, 2), + (Op::RMoveTo, 2), + (Op::LineTo, 2), + (Op::RLineTo, 2), + (Op::CurveTo, 6), + (Op::RCurveTo, 6), + (Op::Arc, 5), + (Op::Arcn, 5), + (Op::Arct, 5), + (Op::ClosePath, 0), + (Op::UCache, 0), + ]; + let nums: Vec = match nums { + Obj::Array { items, .. } => { + // Each entry of the data array and each byte of the operator string counts as an + // element read. + self.spend_elements(items.len().saturating_add(ops.len()))?; + items.borrow().iter().filter_map(Obj::as_num).collect() + } + _ => return ps_err("typecheck", "an encoded user path"), + }; + let mut next = nums.into_iter(); + let mut repeat = 1usize; + for code in ops { + if code >= 32 { + repeat = usize::from(code - 32); + continue; + } + let &(op, n) = OPS.get(usize::from(code)).ok_or(PsError::Ps("rangecheck", "a user path".into()))?; + for _ in 0..std::mem::replace(&mut repeat, 1) { + for _ in 0..n { + let v = next.next().ok_or(PsError::Ps("rangecheck", "a user path".into()))?; + self.push_num(v)?; + } + // Each operator run counts as an operation. + self.spend(1)?; + self.op(op)?; + } + } + Ok(()) + } + + /// `ufill`, `ueofill`, `ustroke` (with an optional matrix): paint a user path, keeping the + /// current path. + pub(super) fn paint_user_path(&mut self, op: Op) -> Res { + let m = match (op, self.stack.last()) { + (Op::UStroke, Some(Obj::Array { items, exec: false })) if items.len() == 6 && self.stack.len() >= 2 => Some(self.pop_matrix()?), + _ => None, + }; + let up = self.pop()?; + self.gsave()?; + let depth = self.saved.len(); + self.take_path(); + let r = self.user_path(up).and_then(|()| { + if let Some(m) = m { + self.g.ctm *= m; + } + let bp = self.take_path(); + match op { + Op::UStroke => self.stroke_path(bp), + _ => self.fill_path(bp, if op == Op::UEoFill { FillRule::EvenOdd } else { FillRule::NonZero }), + } + }); + self.saved.truncate(depth); + self.grestore(); + r + } + + /// `upath`: the current path as a user path (a procedure, in user space). + pub(super) fn upath(&mut self) -> Res { + self.pop_bool()?; + let elements = self.user_elements()?; + let b = self.xf().inverse().transform_rect_bbox(self.g.path.bounding_box()); + let mut items: Vec = [b.x0, b.y0, b.x1, b.y1].into_iter().map(Obj::Real).collect(); + items.push(Obj::Op(Op::SetBBox)); + for (op, pts) in elements { + items.extend(pts.iter().flat_map(|p| [Obj::Real(p.x), Obj::Real(p.y)])); + items.push(Obj::Op(op)); + } + self.alloc(items.len() * std::mem::size_of::())?; + self.push(Obj::proc(items)) + } + + /// `execform`: the form's `PaintProc` run with its matrix, clipped to its box. + pub(super) fn exec_form(&mut self) -> Res { + let d = self.pop_dict()?; + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let m = get("Matrix").and_then(|o| o.items().and_then(|i| crate::import::interp::matrix_of(&i.borrow()))).unwrap_or(Affine::IDENTITY); + let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let paint = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; + self.gsave()?; + let depth = self.saved.len(); + self.g.ctm *= m; + if let [x0, y0, x1, y1] = bbox[..] { + let bp = self.rect_path(x0, y0, x1 - x0, y1 - y0); + self.take_path(); + self.clip_with(bp, FillRule::NonZero); + } + self.push(Obj::Dict(d))?; + let r = self.call(paint); + self.saved.truncate(depth); + self.grestore(); + r + } + + pub(in super::super) fn take_path(&mut self) -> BezPath { + self.g.cur = None; + self.g.start = None; + std::mem::take(&mut self.g.path) + } + + pub(super) fn clip_with(&mut self, path: BezPath, rule: FillRule) { + let Some(c) = self.out.clip(path, rule) else { return }; + if self.g.clips.len() < MAX_NEST { + self.g.clips.push(c); + } else { + self.out.warn(&deep_clips()); + } + } + + /// The area the clip leaves, in document space (the page without a clip). The clip path read + /// counts against the operation budget. + pub(super) fn clip_bounds(&mut self) -> Res { + let (bounds, len) = + self.g.clips.iter().rev().find_map(|c| c.region.as_ref()).map_or((self.out.frame, 0), |(p, _)| (p.bounding_box(), p.elements().len())); + self.spend_elements(len)?; + Ok(bounds) + } +} diff --git a/crates/eps/src/import/graphics/state.rs b/crates/eps/src/import/graphics/state.rs new file mode 100644 index 000000000..6aa25d5a9 --- /dev/null +++ b/crates/eps/src/import/graphics/state.rs @@ -0,0 +1,328 @@ +use super::*; + +/// A colour space. +#[derive(Clone, Debug)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + /// Separation and DeviceN: the ink names, the alternate space and the tint transform. + Inks { + names: Vec>, + alt: Rc, + tint: Obj, + }, + Indexed { + base: Rc, + table: Rc>, + }, + /// Patterns; an uncoloured one paints in a colour of the underlying space. + Pattern(Option>), +} + +impl Space { + /// Components a colour has in it. + pub fn n(&self) -> usize { + match self { + Self::Gray | Self::Indexed { .. } | Self::Pattern(_) => 1, + Self::Rgb => 3, + Self::Cmyk => 4, + Self::Inks { names, .. } => names.len().max(1), + } + } + + /// The colour `setcolorspace` starts with. + pub(super) fn initial(&self) -> Vec { + match self { + Self::Cmyk => vec![0.0, 0.0, 0.0, 1.0], + Self::Inks { names, .. } => vec![1.0; names.len().max(1)], + s => vec![0.0; s.n()], + } + } +} + +/// A process colour from components in `space` (grey, RGB or CMYK), each clamped to 0–1. +pub(crate) fn process(space: &Space, c: &[f64]) -> Option { + let v = |i: usize| c.get(i).map_or(0.0, |x| if x.is_finite() { x.clamp(0.0, 1.0) as f32 } else { 0.0 }); + Some(match space { + Space::Gray => Color::gray(1.0 - v(0)), + Space::Rgb => Color::rgb(v(0), v(1), v(2)), + Space::Cmyk => Color::cmyk(v(0), v(1), v(2), v(3)), + _ => return None, + }) +} + +#[derive(Clone, Debug)] +pub(crate) struct GState { + /// User space → PostScript's default space. + pub ctm: Affine, + pub space: Rc, + /// The `setcolorspace` operand, for `currentcolorspace`. + pub space_obj: Obj, + pub comps: Vec, + pub paint: Paint, + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + /// The dash pattern, shared by the states saved or copied from this one. + pub dash: Rc<[f64]>, + pub dash_offset: f64, + /// The current path, in document space. + pub path: BezPath, + pub cur: Option, + pub start: Option, + pub clips: Vec>, + /// The clips `clipsave` saved, for `cliprestore`. + pub saved_clips: Vec]>>, + pub overprint: bool, + pub font: Option, + /// `nulldevice`: painting draws nothing. + pub null: bool, + /// The colour is a shading pattern whose shading is a mesh: a fill paints it (the shading + /// and pattern space → the document). + pub mesh: Option<(DictRef, Affine)>, +} + +impl Default for GState { + fn default() -> Self { + Self { + ctm: Affine::IDENTITY, + space: Rc::new(Space::Gray), + space_obj: Obj::name("DeviceGray"), + comps: vec![0.0], + paint: Paint::solid(Color::gray(1.0)), + width: 1.0, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 10.0, + dash: Rc::from([]), + dash_offset: 0.0, + path: BezPath::new(), + cur: None, + start: None, + clips: vec![], + saved_clips: vec![], + overprint: false, + font: None, + null: false, + mesh: None, + } + } +} + +/// What the program draws. +pub(crate) struct Out { + /// The document being made: ids, images and swatches. + pub doc: Document, + pub drawn: Drawn, + pub warnings: Vec, + /// PostScript's default space → document space. + pub page: Affine, + /// The page (the bounding box) in document space. + pub frame: Rect, + /// Clip and group ids are given in the order they open, so a chain of both sorts by id. + next_clip: u32, + /// The groups open (outermost first), and how many more opened past [`MAX_NEST`] (their art + /// goes in the innermost group kept). + pub(super) groups: Vec>, + pub(super) too_deep: usize, + /// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]). + elements: usize, + /// The path the last object was filled with: a stroke of the same path right after joins it. + pub(super) merge: Option, + /// Process colours painted, by model (the document opens in CMYK when most are). + pub cmyk: usize, + pub rgb: usize, + /// Spot inks used: name and colour at full tint. + pub spots: Vec<(String, Color)>, + /// The color at full tint of each spot ink in [`Self::spots`], by name. + pub(super) spot_colors: HashMap, Color>, + /// Rectangles painted with a shading (`shfill`): a clipping group of one is the clip path + /// filled with it. + pub shadings: HashSet, + /// Tiling patterns made into pattern swatches: the pattern's `PaintProc` (a pattern made + /// again with `makepattern` shares it) and cell, the colour an uncoloured one painted in, and + /// the swatch's name. Each list holds the patterns of one [`tiling_hash`]; a pattern whose + /// cell has a NaN corner is left out. + pub(super) tilings: HashMap>, + /// How deep art drawn apart (a pattern's cell, a Type 3 glyph) is nested. + pub apart: u32, +} + +fn sane(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) +} + +/// A tiling pattern made into a pattern swatch, as [`Out::tilings`] lists it. +pub(super) type Tiling = (Shared, Rect, Option, String); + +/// A hash of what tells tiling patterns apart in [`Out::tilings`]. Patterns that compare equal +/// hash alike: each number goes in by its bits, and −0 goes in as 0. +pub(super) fn tiling_hash(proc: &Shared, tile: Rect, under: Option<&Paint>) -> u64 { + let mut h = DefaultHasher::new(); + proc.hash(&mut h); + [tile.x0, tile.y0, tile.x1, tile.y1].map(|v| (v + 0.0).to_bits()).hash(&mut h); + if let Some(Paint::Solid { color, swatch, tint }) = under { + let c = match *color { + Color::Rgb { r, g, b } => [r, g, b, 0.0], + Color::Cmyk { c, m, y, k } => [c, m, y, k], + Color::Gray { k } => [k, 0.0, 0.0, 0.0], + Color::Lab { l, a, b } => [l, a, b, 0.0], + }; + c.map(|v| (v + 0.0).to_bits()).hash(&mut h); + swatch.hash(&mut h); + (tint + 0.0).to_bits().hash(&mut h); + } + h.finish() +} + +impl Out { + pub fn new(doc: Document, page: Affine, frame: Rect) -> Self { + Self { + doc, + drawn: vec![], + warnings: vec![], + page, + frame, + next_clip: 0, + groups: vec![], + too_deep: 0, + elements: 0, + merge: None, + cmyk: 0, + rgb: 0, + spots: vec![], + spot_colors: HashMap::new(), + shadings: HashSet::new(), + tilings: HashMap::new(), + apart: 0, + } + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + /// Open a group: what is drawn until it ends ([`Self::end_group`]) goes in it. + pub fn begin_group(&mut self) { + if self.groups.len() >= MAX_NEST { + self.too_deep += 1; + self.warn(&format!("groups nested more than {MAX_NEST} deep were read as part of the group around them")); + return; + } + self.next_clip += 1; + self.groups.push(Arc::new(Clip { id: self.next_clip, region: None })); + } + + /// End the innermost group open (an end without a beginning ends nothing). + pub fn end_group(&mut self) { + if self.too_deep > 0 { + self.too_deep -= 1; + } else { + self.groups.pop(); + } + } + + /// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the + /// order they opened. + pub fn chain(&self, clips: &[Arc]) -> Vec> { + if self.groups.is_empty() { + return clips.to_vec(); + } + let mut chain = Vec::with_capacity(clips.len() + self.groups.len()); + let (mut a, mut b) = (clips.iter().peekable(), self.groups.iter().peekable()); + while let (Some(x), Some(y)) = (a.peek(), b.peek()) { + let next = if x.id < y.id { a.next() } else { b.next() }; + chain.extend(next.cloned()); + } + chain.extend(a.chain(b).cloned()); + chain + } + + /// Add `node` under `clips` (in the groups open); `None` when it was left out. + pub fn push(&mut self, mut node: Node, clips: &[Arc]) -> Option { + self.merge = None; + let chain = self.chain(clips); + let size = node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()) + chain.len(); + self.elements = self.elements.saturating_add(size); + if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN { + self.warn(TOO_MUCH); + return None; + } + if !node.visual_bounds().is_none_or(sane) { + self.warn(FAR_AWAY); + return None; + } + node.id = self.doc.alloc_id(); + let id = node.id; + self.drawn.push((chain, node)); + Some(id) + } + + /// Count a paint's colour model. + fn count(&mut self, p: &Paint) { + match p { + Paint::Solid { color: Color::Cmyk { .. }, swatch: None, .. } => self.cmyk += 1, + Paint::Solid { color: Color::Rgb { .. }, swatch: None, .. } => self.rgb += 1, + _ => {} + } + } + + /// Fill `bp` with `paint`. + pub(super) fn fill(&mut self, bp: BezPath, rule: FillRule, paint: Paint, overprint: bool, clips: &[Arc]) { + self.count(&paint); + let item = AppearanceItem::Fill(FillLayer { overprint, ..FillLayer::new(paint) }); + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + if self.push(n, clips).is_some() { + self.merge = Some(bp); + } + } + + /// Stroke `bp` with `st`: joins the object just filled with the same path, under the same + /// clips (`gsave fill grestore stroke`). + pub(super) fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc]) { + self.count(&st.paint); + if self.merge.as_ref() == Some(&bp) { + let here = self.chain(clips); + if let Some((chain, last)) = self.drawn.last_mut() + && chain.iter().map(|c| c.id).eq(here.iter().map(|c| c.id)) + { + last.appearance.items.push(AppearanceItem::Stroke(st)); + self.merge = None; + return; + } + } + let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() }); + self.push(n, clips); + } + + /// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which + /// clips nothing. + pub(super) fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { + // Only a polygon that really covers the page: a diamond touching its edges has the page as + // its bounding box but cuts its corners away (#1542). + if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) { + return None; + } + self.next_clip += 1; + Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) + } +} + +/// The smallest angle from `a` to `b` (radians, −π to π). +pub(super) fn turn(a: f64, b: f64) -> f64 { + let d = (b - a).rem_euclid(std::f64::consts::TAU); + if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } +} + +/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left +/// of each after whole turns. +pub(super) fn ccw_turn(a: f64, b: f64) -> f64 { + (b % 360.0 - a % 360.0).rem_euclid(360.0) +} diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 99164aaa8..42f968faa 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -37,1511 +37,21 @@ use super::Imported; use super::ai::{self, Structure, slot_of}; use super::ate::{Story, Texts}; -/// The notes that say the import left out something the file has (see [`is_loss`]). -const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; -/// The same for a file without a page to take shown type from. -const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out"; -const ART_LEFT_OUT: &str = "hidden layers have art this can't read"; -const LAYERS_UNREAD: &str = "the file's layers weren't read"; - -/// Does this import note say that the file has something the document doesn't (hidden text, art or -/// layers that could not be read)? Writing the document over the file would lose it for good. -pub fn is_loss(note: &str) -> bool { - note.ends_with(TEXT_LEFT_OUT) - || note.ends_with(TYPE_LEFT_OUT) - || [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) -} - -/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched -/// to each other (more than this and the layers aren't used). -const MAX_SLOTS: usize = 5000; -const MAX_PIECES: usize = 2000; -/// Most bytes of text in the text objects of the layers (a story named twice counts twice). -const MAX_TEXT: usize = 16 << 20; -/// Most pairs of a text object and a piece of the page's type compared, and of a glyph the page -/// kept as outlines and a node of the page's type. -const MAX_PAIRS: usize = 4_000_000; - -/// `names` for a note: the first few, and how many more. -fn few(names: BTreeSet) -> String { - const SHOWN: usize = 5; - let more = names.len().saturating_sub(SHOWN); - let mut list: Vec = names.into_iter().take(SHOWN).collect(); - if more > 0 { - list.push(format!("{more} more")); - } - list.join(", ") -} - -/// A text slot of the layers. -struct SlotInfo { - story: Option, - /// Which of the story's frames it is. - frame: usize, - /// It, its layer and the objects round it show. - shown: bool, - /// Its own frame or path, where the file places it ([`ai::text_frames`]). - art_path: Option, -} - -/// The text slots of the layers and objects, in order. -fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { - for n in nodes { - let shown = shown && n.visible; - if let Some(story) = slot_of(n.name.as_deref()) { - out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None }); - } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { - collect_slots(c, shown, out); - } - } -} - -/// How far from a text object's own extent the strokes drawn around it reach (points). -const OUTLINE_REACH: f64 = 6.0; - -/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`? -fn outlines(n: &Node, around: Rect) -> bool { - matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) - && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0)) - && n.visual_bounds().is_some_and(|b| { - let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH); - b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1 - }) -} - -/// How far apart (points) the baselines of two text nodes may be for them to be on one line. -const SAME_LINE: f64 = 0.5; - -/// Where `n`'s baseline lies across the direction it runs in (points), if it is text. -fn baseline(n: &Node) -> Option { - let NodeKind::Text(t) = &n.kind else { return None }; - let [a, b, _, _, e, f] = t.xf.as_coeffs(); - let len = a.hypot(b); - (len > 1e-9).then(|| (a * f - b * e) / len) -} - -/// The text objects the page paints, each as the run of nodes that make it up (its text, once for -/// each fill and stroke it has, with the paths of its strokes), in painting order: those that -/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`. -fn text_objects(nodes: &[Arc], on: bool, shown: &mut Vec>>, hidden: &mut Vec>>) { - let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_)); - let (mut i, mut free) = (0, 0); - while let Some(n) = nodes.get(i) { - if !is_text(n) { - if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { - text_objects(c, on && n.visible, shown, hidden); - free = i + 1; - } - i += 1; - continue; - } - let mut bounds = n.visual_bounds().unwrap_or_default(); - let (mut start, mut end) = (i, i); - while start > free - && let Some(p) = nodes.get(start - 1) - && outlines(p, bounds) - { - start -= 1; - bounds = bounds.union(p.visual_bounds().unwrap_or_default()); - } - while let Some(next) = nodes.get(end + 1) - && let Some(b) = next.visual_bounds() - && (is_text(next) - && !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area() - && baseline(next) - .zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m))) - .is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE) - || outlines(next, bounds)) - { - end += 1; - bounds = bounds.union(b); - } - let object = nodes.get(start..=end).unwrap_or_default().to_vec(); - let into = if on && n.visible { &mut *shown } else { &mut *hidden }; - into.push(object); - i = end + 1; - free = i; - } -} - -/// `n` and what it holds with ids of `doc`. -fn reid(doc: &mut Document, n: &Arc) -> Arc { - let mut n = n.as_ref().clone(); - n.id = doc.alloc_id(); - if let Some(children) = n.children_mut() { - for c in children.iter_mut() { - *c = reid(doc, c); - } - } - Arc::new(n) -} - -/// The page's text objects given to each of `slots` text slots: in painting order, and when there -/// are more of one than of the other (a label may be a few text objects, or one with several -/// strokes), each goes where the share of the order it is at falls. -fn assign(objects: &[Vec>], slots: usize) -> Vec]>> { - let mut given: Vec]>> = vec![vec![]; slots]; - for (j, o) in objects.iter().enumerate() { - if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) { - at.push(o); - } - } - given -} - -/// What goes into a text slot. -enum Content { - /// Text objects (with the strokes round them) of the page. - Page(Vec>), - /// A text object made from the file's text document, where it lies on the document, whether - /// the page has type where it is, and the page's type of no text object that is nearest it - /// (kept after it). - Made(Box, Option, bool, Vec>), - /// Nothing the file lets this read. - Empty, - /// Its type is on the page with another text object's; there is nothing to put here. - Merged, -} - -/// Does `n` have transparency of its own: an opacity below 100%, a blend mode or isolation? -fn see_through(n: &Node) -> bool { - n.opacity < 1.0 || n.blend != BlendMode::Normal || n.isolate -} - -/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is -/// left out): the page's type takes its place as it is, made type keeps the slot's name. Made type -/// takes the slot's opacity, blend mode, isolation and knockout. The page's type takes them too when -/// the page draws it and the strokes around it with no transparency, the slot's, or the slot's -/// combined with that of the objects around `nodes`; otherwise they keep their own. Where the slot has -/// transparency and the type comes with other art that has none of its own, a group of them takes the -/// slot's instead. `round` has the opacity and blend mode of each object around `nodes`, which the page -/// can have folded into its type. `empty` counts those left out. -fn fill_slots( - doc: &mut Document, - nodes: &mut Vec>, - content: &mut std::vec::IntoIter, - names: &std::collections::HashMap, - empty: &mut usize, - made: &mut Vec<(u32, usize, NodeId)>, - round: &mut Vec<(f32, BlendMode)>, -) { - let mut i = 0; - while i < nodes.len() { - let Some(n) = nodes.get(i) else { break }; - if let Some(story) = slot_of(n.name.as_deref()) { - let visible = n.visible; - let name = names.get(&n.id); - let frame = ai::slot_frame(n.name.as_deref()); - let state = (n.opacity, n.blend, n.isolate, n.knockout); - let transparent = see_through(n); - // An art style read onto the slot (fills, strokes and effects round the characters) is the type's. - let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone()); - let replacement = match content.next() { - Some(Content::Page(parts)) if !parts.is_empty() => { - // The page's type (and the strokes round it) take the slot's place as they are. - let mut parts: Vec> = parts - .iter() - .map(|p| { - let mut p = reid(doc, p); - if !visible { - Arc::make_mut(&mut p).visible = false; - } - if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) { - Arc::make_mut(&mut p).name = Some(name.clone()); - } - p - }) - .collect(); - // When the page draws each of them with no transparency, the slot's, or the slot's - // combined with that of the objects around it, the slot's takes its place: on the one - // part, or on a group of several when the slot has transparency. Otherwise every part - // keeps the transparency the page draws it with (a fill's or a stroke's). - let above: f32 = round.iter().map(|r| r.0).product(); - let objects = |p: &Node| { - !see_through(p) - || (!p.isolate - && (p.blend == BlendMode::Normal || p.blend == state.1 || round.iter().any(|r| r.1 == p.blend)) - && [state.0, state.0 * above].iter().any(|o| (p.opacity - o).abs() < 1e-3)) - }; - if parts.iter().all(|p| objects(p)) { - for p in &mut parts { - let p = Arc::make_mut(p); - (p.opacity, p.blend, p.isolate) = (1.0, BlendMode::Normal, false); - } - if let [p] = parts.as_mut_slice() { - let p = Arc::make_mut(p); - (p.opacity, p.blend, p.isolate, p.knockout) = state; - } else if transparent { - let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts)); - (group.opacity, group.blend, group.isolate, group.knockout) = state; - parts.push(Arc::new(group)); - } - } - let n = parts.len(); - nodes.splice(i..=i, parts); - i += n; - continue; - } - Some(Content::Made(mut text, _, seen, extra)) => { - text.id = doc.alloc_id(); - if let Some(story) = story { - made.push((story, frame, text.id)); - } - text.visible = visible; - // Type with an art style is what the page draws as the style's look: it is seen. - let seen = seen || styled.is_some(); - if let Some(appearance) = styled { - text.appearance = appearance; - } - // Type the page shows is kept; other made type is weighed against the page. - text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; - let extra: Vec> = extra.iter().map(|p| reid(doc, p)).collect(); - // The type takes the slot's transparency; the page's type kept after it, where it has - // none of its own, goes into a group with it that has the transparency instead. - let parts: Vec> = if transparent && !extra.is_empty() && !extra.iter().any(|p| see_through(p)) { - let mut group = Node::group(doc.alloc_id(), std::iter::once(Arc::new(*text)).chain(extra).collect()); - (group.opacity, group.blend, group.isolate, group.knockout) = state; - vec![Arc::new(group)] - } else { - (text.opacity, text.blend, text.isolate, text.knockout) = state; - std::iter::once(Arc::new(*text)).chain(extra).collect() - }; - let n = parts.len(); - nodes.splice(i..=i, parts); - i += n; - continue; - } - Some(Content::Merged) => { - nodes.remove(i); - continue; - } - _ => None, - }; - match replacement { - Some(node) => { - if let Some(slot) = nodes.get_mut(i) { - *slot = Arc::new(node); - } - i += 1; - } - None => { - *empty += 1; - nodes.remove(i); - } - } - } else { - let state = (n.opacity, n.blend); - if n.children().is_some() - && !matches!(n.kind, NodeKind::Compound { .. }) - && let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut) - { - round.push(state); - fill_slots(doc, c, content, names, empty, made, round); - round.pop(); - } - i += 1; - } - } -} - -/// How far, in points, the page's type may be from where the file's text document puts it for the -/// two to be the same type. -const SAME_PLACE: f64 = 0.75; - -/// Its letters and digits, which is what is compared of type (the page may spell a mark another way). -fn letters(s: &str) -> String { - s.chars().filter(char::is_ascii_alphanumeric).collect() -} - -/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it -/// (the page splits a line where its kerning or its styles change), on that line's baseline and -/// after its start? How far from where the line starts it is, if so. -fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { - let NodeKind::Text(t) = &n.kind else { return None }; - let have = letters(&t.plain_text()); - let [a, b, _, _, e, f] = t.xf.as_coeffs(); - let len = a.hypot(b); - if have.is_empty() || len < 1e-9 { - return None; - } - let (ux, uy) = (a / len, b / len); - // About how far a character of it advances, to say how far along a line a piece may be. - let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64; - lines - .iter() - .filter_map(|(text, at)| { - let line = letters(text); - let skipped = line.find(&have)?; - if skipped == 0 { - return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE); - } - let (dx, dy) = (e - at.x, f - at.y); - let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs()); - ((have.len() > 1 || skipped + 1 == line.len()) - && across <= SAME_PLACE - && along >= -SAME_PLACE - && along <= line.len() as f64 * advance * 2.0 + 4.0) - .then_some(across + 0.001 * along) - }) - .min_by(f64::total_cmp) -} - -/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file -/// has it as one line of one text object: the pieces that on one baseline add up to a line of -/// `lines` become one text (each keeping its own style, the line's spaces between them). -fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { - let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; - let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); - // A page with this many pieces of type in one place isn't one of lines of a story. - if texts.len() > MAX_PIECES { - return; - } - let mut taken = vec![false; parts.len()]; - // The first piece of each line to join, the others, and the spaces to put after each piece. - let mut joins: Vec<(usize, Vec, Vec)> = vec![]; - for &i in &texts { - if taken.get(i).copied().unwrap_or(true) { - continue; - } - let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; - let group: Vec = texts - .iter() - .copied() - .filter(|j| { - !taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE) - }) - .collect(); - for j in &group { - if let Some(t) = taken.get_mut(*j) { - *t = true; - } - } - let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); - let places: Vec<(f64, f64)> = group - .iter() - .filter_map(|j| parts.get(*j)) - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None }) - .collect(); - let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01)); - let joined: String = pieces.iter().map(|p| letters(p)).collect(); - if group.len() < 2 || !distinct || joined.is_empty() { - continue; - } - let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue }; - // Where the line has spaces after a piece's last character that the page doesn't draw. - let line_chars: Vec = whole.chars().collect(); - let mut at = 0; - let mut gaps = vec![]; - for (k, piece) in pieces.iter().enumerate() { - for _ in piece.chars().filter(|c| !c.is_whitespace()) { - while line_chars.get(at).is_some_and(|c| c.is_whitespace()) { - at += 1; - } - at += 1; - } - let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect(); - let next = pieces.get(k + 1); - let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace)); - gaps.push(if drawn { String::new() } else { space }); - } - if at <= line_chars.len() { - joins.push((group[0], group[1..].to_vec(), gaps)); - } - } - let mut gone: BTreeSet = BTreeSet::new(); - for (first, rest, gaps) in &joins { - let mut runs: Vec> = [first] - .into_iter() - .chain(rest) - .filter_map(|j| parts.get(*j)) - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None }) - .collect(); - for (r, gap) in runs.iter_mut().zip(gaps) { - if let Some(last) = r.last_mut() { - last.text.push_str(gap); - } - } - if let Some(node) = parts.get_mut(*first).map(Arc::make_mut) - && let NodeKind::Text(t) = &mut node.kind - { - t.runs = runs.into_iter().flatten().collect(); - // The layout cached for the first piece is not this text's. - t.cached_bounds = None; - t.cached_baselines.clear(); - } - gone.extend(rest); - } - // The pieces that went into a text, removed once the joins are done: removing as each is made - // would move the places of the pieces that the others still name. - let mut keep = 0; - parts.retain(|_| { - keep += 1; - !gone.contains(&(keep - 1)) - }); -} - -/// Do the regions of made type in the slots that `keep` takes cover most of `object`? -fn covered(object: &[Arc], content: &[Content], keep: impl Fn(usize) -> bool) -> bool { - let Some(r) = extent(object) else { return false }; - let inside: f64 = content - .iter() - .enumerate() - .filter_map(|(k, c)| match c { - Content::Made(_, Some(region), ..) => Some(region.intersect(r).area()).filter(|a| *a > 0.0 && keep(k)), - _ => None, - }) - .sum(); - inside >= 0.5 * r.area().max(1e-9) -} - -/// The box round `parts`. -fn extent(parts: &[Arc]) -> Option { - parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) -} - -/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the -/// page has it, else type made from the text document, else (for a file whose text document can't say) -/// the page's type in painting order. -fn plan( - infos: &[SlotInfo], - stories: &[Option>], - template: Option<(f64, f64)>, - to_doc: Affine, - pages: [&[Vec>]; 2], - warn: &mut Vec, -) -> Vec { - let [shown, hidden] = pages; - let lines: Vec>> = stories - .iter() - .map(|s| { - let (s, t) = (s.as_ref()?, template?); - Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) - }) - .collect(); - // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show - // has them too: a file's page may draw what its layers have hidden since. - let mut pairs: Vec<(f64, usize, usize)> = vec![]; - for (k, _) in infos.iter().enumerate() { - let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; - for (j, object) in shown.iter().enumerate() { - if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { - pairs.push((d, k, j)); - } - } - } - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut owner: Vec> = vec![None; shown.len()]; - for (_, k, j) in pairs { - if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { - *o = Some(k); - } - } - // Type the text document makes, shown or not: the page's pieces of it aren't needed. - let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); - // Where the made type that doesn't show lies, by slot. - let mut hidden_at: Vec<(usize, Rect)> = vec![]; - let mut taller = 0; - if let Some(t) = template { - for (k, info) in infos.iter().enumerate() { - let Some(Some(story)) = stories.get(k) else { continue }; - let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; - let mut region = story.region(t, to_doc); - // Area type and type on a path in the object's own frame: the text document's frame - // of a copied object keeps its original's path. - if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { - let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); - match &mut text.kind { - TextKind::Area { frame } => { - *frame = path.clone(); - region = path.bounds(); - } - TextKind::OnPath { path: on, .. } => { - *on = closed_where_ends_meet(path.clone()); - region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); - } - TextKind::Point => {} - } - } - if let (false, Some(r)) = (info.shown, region) { - hidden_at.push((k, r)); - } - if let NodeKind::Text(text) = &mut node.kind - && story.fit_first_line(text) - { - taller += 1; - } - if let Some(c) = content.get_mut(k) { - *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); - } - } - } - if taller > 0 { - warn.push(format!( - "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" - )); - } - // The file's text document is trusted where it agrees with the page about something (its lines - // start where the page's type does, or the page has type where it lays type out), or where the - // page has no type to disagree with. - let in_region = |object: &[Arc]| covered(object, &content, |_| true); - let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o)); - if !trusted { - content.iter_mut().for_each(|c| *c = Content::Empty); - } - // Is `object` the type of a text object that doesn't show, as the page draws it: in its frame, - // with its words? (A copied object's lines are where its original's are, so they don't say.) - let hidden_type = |object: &[Arc]| { - let Some(r) = extent(object) else { return false }; - let words: Vec = - object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); - hidden_at.iter().any(|(k, place)| { - place.intersect(r).area() >= 0.5 * r.area().max(1e-9) - && lines - .get(*k) - .and_then(Option::as_ref) - .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) - }) - }; - // The letters of each slot's story, in lower case. - let spelled: Vec = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect(); - // For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in - // its story? All are false unless such slots have the letters of every text between them. - let spelling = |object: &[Arc], content: &[Content]| -> Vec { - let r = extent(object); - let words: Vec = object - .iter() - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None }) - .filter(|w| !w.is_empty()) - .collect(); - let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str()))); - let near: Vec = content - .iter() - .enumerate() - .map(|(k, c)| match (c, r) { - (Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k), - _ => false, - }) - .collect(); - let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); - if every { near } else { vec![] } - }; - let mut spare: Vec>> = vec![]; - for (j, object) in shown.iter().enumerate() { - let spells = spelling(object, &content); - let spells = |k: usize| spells.get(k).copied().unwrap_or(false); - match owner.get(j).copied().flatten().filter(|_| trusted) { - // The type of a text object the layers hide: the layers have it, hidden, so the page's - // isn't another text object's. - Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {} - Some(k) => match content.get_mut(k) { - Some(Content::Made(_, _, seen, _)) => *seen = true, - Some(Content::Page(p)) => p.extend(object.iter().cloned()), - Some(c) => *c = Content::Page(object.clone()), - None => {} - }, - // What lies where made type is and has that type's letters is that type as the page draws it. - None if covered(object, &content, spells) => { - let r = extent(object).unwrap_or(Rect::ZERO); - for (k, c) in content.iter_mut().enumerate() { - if let Content::Made(_, Some(region), seen, _) = c - && !region.intersect(r).is_zero_area() - && spells(k) - { - *seen = true; - } - } - } - None if trusted && hidden_type(object) => {} - None => spare.push(object.clone()), - } - } - for (k, c) in content.iter_mut().enumerate() { - if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) { - join_pieces(parts, l); - } - } - // What is left of the page goes by painting order into the shown slots the file can't say. - let loose: Vec = - infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); - if loose.is_empty() { - // Type of the page that no text object says it owns goes with the nearest text object that has - // type of the page; with none, it is left out. - let mut left_out = false; - for object in &spare { - let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c { - Content::Page(parts) => extent(parts).map(|r| r.center()), - Content::Made(_, region, ..) => region.map(|r| r.center()), - _ => None, - }); - let nearest = content - .iter_mut() - .filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c))) - .min_by(|a, b| a.0.total_cmp(&b.0)); - match nearest { - Some((_, Content::Page(parts) | Content::Made(_, _, _, parts))) => parts.extend(object.iter().cloned()), - _ => left_out = true, - } - } - if !spare.is_empty() { - warn.push( - if left_out { - "some of the page's type has no text object in the layers, so it is left out" - } else { - "some of the page's type has no text object of its own in the layers, so it is with another's" - } - .into(), - ); - } - } else { - for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) { - if let Some(c) = content.get_mut(*slot) { - // A text object the page shows as part of another's has nothing to hold of its own. - *c = if parts.is_empty() && !spare.is_empty() { - Content::Merged - } else { - Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()) - }; - } - } - if !spare.is_empty() && spare.len() != loose.len() { - warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into()); - } - } - // The same for the slots that don't show. - let unread: Vec = - infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); - for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) { - if let Some(c) = content.get_mut(*slot) { - *c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()); - } - } - content -} - -/// `doc` with its layers that don't print hidden, when it has any. -fn as_printed(doc: &Document) -> Option { - fn hide(nodes: &mut [Arc]) -> bool { - let mut any = false; - for n in nodes { - if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible { - Arc::make_mut(n).visible = false; - any = true; - } else if matches!(n.kind, NodeKind::Layer { .. }) - && let Some(c) = Arc::make_mut(n).children_mut() - { - any |= hide(c); - } - } - any - } - let mut d = doc.clone(); - hide(&mut d.layers).then_some(d) -} - -/// A premultiplied pixel seen on white. -fn on_white(p: &[u8; 4]) -> [u32; 3] { - let paper = 255 - u32::from(p[3]).min(255); - [0, 1, 2].map(|i| (u32::from(p[i]) + paper).min(255)) -} - -/// The lightness (0 to 255, Rec. 601 weights) of a pixel seen on white. -fn lightness([r, g, b]: [u32; 3]) -> u32 { - (r * 299 + g * 587 + b * 114) / 1000 -} - -/// How far (0 to 255) a pixel seen on white is from white: how much ink it has. -fn ink([r, g, b]: [u32; 3]) -> u32 { - 255 - r.min(g).min(b) -} - -/// How far apart two pixels' lightness, or their ink where one is white, may be for them to look -/// alike. -const SAME_PIXEL: u32 = 48; -/// The most ink a pixel seen as white has. -const WHITE: u32 = 8; - -/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other -/// is white? -fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { - let (p, q) = (on_white(p), on_white(q)); - let (a, b) = (ink(p), ink(q)); - lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) -} - -/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour -/// and its CMYK equivalent stay within it; a red and a dark red don't). -const SAME_COLOUR: u32 = 64; - -/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same -/// lightness to be alike)? -fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { - let (p, q) = (on_white(p), on_white(q)); - p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) -} - -/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, of those -/// that differ away from type, and of those whose neighbors all differ too, away from type (where -/// one draws an object that the other doesn't, not the edges of one drawn a little differently, or -/// type laid out a little differently: a line the page sets in pieces is laid out afresh as one). -#[derive(Clone, Copy)] -struct Difference { - any: f64, - art: f64, - solid: f64, -} - -/// The scale the comparisons draw a region at: about 400 pixels along its longer side. -fn compare_scale(r: Rect) -> f64 { - (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) -} - -/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases -/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher -/// resolution and apply the same reconstruction filter; keep the existing difference limits. -fn comparison_samples(doc: &Document) -> u32 { - let (mut images, mut vector) = (false, false); - doc.walk(|n| match n.kind { - NodeKind::Image(_) => images = true, - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} - _ => vector = true, - }); - if images && !vector { 4 } else { 1 } -} - -/// Render for the page check. The comparison scale bounds the long side to 400 px, so the -/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha. -fn comparison_render( - renderer: &mut vectorcraft_render::Renderer, - doc: &Document, - rect: Rect, - scale: f64, - samples: u32, -) -> vectorcraft_render::Rendered { - let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false); - if samples == 1 { - return rendered; - } - let (width, height) = match vectorcraft_render::raster_size(rect, scale) { - Ok(size) => size, - // A malformed region cannot provide a comparable raster. - Err(_) => return rendered, - }; - let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else { - return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() }; - }; - let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw(); - vectorcraft_render::Rendered { width, height, pixels } -} - -/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers -/// with a renderer of their own, which keeps their decoded images between draws (the page's images -/// can share their keys and not their pixels, so the two don't share one). -struct PageView { - page: vectorcraft_render::Rendered, - /// Where the page's own type is, in its pixels. - page_type: Vec, - /// Where objects read with their art style lie, in its pixels: the page has Illustrator's - /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't - /// held to look alike. - restyled: Vec, - layers: vectorcraft_render::Renderer, - scale: f64, - samples: u32, -} - -impl PageView { - fn new(frame: &Frame<'_>) -> Self { - let ra = frame.page_rect; - let scale = compare_scale(ra); - // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in - // the app that wrote the file. - let samples = comparison_samples(frame.page); - let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); - let mut page_type = vec![]; - type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } - } - - /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out - /// of the comparisons ([`Structure::restyled`](ai::Structure)). - fn restyled(&mut self, looks: &[Rect], region: Rect) { - let s = self.scale; - self.restyled = looks - .iter() - .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) - .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) - .collect(); - } - - /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). - fn difference(&mut self, b: &Document, rb: Rect) -> Difference { - // The page doesn't print what isn't printed. - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - let mut areas = self.page_type.clone(); - type_areas(&b.layers, rb, self.scale, &mut areas); - compared(&self.page, &y, &areas, &self.restyled) - } - - /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page - /// ([`differ_in_colour`]), type included: what taking an object out of the layers changes - /// there, when it is covered by one of about the same lightness (#1102). - fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - if (y.width, y.height) != (self.page.width, self.page.height) { - return 1.0; - } - let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count(); - differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) - } -} - -/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), -/// for a region other than the page's ([`PageView`] draws that one once). -fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let scale = compare_scale(ra); - // A renderer each: the two documents' images can share their keys and not their pixels. - let samples = comparison_samples(a); - let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples); - let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples); - let mut areas = vec![]; - type_areas(&a.layers, ra, scale, &mut areas); - type_areas(&b.layers, rb, scale, &mut areas); - compared(&x, &y, &areas, &[]) -} - -/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from -/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked -/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, -/// and what the comparison looks for is art that is missing or out of place. -fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { - if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { - return Difference { any: 1.0, art: 1.0, solid: 1.0 }; - } - let (w, h) = (x.width as usize, x.height as usize); - // Pixels in `skip` count as alike. - let skipped = in_areas(skip, w, h); - let differs: Vec = - x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); - let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); - let typed = in_areas(areas, w, h); - let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); - // A pixel on the region's edge has neighbours outside it, which don't count as differing. - let solid = (1..h.saturating_sub(1)) - .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) - .filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) - .count(); - let area = (w as f64 * h as f64).max(1.0); - Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area } -} - -/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin -/// for the strokes round it). -fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { - for n in nodes.iter().filter(|n| n.visible) { - if let NodeKind::Text(_) = n.kind - && let Some(b) = n.visual_bounds() - { - let px = - |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); - out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); - } else if let Some(c) = n.children() { - type_areas(c, region, scale, out); - } - } -} - -/// Which of `w` × `h` pixels (row by row) lie in one of `areas`. -fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { - // Each area adds one inside it: corners marked, then summed along the rows and down the columns. - let stride = w + 1; - let mut count = vec![0i64; stride * (h + 1)]; - let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; - for r in areas { - let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); - for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] { - if let Some(c) = count.get_mut(y * stride + x) { - *c += d; - } - } - } - for row in count.chunks_mut(stride) { - let mut run = 0; - for c in row { - run += *c; - *c = run; - } - } - let mut column = vec![0i64; stride]; - for row in count.chunks_mut(stride) { - for (c, sum) in row.iter_mut().zip(column.iter_mut()) { - *sum += *c; - *c = *sum; - } - } - (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() -} - -/// More of the page than this differing between the layers' art and the page's own, and the -/// layers aren't used. -const MAX_DIFFERENCE: f64 = 0.05; -/// More than this and the import says so. -const NOTABLE_DIFFERENCE: f64 = 0.002; -/// More of the page than this where one draws an object that the other doesn't (see -/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. -const MAX_SOLID_DIFFERENCE: f64 = 0.001; - -/// Which box of the editing copy is the page. -#[derive(Clone, Copy)] -enum Page { - /// An EPS's page is its art's bounding box. - Art, - /// A `.ai` is read on its (first and only) artboard. - Artboard, -} - -/// The page (`visible`, the file read as its page) and the place of its area in the layers' -/// document. -struct Frame<'a> { - page: &'a Document, - /// The page's area in `page`. - page_rect: Rect, - /// The same area in the layers' document. - rect: Rect, - /// The page holds all the art that prints (an EPS's page is its art's box, unless it was - /// saved as its artboard): the layers print nothing outside it. - whole: bool, -} - -/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the -/// file read as its page, put in its text objects → the document and its notes. Without a page -/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and -/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in -/// the text objects. `unit`: the size in points of a unit of the page's user space (see -/// [`layered_ai`]). -fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool, unit: f64) -> Result<(Document, Vec), String> { - // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art - // space, as its PDF part may (#2031). - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = - ai::read(data, matches!(page, Page::Art))?; - // Editing data in the units of the page's user space ([`in_units`]) is compared with the page - // scaled down to those units, and the document read from it is scaled up by `unit`. - let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); - let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| { - let mut v = v.clone(); - scale_document(&mut v, 1.0 / unit); - v - }); - let visible = small.as_ref().or(visible); - let (mut shown, mut hidden) = (vec![], vec![]); - // The glyphs the page kept as outlines that no text object of it takes in, one by one. - let mut lone: Vec>> = vec![]; - let mut frame = None; - if let Some(visible) = visible { - let art_box = match page { - Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, - Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, - }; - let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; - // The layers' art in the document's terms: a Large Canvas editing copy is written at 1/N - // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). - let rect = to_doc.transform_rect_bbox(art_box); - if (rect.width() - page_rect.width()).abs() > 0.5 || (rect.height() - page_rect.height()).abs() > 0.5 { - // Both sizes in points as the fields show them, and the editing data's page to the - // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and - // the scale doesn't round to 1:1. - let n = |v: f64| Unit::Points.number(v); - let (w, h, pw, ph) = (n(rect.width()), n(rect.height()), n(page_rect.width()), n(page_rect.height())); - let k = page_rect.width() / rect.width(); - let factor = n(k.max(1.0 / k)); - if k.is_finite() && k > 0.0 && (rect.height() * k - page_rect.height()).abs() <= 0.5 && factor != "1" { - let scale = if k >= 1.0 { format!("1:{factor}") } else { format!("{factor}:1") }; - return Err(format!("its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale}")); - } - return Err(format!("its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page")); - } - // The page's area in the layers' document, and the page's type moved there. - let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0)); - for l in &visible.layers { - if let Some(c) = l.children() { - text_objects(c, l.visible, &mut shown, &mut hidden); - } - } - let taken: BTreeSet = shown.iter().chain(&hidden).flatten().map(|n| n.id).collect(); - visible.walk(|n| { - if vectorcraft_pdf::is_glyph_outlines(n) && !taken.contains(&n.id) { - lone.push(vec![Arc::new(n.clone())]); - } - }); - // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each - // goes where the layers have that artboard. - let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); - let same = - |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; - let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); - for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { - // The page with most of it, the first for what is on none. - let on = extent(object).and_then(|r| { - let inside = |p: &Rect| p.intersect(r).area().max(0.0); - pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) - }); - let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { - Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), - None => shift, - }; - for n in object.iter_mut() { - Arc::make_mut(n).transform(shift, false); - } - } - // An EPS saved as its artboard has the art outside it left out of its page. - let artboard_page = - artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); - let whole = matches!(page, Page::Art) && !artboard_page; - if whole { - // Its page is the file's own (its bounding box), as when it comes in as its page: art - // reaching past the editing data's artboard stays on it (#1102). - doc.artboards.truncate(1); - if let Some(a) = doc.artboards.first_mut() { - a.rect = rect; - } - } - frame = Some(Frame { page: visible, page_rect, rect, whole }); - } - let mut infos = vec![]; - collect_slots(&doc.layers, true, &mut infos); - // Each text object's own frame or path, by its story and frame. - let frames = ai::text_frames(data); - for info in &mut infos { - info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(to_doc * p.clone()))); - } - // Type asked for as outlines is the page's: the text objects that show can't be filled. - if outlined && infos.iter().any(|i| i.shown) { - return Err("its type is to come in as outlines, which only its page has".into()); - } - if infos.iter().all(|i| !i.shown) && !shown.is_empty() { - return Err("its page has text, and its layers have no text objects to put it in".into()); - } - // The page's type is compared with every text object of the layers: that has its limits. - if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { - return Err("it has too many text objects".into()); - } - let texts = Texts::read(data); - // Each story is read once, however many text objects name it. - let mut read: std::collections::BTreeMap>> = Default::default(); - let stories: Vec>> = infos - .iter() - .map(|i| { - let n = i.story?; - read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone() - }) - .collect(); - if stories.iter().flatten().map(|s| s.len()).sum::() > MAX_TEXT { - return Err("it has too much text".into()); - } - let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); - let mut notes = warnings; - let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); - // The glyphs left out with the rest of the page's art, and how many text objects take type of - // the page right next to them (not counted past `MAX_PAIRS` comparisons). - let compared = lone.len().saturating_mul(shown.iter().chain(&hidden).map(Vec::len).sum()); - let missing: Vec = if compared > MAX_PAIRS { vec![] } else { lone.iter().filter_map(|g| extent(g)).collect() }; - let next_to_missing = |n: &Arc| { - matches!(n.kind, NodeKind::Text(_)) - && n.visual_bounds().is_some_and(|b| missing.iter().any(|r| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).is_zero_area())) - }; - let short = content.iter().filter(|c| matches!(c, Content::Page(p) | Content::Made(_, _, _, p) if p.iter().any(next_to_missing))).count(); - let mut empty = 0; - let mut layers = std::mem::take(&mut doc.layers); - let mut made = vec![]; - fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]); - doc.layers = layers; - // A story in several frames is threaded type: its frames in order (the first holds the story). - made.sort(); - for thread in made.chunk_by(|a, b| a.0 == b.0) { - if thread.len() > 1 { - doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); - } - } - if empty > 0 { - notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); - } - if short > 0 { - notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); - } - if !hidden_unread.is_empty() { - notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); - } - match frame { - Some(frame) => { - let mut view = PageView::new(&frame); - view.restyled(&restyled, frame.rect); - let unseen = prune_unseen(&frame, &mut view, &mut doc); - if unseen > 0 { - notes.push(format!("{unseen} {UNSEEN_TEXT}")); - } - match compare(&frame, &mut view, &doc) { - Ok(note) => notes.extend(note), - Err(why) if trust => notes.push(format!("{why}: they come from its editing data all the same")), - Err(why) => return Err(why), - } - } - None => unmark(&mut doc.layers, true, true, &mut vec![]), - } - doc.limit_depth_noting(&mut notes); - if small.is_some() { - scale_document(&mut doc, unit); - } - Ok((doc, notes)) -} - -/// Does the editing data's first artboard `board` (in the document read from it: art space through -/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above -/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a -/// unit (their `UserUnit`). -fn in_units(board: Option, page: &Document, unit: f64) -> bool { - let (Some(board), Some(p)) = (board, page.artboards.first().map(|a| a.rect)) else { return false }; - let fits = |k: f64| (board.width() - p.width() / k).abs() <= 0.5 && (board.height() - p.height() / k).abs() <= 0.5; - unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) -} - -/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, -/// effects and pattern tiles. -fn scale_document(doc: &mut Document, k: f64) { - let a = Affine::scale(k); - let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; - for layer in &mut doc.layers { - Arc::make_mut(layer).transform(a, scaling); - } - for board in &mut doc.artboards { - board.rect = a.transform_rect_bbox(board.rect); - } -} - -/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's -/// text document. -const MADE: &str = "\u{1}made"; -/// How many such objects are weighed against the page (each takes a render of the page's size). -const MAX_WEIGHED: usize = 12; -/// The text objects the file keeps that the page doesn't draw. -const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't draw (what is left of type turned to outlines) are left out"; -/// The text objects that hold the page's type next to glyphs the page kept as outlines, which are -/// left out. -const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; -/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. -const UNSEEN_MARGIN: f64 = 5e-5; - -/// Clear the marks in `nodes` and say which marked objects show and print, by id. -fn unmark(nodes: &mut [Arc], shown: bool, prints: bool, found: &mut Vec<(NodeId, Option)>) { - for n in nodes { - let shown = shown && n.visible; - let prints = prints && !matches!(n.kind, NodeKind::Layer { printable: false, .. }); - if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) { - if shown && prints { - found.push((n.id, n.visual_bounds())); - } - let name = (!name.is_empty()).then(|| name.to_string()); - Arc::make_mut(n).name = name; - } else if !matches!(n.kind, NodeKind::Compound { .. }) - && let Some(c) = Arc::make_mut(n).children_mut() - { - unmark(c, shown, prints, found); - } - } -} - -/// Take object `id` out of `nodes` (`hide`: leave it, hidden); whether it was there. -fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { - if let Some(i) = nodes.iter().position(|n| n.id == id) { - if hide { - if let Some(n) = nodes.get_mut(i) { - Arc::make_mut(n).visible = false; - } - } else { - nodes.remove(i); - } - return true; - } - nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) -} - -/// The text objects made from the file's text document that show and print on the page's area, -/// where the page has no type to match them, and that the layers look no worse without, are not -/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type -/// of what was turned to outlines): they are taken out. How many. -fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize { - let mut found = vec![]; - unmark(&mut doc.layers, true, true, &mut found); - let rect = frame.rect; - if found.is_empty() || found.len() > MAX_WEIGHED { - return 0; - } - let with = view.difference(doc, rect).any; - // Taking a text object out changes only what is drawn in its box. When the art would still - // differ from the page by more than `compare` allows with every candidate gone, the layers - // aren't used whatever is pruned: don't draw them again for each one (#758). - let margin = OUTLINE_REACH + 1.0; - let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum(); - if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE { - return 0; - } - // Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no - // less there for being about as light (#1102). - let mut with = view.colour_difference(doc, rect); - let mut removed = 0; - for (id, bounds) in found { - if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { - continue; - } - let mut without = doc.clone(); - if !take_out(&mut without.layers, id, true) { - continue; - } - let d = view.colour_difference(&without, rect); - if d - with <= UNSEEN_MARGIN { - take_out(&mut doc.layers, id, false); - with = d; - removed += 1; - } - } - removed -} - -/// How far (points) the art the layers print may reach past a page that holds it all. -const PAST_THE_PAGE: f64 = 1.0; - -/// Do the layers' art and the page's own look alike (where the page holds all the art that prints, -/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little: -/// with the share of the pixels that differ away from type when that is notable, otherwise with the -/// share of those that differ in the type's areas. -fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result, String> { - let mut diff = view.difference(layered, frame.rect); - if frame.whole - && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) - .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) - { - // The page and the art the layers print beyond it, each in its own document's place. - let all = frame.rect.union(art); - let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0); - let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); - diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) }; - } - if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { - return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0)); - } - if diff.any <= NOTABLE_DIFFERENCE { - return Ok(None); - } - Ok(Some(if diff.art > NOTABLE_DIFFERENCE { - format!( - "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", - diff.art * 100.0 - ) - } else { - format!( - "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", - (diff.any - diff.art) * 100.0 - ) - })) -} - -/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or -/// its program in the legacy format, see [`editing_copy`]): its layers (the -/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the -/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it -/// has one that can't be used, `visible` with a warning that says why. -pub(super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { - let Some(data) = editing_copy(ps) else { return visible }; - let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); - match result { - Ok((document, mut warnings)) => { - for w in &visible.warnings { - if !warnings.contains(w) { - warnings.push(w.clone()); - } - } - Imported { document, warnings, preview: false } - } - Err(why) => { - let mut v = visible; - v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); - v - } - } -} - -/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through -/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead, -/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the -/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have. -/// `warnings` are the notes of reading the PDF part; the notes returned are those, then the layers' -/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed -/// or its layers can't be read. The flag returned is `true` when the document is the one read from -/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as -/// outlines (a file whose type shows then comes in as its PDF part). -/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. -/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, -/// by which its art came in scaled. Editing data whose artboard matches the page only in those -/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from -/// it. -pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { - let unread = |mut all: Vec, why: &str| { - all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); - all - }; - let data = match ai::decode_private(private) { - Ok(data) => data, - Err(why) => return (visible, unread(warnings, &why), false), - }; - if !has_layers(&data) { - return (visible, warnings, false); - } - // The PDF part, its pages placed and named as the editing data's artboards are, when the - // layers can't be used (`why`). - let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { - place_pages(&ai::artboards(&data), &mut visible, unit); - let mut all = unread(warnings, why); - all.extend(ai::variables(&data).note()); - (visible, all, false) - }; - match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { - // An image too large for the editing data's reader would be missing from the layers, while - // the PDF part has it: the file comes in as its PDF part instead (#1479). - Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { - from_pdf_part(visible, warnings, "an image is too large to read from them") - } - Ok((mut done, notes)) => { - done.setup.bleed = visible.setup.bleed; - let mut all = warnings; - all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); - (done, all, true) - } - Err(why) => from_pdf_part(visible, warnings, &why), - } -} - -/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as -/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on -/// each, to where they are, so the artboards keep their names and layout when the layers can't be -/// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its -/// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points: -/// then the layout is drawn `unit` times as large. -fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { - if boards.is_empty() || boards.len() != doc.artboards.len() { - return; - } - let differ = |k: f64| { - boards - .iter() - .zip(&doc.artboards) - .any(|((_, b), a)| (b.width() - a.rect.width() / k).abs() > 0.5 || (b.height() - a.rect.height() / k).abs() > 0.5) - }; - let k = if !differ(1.0) { - 1.0 - } else if unit.is_finite() && unit > 1.0 && !differ(unit) { - unit - } else { - return; - }; - for ((name, _), a) in boards.iter().zip(&mut doc.artboards) { - a.name.clone_from(name); - } - let [(_, first), _, ..] = boards else { return }; - // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it, - // `k` points a unit. - let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]); - let pages: Vec = doc.artboards.iter().map(|a| a.rect).collect(); - let moves: Vec = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect(); - for layer in &mut doc.layers { - place_art(Arc::make_mut(layer), &pages, &moves); - } - for (a, v) in doc.artboards.iter_mut().zip(&moves) { - a.rect = a.rect + *v; - } -} - -/// The objects in `layer` (and its sublayers) moved by the move of the page they are on. -fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { - let Some(children) = layer.children_mut() else { return }; - for child in children { - if matches!(child.kind, NodeKind::Layer { .. }) { - place_art(Arc::make_mut(child), pages, moves); - continue; - } - let Some(r) = child.visual_bounds() else { continue }; - // The page it is on: the one with most of it (pages keep only the art that reaches into - // them), else the nearest. - let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); - let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; - if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { - Arc::make_mut(child).transform(Affine::translate(*v), false); - } - } -} - -/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy -/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. -pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { - let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) -} - -/// Does the editing data (or a program in the legacy format) have layers? -fn has_layers(data: &[u8]) -> bool { - data.windows(15).any(|w| w == b"%AI5_BeginLayer") -} - -/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program -/// itself when it has layers (the legacy format). `None` without one. -fn editing_copy(ps: &[u8]) -> Option, String>> { - match ai::eps_data(ps) { - Some(data) => Some(data.map(Cow::Owned)), - None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), - } -} - -/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its -/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the -/// document and the import's notes. A file whose header says it is in the format (`illustrator`) -/// but whose program has no layers (Illustrator 3 had none; image editors still export their -/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program -/// has no layers and isn't in the format: other PostScript isn't read as the format's operators. -pub(super) fn program_alone(ps: &[u8], illustrator: bool) -> Option), String>> { - if ai::has_eps_data(ps) { - return None; - } - if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0)); - } - let program = one_layer(ps).filter(|_| illustrator)?; - Some(build(&program, None, Page::Art, false, false, 1.0)) -} - -/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its -/// end) on a layer that shows, prints and isn't locked, as the version 5 format writes one. `None` -/// without a `%%EndSetup`: where the art starts isn't known. -fn one_layer(ps: &[u8]) -> Option> { - let setup = line_with(ps, 0, b"%%EndSetup")?; - let art = line_with(ps, setup + 1, b"").unwrap_or(ps.len()); - let end = line_with(ps, art, b"%%PageTrailer").or_else(|| line_with(ps, art, b"%%Trailer")).unwrap_or(ps.len()); - let mut out = Vec::with_capacity(ps.len() + 80); - out.extend_from_slice(ps.get(..art)?); - out.extend_from_slice(b"\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Layer 1) Ln\n"); - out.extend_from_slice(ps.get(art..end)?); - out.extend_from_slice(b"\nLB\n%AI5_EndLayer--\n"); - out.extend_from_slice(ps.get(end..)?); - Some(out) -} - -/// Where the first line of `ps` from `from` on that starts with `key` starts. -fn line_with(ps: &[u8], from: usize, key: &[u8]) -> Option { - (from..ps.len()).find(|&i| (i == 0 || matches!(ps.get(i - 1), Some(b'\n' | b'\r'))) && ps.get(i..).is_some_and(|rest| rest.starts_with(key))) -} +mod build; +mod compare; +mod layers; +mod plan; +mod slots; + +#[cfg(test)] +use build::{Frame, MADE, prune_unseen}; +#[cfg(test)] +use compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour}; +pub use layers::{ai_alone, layered_ai}; +pub(super) use layers::{layered, program_alone}; +pub use slots::is_loss; +#[cfg(test)] +use slots::join_pieces; #[cfg(test)] mod tests { @@ -1659,48 +169,3 @@ mod tests { assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error"); } } - -/// `path` with each open subpath whose last anchor lies on its first (within a thousandth of a -/// point) closed there: the object's own path of type round a closed path is written open, ending -/// on its start, and type on an open path stops at its end, so it had no room to run past it. -fn closed_where_ends_meet(mut path: PathData) -> PathData { - for sp in &mut path.subpaths { - let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; - if sp.closed || sp.anchors.len() < 3 || first.p.distance(last.p) >= 1e-3 { - continue; - } - if let Some(l) = sp.anchors.pop() - && let Some(a0) = sp.anchors.first_mut() - { - a0.h_in = l.h_in; - } - sp.closed = true; - } - path -} - -#[cfg(test)] -mod closed_ends_tests { - use super::*; - - /// A path drawn round to its start (the last anchor on the first) is closed there, its first - /// anchor taking the last one's incoming handle; one whose ends are apart stays open. - #[test] - fn a_path_ending_on_its_start_is_closed() { - let mut bp = kurbo::BezPath::new(); - bp.move_to((100.0, 0.0)); - bp.curve_to((100.0, 55.0), (55.0, 100.0), (0.0, 100.0)); - bp.curve_to((-55.0, 100.0), (-100.0, 55.0), (-100.0, 0.0)); - bp.curve_to((-100.0, -55.0), (-55.0, -100.0), (0.0, -100.0)); - bp.curve_to((55.0, -100.0), (100.0, -55.0), (100.0, 0.0001)); - let closed = closed_where_ends_meet(PathData::from_bezpath(&bp)); - let sp = &closed.subpaths[0]; - assert!(sp.closed && sp.anchors.len() == 4, "{sp:?}"); - assert_eq!(sp.anchors[0].h_in, Point::new(100.0, -55.0)); - let mut open = kurbo::BezPath::new(); - open.move_to((0.0, 0.0)); - open.line_to((50.0, 0.0)); - open.line_to((50.0, 50.0)); - assert!(!closed_where_ends_meet(PathData::from_bezpath(&open)).subpaths[0].closed); - } -} diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs new file mode 100644 index 000000000..9441da025 --- /dev/null +++ b/crates/eps/src/import/native/build.rs @@ -0,0 +1,333 @@ +use super::compare::{MAX_DIFFERENCE, PageView, compare}; +use super::plan::plan; +use super::slots::{ + ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots, + text_objects, +}; +use super::*; + +/// Which box of the editing copy is the page. +#[derive(Clone, Copy)] +pub(super) enum Page { + /// An EPS's page is its art's bounding box. + Art, + /// A `.ai` is read on its (first and only) artboard. + Artboard, +} + +/// The page (`visible`, the file read as its page) and the place of its area in the layers' +/// document. +pub(super) struct Frame<'a> { + pub(super) page: &'a Document, + /// The page's area in `page`. + pub(super) page_rect: Rect, + /// The same area in the layers' document. + pub(super) rect: Rect, + /// The page holds all the art that prints (an EPS's page is its art's box, unless it was + /// saved as its artboard): the layers print nothing outside it. + pub(super) whole: bool, +} + +/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the +/// file read as its page, put in its text objects → the document and its notes. Without a page +/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and +/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in +/// the text objects. `unit`: the size in points of a unit of the page's user space (see +/// [`layered_ai`]). +pub(super) fn build( + data: &[u8], + visible: Option<&Document>, + page: Page, + outlined: bool, + trust: bool, + unit: f64, +) -> Result<(Document, Vec), String> { + // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art + // space, as its PDF part may (#2031). + let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = + ai::read(data, matches!(page, Page::Art))?; + // Editing data in the units of the page's user space ([`in_units`]) is compared with the page + // scaled down to those units, and the document read from it is scaled up by `unit`. + let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); + let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| { + let mut v = v.clone(); + scale_document(&mut v, 1.0 / unit); + v + }); + let visible = small.as_ref().or(visible); + let (mut shown, mut hidden) = (vec![], vec![]); + // The glyphs the page kept as outlines that no text object of it takes in, one by one. + let mut lone: Vec>> = vec![]; + let mut frame = None; + if let Some(visible) = visible { + let art_box = match page { + Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, + Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, + }; + let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; + // The layers' art in the document's terms: a Large Canvas editing copy is written at 1/N + // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). + let rect = to_doc.transform_rect_bbox(art_box); + if (rect.width() - page_rect.width()).abs() > 0.5 || (rect.height() - page_rect.height()).abs() > 0.5 { + // Both sizes in points as the fields show them, and the editing data's page to the + // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and + // the scale doesn't round to 1:1. + let n = |v: f64| Unit::Points.number(v); + let (w, h, pw, ph) = (n(rect.width()), n(rect.height()), n(page_rect.width()), n(page_rect.height())); + let k = page_rect.width() / rect.width(); + let factor = n(k.max(1.0 / k)); + if k.is_finite() && k > 0.0 && (rect.height() * k - page_rect.height()).abs() <= 0.5 && factor != "1" { + let scale = if k >= 1.0 { format!("1:{factor}") } else { format!("{factor}:1") }; + return Err(format!("its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale}")); + } + return Err(format!("its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page")); + } + // The page's area in the layers' document, and the page's type moved there. + let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0)); + for l in &visible.layers { + if let Some(c) = l.children() { + text_objects(c, l.visible, &mut shown, &mut hidden); + } + } + let taken: BTreeSet = shown.iter().chain(&hidden).flatten().map(|n| n.id).collect(); + visible.walk(|n| { + if vectorcraft_pdf::is_glyph_outlines(n) && !taken.contains(&n.id) { + lone.push(vec![Arc::new(n.clone())]); + } + }); + // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each + // goes where the layers have that artboard. + let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); + let same = + |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; + let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); + for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { + // The page with most of it, the first for what is on none. + let on = extent(object).and_then(|r| { + let inside = |p: &Rect| p.intersect(r).area().max(0.0); + pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) + }); + let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { + Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), + None => shift, + }; + for n in object.iter_mut() { + Arc::make_mut(n).transform(shift, false); + } + } + // An EPS saved as its artboard has the art outside it left out of its page. + let artboard_page = + artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); + let whole = matches!(page, Page::Art) && !artboard_page; + if whole { + // Its page is the file's own (its bounding box), as when it comes in as its page: art + // reaching past the editing data's artboard stays on it (#1102). + doc.artboards.truncate(1); + if let Some(a) = doc.artboards.first_mut() { + a.rect = rect; + } + } + frame = Some(Frame { page: visible, page_rect, rect, whole }); + } + let mut infos = vec![]; + collect_slots(&doc.layers, true, &mut infos); + // Each text object's own frame or path, by its story and frame. + let frames = ai::text_frames(data); + for info in &mut infos { + info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(to_doc * p.clone()))); + } + // Type asked for as outlines is the page's: the text objects that show can't be filled. + if outlined && infos.iter().any(|i| i.shown) { + return Err("its type is to come in as outlines, which only its page has".into()); + } + if infos.iter().all(|i| !i.shown) && !shown.is_empty() { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + // The page's type is compared with every text object of the layers: that has its limits. + if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { + return Err("it has too many text objects".into()); + } + let texts = Texts::read(data); + // Each story is read once, however many text objects name it. + let mut read: std::collections::BTreeMap>> = Default::default(); + let stories: Vec>> = infos + .iter() + .map(|i| { + let n = i.story?; + read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone() + }) + .collect(); + if stories.iter().flatten().map(|s| s.len()).sum::() > MAX_TEXT { + return Err("it has too much text".into()); + } + let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let mut notes = warnings; + let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); + // The glyphs left out with the rest of the page's art, and how many text objects take type of + // the page right next to them (not counted past `MAX_PAIRS` comparisons). + let compared = lone.len().saturating_mul(shown.iter().chain(&hidden).map(Vec::len).sum()); + let missing: Vec = if compared > MAX_PAIRS { vec![] } else { lone.iter().filter_map(|g| extent(g)).collect() }; + let next_to_missing = |n: &Arc| { + matches!(n.kind, NodeKind::Text(_)) + && n.visual_bounds().is_some_and(|b| missing.iter().any(|r| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).is_zero_area())) + }; + let short = content.iter().filter(|c| matches!(c, Content::Page(p) | Content::Made(_, _, _, p) if p.iter().any(next_to_missing))).count(); + let mut empty = 0; + let mut layers = std::mem::take(&mut doc.layers); + let mut made = vec![]; + fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]); + doc.layers = layers; + // A story in several frames is threaded type: its frames in order (the first holds the story). + made.sort(); + for thread in made.chunk_by(|a, b| a.0 == b.0) { + if thread.len() > 1 { + doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); + } + } + if empty > 0 { + notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); + } + if short > 0 { + notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); + } + if !hidden_unread.is_empty() { + notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); + } + match frame { + Some(frame) => { + let mut view = PageView::new(&frame); + view.restyled(&restyled, frame.rect); + let unseen = prune_unseen(&frame, &mut view, &mut doc); + if unseen > 0 { + notes.push(format!("{unseen} {UNSEEN_TEXT}")); + } + match compare(&frame, &mut view, &doc) { + Ok(note) => notes.extend(note), + Err(why) if trust => notes.push(format!("{why}: they come from its editing data all the same")), + Err(why) => return Err(why), + } + } + None => unmark(&mut doc.layers, true, true, &mut vec![]), + } + doc.limit_depth_noting(&mut notes); + if small.is_some() { + scale_document(&mut doc, unit); + } + Ok((doc, notes)) +} + +/// Does the editing data's first artboard `board` (in the document read from it: art space through +/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above +/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a +/// unit (their `UserUnit`). +fn in_units(board: Option, page: &Document, unit: f64) -> bool { + let (Some(board), Some(p)) = (board, page.artboards.first().map(|a| a.rect)) else { return false }; + let fits = |k: f64| (board.width() - p.width() / k).abs() <= 0.5 && (board.height() - p.height() / k).abs() <= 0.5; + unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) +} + +/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, +/// effects and pattern tiles. +fn scale_document(doc: &mut Document, k: f64) { + let a = Affine::scale(k); + let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; + for layer in &mut doc.layers { + Arc::make_mut(layer).transform(a, scaling); + } + for board in &mut doc.artboards { + board.rect = a.transform_rect_bbox(board.rect); + } +} + +/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's +/// text document. +pub(super) const MADE: &str = "\u{1}made"; + +/// How many such objects are weighed against the page (each takes a render of the page's size). +const MAX_WEIGHED: usize = 12; + +/// The text objects the file keeps that the page doesn't draw. +const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't draw (what is left of type turned to outlines) are left out"; + +/// The text objects that hold the page's type next to glyphs the page kept as outlines, which are +/// left out. +const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; + +/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. +const UNSEEN_MARGIN: f64 = 5e-5; + +/// Clear the marks in `nodes` and say which marked objects show and print, by id. +fn unmark(nodes: &mut [Arc], shown: bool, prints: bool, found: &mut Vec<(NodeId, Option)>) { + for n in nodes { + let shown = shown && n.visible; + let prints = prints && !matches!(n.kind, NodeKind::Layer { printable: false, .. }); + if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) { + if shown && prints { + found.push((n.id, n.visual_bounds())); + } + let name = (!name.is_empty()).then(|| name.to_string()); + Arc::make_mut(n).name = name; + } else if !matches!(n.kind, NodeKind::Compound { .. }) + && let Some(c) = Arc::make_mut(n).children_mut() + { + unmark(c, shown, prints, found); + } + } +} + +/// Take object `id` out of `nodes` (`hide`: leave it, hidden); whether it was there. +fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { + if let Some(i) = nodes.iter().position(|n| n.id == id) { + if hide { + if let Some(n) = nodes.get_mut(i) { + Arc::make_mut(n).visible = false; + } + } else { + nodes.remove(i); + } + return true; + } + nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) +} + +/// The text objects made from the file's text document that show and print on the page's area, +/// where the page has no type to match them, and that the layers look no worse without, are not +/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type +/// of what was turned to outlines): they are taken out. How many. +pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize { + let mut found = vec![]; + unmark(&mut doc.layers, true, true, &mut found); + let rect = frame.rect; + if found.is_empty() || found.len() > MAX_WEIGHED { + return 0; + } + let with = view.difference(doc, rect).any; + // Taking a text object out changes only what is drawn in its box. When the art would still + // differ from the page by more than `compare` allows with every candidate gone, the layers + // aren't used whatever is pruned: don't draw them again for each one (#758). + let margin = OUTLINE_REACH + 1.0; + let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum(); + if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE { + return 0; + } + // Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no + // less there for being about as light (#1102). + let mut with = view.colour_difference(doc, rect); + let mut removed = 0; + for (id, bounds) in found { + if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { + continue; + } + let mut without = doc.clone(); + if !take_out(&mut without.layers, id, true) { + continue; + } + let d = view.colour_difference(&without, rect); + if d - with <= UNSEEN_MARGIN { + take_out(&mut doc.layers, id, false); + with = d; + removed += 1; + } + } + removed +} diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs new file mode 100644 index 000000000..ceaa584e2 --- /dev/null +++ b/crates/eps/src/import/native/compare.rs @@ -0,0 +1,322 @@ +use super::build::Frame; +use super::slots::OUTLINE_REACH; +use super::*; + +/// `doc` with its layers that don't print hidden, when it has any. +fn as_printed(doc: &Document) -> Option { + fn hide(nodes: &mut [Arc]) -> bool { + let mut any = false; + for n in nodes { + if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible { + Arc::make_mut(n).visible = false; + any = true; + } else if matches!(n.kind, NodeKind::Layer { .. }) + && let Some(c) = Arc::make_mut(n).children_mut() + { + any |= hide(c); + } + } + any + } + let mut d = doc.clone(); + hide(&mut d.layers).then_some(d) +} + +/// A premultiplied pixel seen on white. +fn on_white(p: &[u8; 4]) -> [u32; 3] { + let paper = 255 - u32::from(p[3]).min(255); + [0, 1, 2].map(|i| (u32::from(p[i]) + paper).min(255)) +} + +/// The lightness (0 to 255, Rec. 601 weights) of a pixel seen on white. +fn lightness([r, g, b]: [u32; 3]) -> u32 { + (r * 299 + g * 587 + b * 114) / 1000 +} + +/// How far (0 to 255) a pixel seen on white is from white: how much ink it has. +fn ink([r, g, b]: [u32; 3]) -> u32 { + 255 - r.min(g).min(b) +} + +/// How far apart two pixels' lightness, or their ink where one is white, may be for them to look +/// alike. +const SAME_PIXEL: u32 = 48; + +/// The most ink a pixel seen as white has. +const WHITE: u32 = 8; + +/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other +/// is white? +pub(super) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { + let (p, q) = (on_white(p), on_white(q)); + let (a, b) = (ink(p), ink(q)); + lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) +} + +/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour +/// and its CMYK equivalent stay within it; a red and a dark red don't). +const SAME_COLOUR: u32 = 64; + +/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same +/// lightness to be alike)? +pub(super) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { + let (p, q) = (on_white(p), on_white(q)); + p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) +} + +/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, of those +/// that differ away from type, and of those whose neighbors all differ too, away from type (where +/// one draws an object that the other doesn't, not the edges of one drawn a little differently, or +/// type laid out a little differently: a line the page sets in pieces is laid out afresh as one). +#[derive(Clone, Copy)] +pub(super) struct Difference { + pub(super) any: f64, + art: f64, + pub(super) solid: f64, +} + +/// The scale the comparisons draw a region at: about 400 pixels along its longer side. +fn compare_scale(r: Rect) -> f64 { + (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) +} + +/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases +/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher +/// resolution and apply the same reconstruction filter; keep the existing difference limits. +pub(super) fn comparison_samples(doc: &Document) -> u32 { + let (mut images, mut vector) = (false, false); + doc.walk(|n| match n.kind { + NodeKind::Image(_) => images = true, + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + _ => vector = true, + }); + if images && !vector { 4 } else { 1 } +} + +/// Render for the page check. The comparison scale bounds the long side to 400 px, so the +/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha. +pub(super) fn comparison_render( + renderer: &mut vectorcraft_render::Renderer, + doc: &Document, + rect: Rect, + scale: f64, + samples: u32, +) -> vectorcraft_render::Rendered { + let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false); + if samples == 1 { + return rendered; + } + let (width, height) = match vectorcraft_render::raster_size(rect, scale) { + Ok(size) => size, + // A malformed region cannot provide a comparable raster. + Err(_) => return rendered, + }; + let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else { + return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() }; + }; + let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw(); + vectorcraft_render::Rendered { width, height, pixels } +} + +/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers +/// with a renderer of their own, which keeps their decoded images between draws (the page's images +/// can share their keys and not their pixels, so the two don't share one). +pub(super) struct PageView { + page: vectorcraft_render::Rendered, + /// Where the page's own type is, in its pixels. + page_type: Vec, + /// Where objects read with their art style lie, in its pixels: the page has Illustrator's + /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't + /// held to look alike. + restyled: Vec, + layers: vectorcraft_render::Renderer, + scale: f64, + samples: u32, +} + +impl PageView { + pub(super) fn new(frame: &Frame<'_>) -> Self { + let ra = frame.page_rect; + let scale = compare_scale(ra); + // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in + // the app that wrote the file. + let samples = comparison_samples(frame.page); + let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); + let mut page_type = vec![]; + type_areas(&frame.page.layers, ra, scale, &mut page_type); + Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } + } + + /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out + /// of the comparisons ([`Structure::restyled`](ai::Structure)). + pub(super) fn restyled(&mut self, looks: &[Rect], region: Rect) { + let s = self.scale; + self.restyled = looks + .iter() + .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) + .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) + .collect(); + } + + /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). + pub(super) fn difference(&mut self, b: &Document, rb: Rect) -> Difference { + // The page doesn't print what isn't printed. + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); + let mut areas = self.page_type.clone(); + type_areas(&b.layers, rb, self.scale, &mut areas); + compared(&self.page, &y, &areas, &self.restyled) + } + + /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page + /// ([`differ_in_colour`]), type included: what taking an object out of the layers changes + /// there, when it is covered by one of about the same lightness (#1102). + pub(super) fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); + if (y.width, y.height) != (self.page.width, self.page.height) { + return 1.0; + } + let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count(); + differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) + } +} + +/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), +/// for a region other than the page's ([`PageView`] draws that one once). +pub(super) fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let scale = compare_scale(ra); + // A renderer each: the two documents' images can share their keys and not their pixels. + let samples = comparison_samples(a); + let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples); + let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples); + let mut areas = vec![]; + type_areas(&a.layers, ra, scale, &mut areas); + type_areas(&b.layers, rb, scale, &mut areas); + compared(&x, &y, &areas, &[]) +} + +/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from +/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked +/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, +/// and what the comparison looks for is art that is missing or out of place. +pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + return Difference { any: 1.0, art: 1.0, solid: 1.0 }; + } + let (w, h) = (x.width as usize, x.height as usize); + // Pixels in `skip` count as alike. + let skipped = in_areas(skip, w, h); + let differs: Vec = + x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); + let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); + let typed = in_areas(areas, w, h); + let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); + // A pixel on the region's edge has neighbours outside it, which don't count as differing. + let solid = (1..h.saturating_sub(1)) + .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) + .filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) + .count(); + let area = (w as f64 * h as f64).max(1.0); + Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area } +} + +/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin +/// for the strokes round it). +fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + if let NodeKind::Text(_) = n.kind + && let Some(b) = n.visual_bounds() + { + let px = + |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); + out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); + } else if let Some(c) = n.children() { + type_areas(c, region, scale, out); + } + } +} + +/// Which of `w` × `h` pixels (row by row) lie in one of `areas`. +fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { + // Each area adds one inside it: corners marked, then summed along the rows and down the columns. + let stride = w + 1; + let mut count = vec![0i64; stride * (h + 1)]; + let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; + for r in areas { + let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); + for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] { + if let Some(c) = count.get_mut(y * stride + x) { + *c += d; + } + } + } + for row in count.chunks_mut(stride) { + let mut run = 0; + for c in row { + run += *c; + *c = run; + } + } + let mut column = vec![0i64; stride]; + for row in count.chunks_mut(stride) { + for (c, sum) in row.iter_mut().zip(column.iter_mut()) { + *sum += *c; + *c = *sum; + } + } + (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() +} + +/// More of the page than this differing between the layers' art and the page's own, and the +/// layers aren't used. +pub(super) const MAX_DIFFERENCE: f64 = 0.05; + +/// More than this and the import says so. +const NOTABLE_DIFFERENCE: f64 = 0.002; + +/// More of the page than this where one draws an object that the other doesn't (see +/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. +pub(super) const MAX_SOLID_DIFFERENCE: f64 = 0.001; + +/// How far (points) the art the layers print may reach past a page that holds it all. +const PAST_THE_PAGE: f64 = 1.0; + +/// Do the layers' art and the page's own look alike (where the page holds all the art that prints, +/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little: +/// with the share of the pixels that differ away from type when that is notable, otherwise with the +/// share of those that differ in the type's areas. +pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result, String> { + let mut diff = view.difference(layered, frame.rect); + if frame.whole + && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) + .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) + { + // The page and the art the layers print beyond it, each in its own document's place. + let all = frame.rect.union(art); + let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0); + let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); + diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) }; + } + if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { + return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0)); + } + if diff.any <= NOTABLE_DIFFERENCE { + return Ok(None); + } + Ok(Some(if diff.art > NOTABLE_DIFFERENCE { + format!( + "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", + diff.art * 100.0 + ) + } else { + format!( + "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", + (diff.any - diff.art) * 100.0 + ) + })) +} diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs new file mode 100644 index 000000000..e0e7eacc0 --- /dev/null +++ b/crates/eps/src/import/native/layers.rs @@ -0,0 +1,242 @@ +use super::build::{Page, build}; +use super::slots::LAYERS_UNREAD; +use super::*; + +/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or +/// its program in the legacy format, see [`editing_copy`]): its layers (the +/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the +/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it +/// has one that can't be used, `visible` with a warning that says why. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { + let Some(data) = editing_copy(ps) else { return visible }; + let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); + match result { + Ok((document, mut warnings)) => { + for w in &visible.warnings { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + Imported { document, warnings, preview: false } + } + Err(why) => { + let mut v = visible; + v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); + v + } + } +} + +/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through +/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead, +/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the +/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have. +/// `warnings` are the notes of reading the PDF part; the notes returned are those, then the layers' +/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed +/// or its layers can't be read. The flag returned is `true` when the document is the one read from +/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing +/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as +/// outlines (a file whose type shows then comes in as its PDF part). +/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. +/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, +/// by which its art came in scaled. Editing data whose artboard matches the page only in those +/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from +/// it. +pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { + let unread = |mut all: Vec, why: &str| { + all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); + all + }; + let data = match ai::decode_private(private) { + Ok(data) => data, + Err(why) => return (visible, unread(warnings, &why), false), + }; + if !has_layers(&data) { + return (visible, warnings, false); + } + // The PDF part, its pages placed and named as the editing data's artboards are, when the + // layers can't be used (`why`). + let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { + place_pages(&ai::artboards(&data), &mut visible, unit); + let mut all = unread(warnings, why); + all.extend(ai::variables(&data).note()); + (visible, all, false) + }; + match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + // An image too large for the editing data's reader would be missing from the layers, while + // the PDF part has it: the file comes in as its PDF part instead (#1479). + Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { + from_pdf_part(visible, warnings, "an image is too large to read from them") + } + Ok((mut done, notes)) => { + done.setup.bleed = visible.setup.bleed; + let mut all = warnings; + all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); + (done, all, true) + } + Err(why) => from_pdf_part(visible, warnings, &why), + } +} + +/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as +/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on +/// each, to where they are, so the artboards keep their names and layout when the layers can't be +/// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its +/// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points: +/// then the layout is drawn `unit` times as large. +fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { + if boards.is_empty() || boards.len() != doc.artboards.len() { + return; + } + let differ = |k: f64| { + boards + .iter() + .zip(&doc.artboards) + .any(|((_, b), a)| (b.width() - a.rect.width() / k).abs() > 0.5 || (b.height() - a.rect.height() / k).abs() > 0.5) + }; + let k = if !differ(1.0) { + 1.0 + } else if unit.is_finite() && unit > 1.0 && !differ(unit) { + unit + } else { + return; + }; + for ((name, _), a) in boards.iter().zip(&mut doc.artboards) { + a.name.clone_from(name); + } + let [(_, first), _, ..] = boards else { return }; + // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it, + // `k` points a unit. + let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]); + let pages: Vec = doc.artboards.iter().map(|a| a.rect).collect(); + let moves: Vec = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect(); + for layer in &mut doc.layers { + place_art(Arc::make_mut(layer), &pages, &moves); + } + for (a, v) in doc.artboards.iter_mut().zip(&moves) { + a.rect = a.rect + *v; + } +} + +/// The objects in `layer` (and its sublayers) moved by the move of the page they are on. +fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { + let Some(children) = layer.children_mut() else { return }; + for child in children { + if matches!(child.kind, NodeKind::Layer { .. }) { + place_art(Arc::make_mut(child), pages, moves); + continue; + } + let Some(r) = child.visual_bounds() else { continue }; + // The page it is on: the one with most of it (pages keep only the art that reaches into + // them), else the nearest. + let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); + let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; + if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { + Arc::make_mut(child).transform(Affine::translate(*v), false); + } + } +} + +/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy +/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { + let data = ai::decode_private(private)?; + build(&data, None, Page::Artboard, false, false, 1.0) +} + +/// Does the editing data (or a program in the legacy format) have layers? +fn has_layers(data: &[u8]) -> bool { + data.windows(15).any(|w| w == b"%AI5_BeginLayer") +} + +/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program +/// itself when it has layers (the legacy format). `None` without one. +fn editing_copy(ps: &[u8]) -> Option, String>> { + match ai::eps_data(ps) { + Some(data) => Some(data.map(Cow::Owned)), + None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), + } +} + +/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its +/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the +/// document and the import's notes. A file whose header says it is in the format (`illustrator`) +/// but whose program has no layers (Illustrator 3 had none; image editors still export their +/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program +/// has no layers and isn't in the format: other PostScript isn't read as the format's operators. +pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option), String>> { + if ai::has_eps_data(ps) { + return None; + } + if has_layers(ps) { + return Some(build(ps, None, Page::Art, false, false, 1.0)); + } + let program = one_layer(ps).filter(|_| illustrator)?; + Some(build(&program, None, Page::Art, false, false, 1.0)) +} + +/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its +/// end) on a layer that shows, prints and isn't locked, as the version 5 format writes one. `None` +/// without a `%%EndSetup`: where the art starts isn't known. +fn one_layer(ps: &[u8]) -> Option> { + let setup = line_with(ps, 0, b"%%EndSetup")?; + let art = line_with(ps, setup + 1, b"").unwrap_or(ps.len()); + let end = line_with(ps, art, b"%%PageTrailer").or_else(|| line_with(ps, art, b"%%Trailer")).unwrap_or(ps.len()); + let mut out = Vec::with_capacity(ps.len() + 80); + out.extend_from_slice(ps.get(..art)?); + out.extend_from_slice(b"\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Layer 1) Ln\n"); + out.extend_from_slice(ps.get(art..end)?); + out.extend_from_slice(b"\nLB\n%AI5_EndLayer--\n"); + out.extend_from_slice(ps.get(end..)?); + Some(out) +} + +/// Where the first line of `ps` from `from` on that starts with `key` starts. +fn line_with(ps: &[u8], from: usize, key: &[u8]) -> Option { + (from..ps.len()).find(|&i| (i == 0 || matches!(ps.get(i - 1), Some(b'\n' | b'\r'))) && ps.get(i..).is_some_and(|rest| rest.starts_with(key))) +} + +/// `path` with each open subpath whose last anchor lies on its first (within a thousandth of a +/// point) closed there: the object's own path of type round a closed path is written open, ending +/// on its start, and type on an open path stops at its end, so it had no room to run past it. +pub(super) fn closed_where_ends_meet(mut path: PathData) -> PathData { + for sp in &mut path.subpaths { + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if sp.closed || sp.anchors.len() < 3 || first.p.distance(last.p) >= 1e-3 { + continue; + } + if let Some(l) = sp.anchors.pop() + && let Some(a0) = sp.anchors.first_mut() + { + a0.h_in = l.h_in; + } + sp.closed = true; + } + path +} + +#[cfg(test)] +mod closed_ends_tests { + use super::*; + + /// A path drawn round to its start (the last anchor on the first) is closed there, its first + /// anchor taking the last one's incoming handle; one whose ends are apart stays open. + #[test] + fn a_path_ending_on_its_start_is_closed() { + let mut bp = kurbo::BezPath::new(); + bp.move_to((100.0, 0.0)); + bp.curve_to((100.0, 55.0), (55.0, 100.0), (0.0, 100.0)); + bp.curve_to((-55.0, 100.0), (-100.0, 55.0), (-100.0, 0.0)); + bp.curve_to((-100.0, -55.0), (-55.0, -100.0), (0.0, -100.0)); + bp.curve_to((55.0, -100.0), (100.0, -55.0), (100.0, 0.0001)); + let closed = closed_where_ends_meet(PathData::from_bezpath(&bp)); + let sp = &closed.subpaths[0]; + assert!(sp.closed && sp.anchors.len() == 4, "{sp:?}"); + assert_eq!(sp.anchors[0].h_in, Point::new(100.0, -55.0)); + let mut open = kurbo::BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((50.0, 0.0)); + open.line_to((50.0, 50.0)); + assert!(!closed_where_ends_meet(PathData::from_bezpath(&open)).subpaths[0].closed); + } +} diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs new file mode 100644 index 000000000..aa86d57b6 --- /dev/null +++ b/crates/eps/src/import/native/plan.rs @@ -0,0 +1,222 @@ +use super::layers::closed_where_ends_meet; +use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type}; +use super::*; + +/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the +/// page has it, else type made from the text document, else (for a file whose text document can't say) +/// the page's type in painting order. +pub(super) fn plan( + infos: &[SlotInfo], + stories: &[Option>], + template: Option<(f64, f64)>, + to_doc: Affine, + pages: [&[Vec>]; 2], + warn: &mut Vec, +) -> Vec { + let [shown, hidden] = pages; + let lines: Vec>> = stories + .iter() + .map(|s| { + let (s, t) = (s.as_ref()?, template?); + Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) + }) + .collect(); + // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show + // has them too: a file's page may draw what its layers have hidden since. + let mut pairs: Vec<(f64, usize, usize)> = vec![]; + for (k, _) in infos.iter().enumerate() { + let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; + for (j, object) in shown.iter().enumerate() { + if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { + pairs.push((d, k, j)); + } + } + } + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut owner: Vec> = vec![None; shown.len()]; + for (_, k, j) in pairs { + if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { + *o = Some(k); + } + } + // Type the text document makes, shown or not: the page's pieces of it aren't needed. + let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); + // Where the made type that doesn't show lies, by slot. + let mut hidden_at: Vec<(usize, Rect)> = vec![]; + let mut taller = 0; + if let Some(t) = template { + for (k, info) in infos.iter().enumerate() { + let Some(Some(story)) = stories.get(k) else { continue }; + let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; + let mut region = story.region(t, to_doc); + // Area type and type on a path in the object's own frame: the text document's frame + // of a copied object keeps its original's path. + if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { + let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + match &mut text.kind { + TextKind::Area { frame } => { + *frame = path.clone(); + region = path.bounds(); + } + TextKind::OnPath { path: on, .. } => { + *on = closed_where_ends_meet(path.clone()); + region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); + } + TextKind::Point => {} + } + } + if let (false, Some(r)) = (info.shown, region) { + hidden_at.push((k, r)); + } + if let NodeKind::Text(text) = &mut node.kind + && story.fit_first_line(text) + { + taller += 1; + } + if let Some(c) = content.get_mut(k) { + *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); + } + } + } + if taller > 0 { + warn.push(format!( + "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" + )); + } + // The file's text document is trusted where it agrees with the page about something (its lines + // start where the page's type does, or the page has type where it lays type out), or where the + // page has no type to disagree with. + let in_region = |object: &[Arc]| covered(object, &content, |_| true); + let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o)); + if !trusted { + content.iter_mut().for_each(|c| *c = Content::Empty); + } + // Is `object` the type of a text object that doesn't show, as the page draws it: in its frame, + // with its words? (A copied object's lines are where its original's are, so they don't say.) + let hidden_type = |object: &[Arc]| { + let Some(r) = extent(object) else { return false }; + let words: Vec = + object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); + hidden_at.iter().any(|(k, place)| { + place.intersect(r).area() >= 0.5 * r.area().max(1e-9) + && lines + .get(*k) + .and_then(Option::as_ref) + .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + }) + }; + // The letters of each slot's story, in lower case. + let spelled: Vec = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect(); + // For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in + // its story? All are false unless such slots have the letters of every text between them. + let spelling = |object: &[Arc], content: &[Content]| -> Vec { + let r = extent(object); + let words: Vec = object + .iter() + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None }) + .filter(|w| !w.is_empty()) + .collect(); + let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str()))); + let near: Vec = content + .iter() + .enumerate() + .map(|(k, c)| match (c, r) { + (Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k), + _ => false, + }) + .collect(); + let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); + if every { near } else { vec![] } + }; + let mut spare: Vec>> = vec![]; + for (j, object) in shown.iter().enumerate() { + let spells = spelling(object, &content); + let spells = |k: usize| spells.get(k).copied().unwrap_or(false); + match owner.get(j).copied().flatten().filter(|_| trusted) { + // The type of a text object the layers hide: the layers have it, hidden, so the page's + // isn't another text object's. + Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {} + Some(k) => match content.get_mut(k) { + Some(Content::Made(_, _, seen, _)) => *seen = true, + Some(Content::Page(p)) => p.extend(object.iter().cloned()), + Some(c) => *c = Content::Page(object.clone()), + None => {} + }, + // What lies where made type is and has that type's letters is that type as the page draws it. + None if covered(object, &content, spells) => { + let r = extent(object).unwrap_or(Rect::ZERO); + for (k, c) in content.iter_mut().enumerate() { + if let Content::Made(_, Some(region), seen, _) = c + && !region.intersect(r).is_zero_area() + && spells(k) + { + *seen = true; + } + } + } + None if trusted && hidden_type(object) => {} + None => spare.push(object.clone()), + } + } + for (k, c) in content.iter_mut().enumerate() { + if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) { + join_pieces(parts, l); + } + } + // What is left of the page goes by painting order into the shown slots the file can't say. + let loose: Vec = + infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); + if loose.is_empty() { + // Type of the page that no text object says it owns goes with the nearest text object that has + // type of the page; with none, it is left out. + let mut left_out = false; + for object in &spare { + let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c { + Content::Page(parts) => extent(parts).map(|r| r.center()), + Content::Made(_, region, ..) => region.map(|r| r.center()), + _ => None, + }); + let nearest = content + .iter_mut() + .filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c))) + .min_by(|a, b| a.0.total_cmp(&b.0)); + match nearest { + Some((_, Content::Page(parts) | Content::Made(_, _, _, parts))) => parts.extend(object.iter().cloned()), + _ => left_out = true, + } + } + if !spare.is_empty() { + warn.push( + if left_out { + "some of the page's type has no text object in the layers, so it is left out" + } else { + "some of the page's type has no text object of its own in the layers, so it is with another's" + } + .into(), + ); + } + } else { + for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) { + if let Some(c) = content.get_mut(*slot) { + // A text object the page shows as part of another's has nothing to hold of its own. + *c = if parts.is_empty() && !spare.is_empty() { + Content::Merged + } else { + Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()) + }; + } + } + if !spare.is_empty() && spare.len() != loose.len() { + warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into()); + } + } + // The same for the slots that don't show. + let unread: Vec = + infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); + for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) { + if let Some(c) = content.get_mut(*slot) { + *c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()); + } + } + content +} diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs new file mode 100644 index 000000000..07f7ef13d --- /dev/null +++ b/crates/eps/src/import/native/slots.rs @@ -0,0 +1,470 @@ +use super::build::MADE; +use super::*; + +/// The notes that say the import left out something the file has (see [`is_loss`]). +pub(super) const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; + +/// The same for a file without a page to take shown type from. +pub(super) const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out"; + +pub(super) const ART_LEFT_OUT: &str = "hidden layers have art this can't read"; + +pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read"; + +/// Does this import note say that the file has something the document doesn't (hidden text, art or +/// layers that could not be read)? Writing the document over the file would lose it for good. +pub fn is_loss(note: &str) -> bool { + note.ends_with(TEXT_LEFT_OUT) + || note.ends_with(TYPE_LEFT_OUT) + || [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) +} + +/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched +/// to each other (more than this and the layers aren't used). +pub(super) const MAX_SLOTS: usize = 5000; + +const MAX_PIECES: usize = 2000; + +/// Most bytes of text in the text objects of the layers (a story named twice counts twice). +pub(super) const MAX_TEXT: usize = 16 << 20; + +/// Most pairs of a text object and a piece of the page's type compared, and of a glyph the page +/// kept as outlines and a node of the page's type. +pub(super) const MAX_PAIRS: usize = 4_000_000; + +/// `names` for a note: the first few, and how many more. +pub(super) fn few(names: BTreeSet) -> String { + const SHOWN: usize = 5; + let more = names.len().saturating_sub(SHOWN); + let mut list: Vec = names.into_iter().take(SHOWN).collect(); + if more > 0 { + list.push(format!("{more} more")); + } + list.join(", ") +} + +/// A text slot of the layers. +pub(super) struct SlotInfo { + pub(super) story: Option, + /// Which of the story's frames it is. + pub(super) frame: usize, + /// It, its layer and the objects round it show. + pub(super) shown: bool, + /// Its own frame or path, where the file places it ([`ai::text_frames`]). + pub(super) art_path: Option, +} + +/// The text slots of the layers and objects, in order. +pub(super) fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { + for n in nodes { + let shown = shown && n.visible; + if let Some(story) = slot_of(n.name.as_deref()) { + out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None }); + } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { + collect_slots(c, shown, out); + } + } +} + +/// How far from a text object's own extent the strokes drawn around it reach (points). +pub(super) const OUTLINE_REACH: f64 = 6.0; + +/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`? +fn outlines(n: &Node, around: Rect) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0)) + && n.visual_bounds().is_some_and(|b| { + let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH); + b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1 + }) +} + +/// How far apart (points) the baselines of two text nodes may be for them to be on one line. +const SAME_LINE: f64 = 0.5; + +/// Where `n`'s baseline lies across the direction it runs in (points), if it is text. +fn baseline(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let [a, b, _, _, e, f] = t.xf.as_coeffs(); + let len = a.hypot(b); + (len > 1e-9).then(|| (a * f - b * e) / len) +} + +/// The text objects the page paints, each as the run of nodes that make it up (its text, once for +/// each fill and stroke it has, with the paths of its strokes), in painting order: those that +/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`. +pub(super) fn text_objects(nodes: &[Arc], on: bool, shown: &mut Vec>>, hidden: &mut Vec>>) { + let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_)); + let (mut i, mut free) = (0, 0); + while let Some(n) = nodes.get(i) { + if !is_text(n) { + if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { + text_objects(c, on && n.visible, shown, hidden); + free = i + 1; + } + i += 1; + continue; + } + let mut bounds = n.visual_bounds().unwrap_or_default(); + let (mut start, mut end) = (i, i); + while start > free + && let Some(p) = nodes.get(start - 1) + && outlines(p, bounds) + { + start -= 1; + bounds = bounds.union(p.visual_bounds().unwrap_or_default()); + } + while let Some(next) = nodes.get(end + 1) + && let Some(b) = next.visual_bounds() + && (is_text(next) + && !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area() + && baseline(next) + .zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m))) + .is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE) + || outlines(next, bounds)) + { + end += 1; + bounds = bounds.union(b); + } + let object = nodes.get(start..=end).unwrap_or_default().to_vec(); + let into = if on && n.visible { &mut *shown } else { &mut *hidden }; + into.push(object); + i = end + 1; + free = i; + } +} + +/// `n` and what it holds with ids of `doc`. +fn reid(doc: &mut Document, n: &Arc) -> Arc { + let mut n = n.as_ref().clone(); + n.id = doc.alloc_id(); + if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + *c = reid(doc, c); + } + } + Arc::new(n) +} + +/// The page's text objects given to each of `slots` text slots: in painting order, and when there +/// are more of one than of the other (a label may be a few text objects, or one with several +/// strokes), each goes where the share of the order it is at falls. +pub(super) fn assign(objects: &[Vec>], slots: usize) -> Vec]>> { + let mut given: Vec]>> = vec![vec![]; slots]; + for (j, o) in objects.iter().enumerate() { + if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) { + at.push(o); + } + } + given +} + +/// What goes into a text slot. +pub(super) enum Content { + /// Text objects (with the strokes round them) of the page. + Page(Vec>), + /// A text object made from the file's text document, where it lies on the document, whether + /// the page has type where it is, and the page's type of no text object that is nearest it + /// (kept after it). + Made(Box, Option, bool, Vec>), + /// Nothing the file lets this read. + Empty, + /// Its type is on the page with another text object's; there is nothing to put here. + Merged, +} + +/// Does `n` have transparency of its own: an opacity below 100%, a blend mode or isolation? +fn see_through(n: &Node) -> bool { + n.opacity < 1.0 || n.blend != BlendMode::Normal || n.isolate +} + +/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is +/// left out): the page's type takes its place as it is, made type keeps the slot's name. Made type +/// takes the slot's opacity, blend mode, isolation and knockout. The page's type takes them too when +/// the page draws it and the strokes around it with no transparency, the slot's, or the slot's +/// combined with that of the objects around `nodes`; otherwise they keep their own. Where the slot has +/// transparency and the type comes with other art that has none of its own, a group of them takes the +/// slot's instead. `round` has the opacity and blend mode of each object around `nodes`, which the page +/// can have folded into its type. `empty` counts those left out. +pub(super) fn fill_slots( + doc: &mut Document, + nodes: &mut Vec>, + content: &mut std::vec::IntoIter, + names: &std::collections::HashMap, + empty: &mut usize, + made: &mut Vec<(u32, usize, NodeId)>, + round: &mut Vec<(f32, BlendMode)>, +) { + let mut i = 0; + while i < nodes.len() { + let Some(n) = nodes.get(i) else { break }; + if let Some(story) = slot_of(n.name.as_deref()) { + let visible = n.visible; + let name = names.get(&n.id); + let frame = ai::slot_frame(n.name.as_deref()); + let state = (n.opacity, n.blend, n.isolate, n.knockout); + let transparent = see_through(n); + // An art style read onto the slot (fills, strokes and effects round the characters) is the type's. + let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone()); + let replacement = match content.next() { + Some(Content::Page(parts)) if !parts.is_empty() => { + // The page's type (and the strokes round it) take the slot's place as they are. + let mut parts: Vec> = parts + .iter() + .map(|p| { + let mut p = reid(doc, p); + if !visible { + Arc::make_mut(&mut p).visible = false; + } + if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) { + Arc::make_mut(&mut p).name = Some(name.clone()); + } + p + }) + .collect(); + // When the page draws each of them with no transparency, the slot's, or the slot's + // combined with that of the objects around it, the slot's takes its place: on the one + // part, or on a group of several when the slot has transparency. Otherwise every part + // keeps the transparency the page draws it with (a fill's or a stroke's). + let above: f32 = round.iter().map(|r| r.0).product(); + let objects = |p: &Node| { + !see_through(p) + || (!p.isolate + && (p.blend == BlendMode::Normal || p.blend == state.1 || round.iter().any(|r| r.1 == p.blend)) + && [state.0, state.0 * above].iter().any(|o| (p.opacity - o).abs() < 1e-3)) + }; + if parts.iter().all(|p| objects(p)) { + for p in &mut parts { + let p = Arc::make_mut(p); + (p.opacity, p.blend, p.isolate) = (1.0, BlendMode::Normal, false); + } + if let [p] = parts.as_mut_slice() { + let p = Arc::make_mut(p); + (p.opacity, p.blend, p.isolate, p.knockout) = state; + } else if transparent { + let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts)); + (group.opacity, group.blend, group.isolate, group.knockout) = state; + parts.push(Arc::new(group)); + } + } + let n = parts.len(); + nodes.splice(i..=i, parts); + i += n; + continue; + } + Some(Content::Made(mut text, _, seen, extra)) => { + text.id = doc.alloc_id(); + if let Some(story) = story { + made.push((story, frame, text.id)); + } + text.visible = visible; + // Type with an art style is what the page draws as the style's look: it is seen. + let seen = seen || styled.is_some(); + if let Some(appearance) = styled { + text.appearance = appearance; + } + // Type the page shows is kept; other made type is weighed against the page. + text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; + let extra: Vec> = extra.iter().map(|p| reid(doc, p)).collect(); + // The type takes the slot's transparency; the page's type kept after it, where it has + // none of its own, goes into a group with it that has the transparency instead. + let parts: Vec> = if transparent && !extra.is_empty() && !extra.iter().any(|p| see_through(p)) { + let mut group = Node::group(doc.alloc_id(), std::iter::once(Arc::new(*text)).chain(extra).collect()); + (group.opacity, group.blend, group.isolate, group.knockout) = state; + vec![Arc::new(group)] + } else { + (text.opacity, text.blend, text.isolate, text.knockout) = state; + std::iter::once(Arc::new(*text)).chain(extra).collect() + }; + let n = parts.len(); + nodes.splice(i..=i, parts); + i += n; + continue; + } + Some(Content::Merged) => { + nodes.remove(i); + continue; + } + _ => None, + }; + match replacement { + Some(node) => { + if let Some(slot) = nodes.get_mut(i) { + *slot = Arc::new(node); + } + i += 1; + } + None => { + *empty += 1; + nodes.remove(i); + } + } + } else { + let state = (n.opacity, n.blend); + if n.children().is_some() + && !matches!(n.kind, NodeKind::Compound { .. }) + && let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut) + { + round.push(state); + fill_slots(doc, c, content, names, empty, made, round); + round.pop(); + } + i += 1; + } + } +} + +/// How far, in points, the page's type may be from where the file's text document puts it for the +/// two to be the same type. +const SAME_PLACE: f64 = 0.75; + +/// Its letters and digits, which is what is compared of type (the page may spell a mark another way). +pub(super) fn letters(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).collect() +} + +/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it +/// (the page splits a line where its kerning or its styles change), on that line's baseline and +/// after its start? How far from where the line starts it is, if so. +pub(super) fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let have = letters(&t.plain_text()); + let [a, b, _, _, e, f] = t.xf.as_coeffs(); + let len = a.hypot(b); + if have.is_empty() || len < 1e-9 { + return None; + } + let (ux, uy) = (a / len, b / len); + // About how far a character of it advances, to say how far along a line a piece may be. + let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64; + lines + .iter() + .filter_map(|(text, at)| { + let line = letters(text); + let skipped = line.find(&have)?; + if skipped == 0 { + return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE); + } + let (dx, dy) = (e - at.x, f - at.y); + let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs()); + ((have.len() > 1 || skipped + 1 == line.len()) + && across <= SAME_PLACE + && along >= -SAME_PLACE + && along <= line.len() as f64 * advance * 2.0 + 4.0) + .then_some(across + 0.001 * along) + }) + .min_by(f64::total_cmp) +} + +/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file +/// has it as one line of one text object: the pieces that on one baseline add up to a line of +/// `lines` become one text (each keeping its own style, the line's spaces between them). +pub(super) fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { + let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; + let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); + // A page with this many pieces of type in one place isn't one of lines of a story. + if texts.len() > MAX_PIECES { + return; + } + let mut taken = vec![false; parts.len()]; + // The first piece of each line to join, the others, and the spaces to put after each piece. + let mut joins: Vec<(usize, Vec, Vec)> = vec![]; + for &i in &texts { + if taken.get(i).copied().unwrap_or(true) { + continue; + } + let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; + let group: Vec = texts + .iter() + .copied() + .filter(|j| { + !taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE) + }) + .collect(); + for j in &group { + if let Some(t) = taken.get_mut(*j) { + *t = true; + } + } + let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); + let places: Vec<(f64, f64)> = group + .iter() + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None }) + .collect(); + let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01)); + let joined: String = pieces.iter().map(|p| letters(p)).collect(); + if group.len() < 2 || !distinct || joined.is_empty() { + continue; + } + let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue }; + // Where the line has spaces after a piece's last character that the page doesn't draw. + let line_chars: Vec = whole.chars().collect(); + let mut at = 0; + let mut gaps = vec![]; + for (k, piece) in pieces.iter().enumerate() { + for _ in piece.chars().filter(|c| !c.is_whitespace()) { + while line_chars.get(at).is_some_and(|c| c.is_whitespace()) { + at += 1; + } + at += 1; + } + let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect(); + let next = pieces.get(k + 1); + let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace)); + gaps.push(if drawn { String::new() } else { space }); + } + if at <= line_chars.len() { + joins.push((group[0], group[1..].to_vec(), gaps)); + } + } + let mut gone: BTreeSet = BTreeSet::new(); + for (first, rest, gaps) in &joins { + let mut runs: Vec> = [first] + .into_iter() + .chain(rest) + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None }) + .collect(); + for (r, gap) in runs.iter_mut().zip(gaps) { + if let Some(last) = r.last_mut() { + last.text.push_str(gap); + } + } + if let Some(node) = parts.get_mut(*first).map(Arc::make_mut) + && let NodeKind::Text(t) = &mut node.kind + { + t.runs = runs.into_iter().flatten().collect(); + // The layout cached for the first piece is not this text's. + t.cached_bounds = None; + t.cached_baselines.clear(); + } + gone.extend(rest); + } + // The pieces that went into a text, removed once the joins are done: removing as each is made + // would move the places of the pieces that the others still name. + let mut keep = 0; + parts.retain(|_| { + keep += 1; + !gone.contains(&(keep - 1)) + }); +} + +/// Do the regions of made type in the slots that `keep` takes cover most of `object`? +pub(super) fn covered(object: &[Arc], content: &[Content], keep: impl Fn(usize) -> bool) -> bool { + let Some(r) = extent(object) else { return false }; + let inside: f64 = content + .iter() + .enumerate() + .filter_map(|(k, c)| match c { + Content::Made(_, Some(region), ..) => Some(region.intersect(r).area()).filter(|a| *a > 0.0 && keep(k)), + _ => None, + }) + .sum(); + inside >= 0.5 * r.area().max(1e-9) +} + +/// The box round `parts`. +pub(super) fn extent(parts: &[Arc]) -> Option { + parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) +} diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index 892bcf417..2fe6ba324 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -768,17 +768,24 @@ fn errors_say_what_the_postscript_error_means() { fn every_postscript_error_the_reader_raises_has_a_meaning() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join("import"); let mut raised = std::collections::BTreeSet::new(); - for entry in std::fs::read_dir(&dir).unwrap() { - let path = entry.unwrap().path(); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if !name.ends_with(".rs") || name.starts_with("tests") { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - for marker in ["Ps(\"", "ps_err(\""] { - for (at, _) in text.match_indices(marker) { - let rest = &text[at + marker.len()..]; - raised.insert(rest[..rest.find('"').unwrap()].to_string()); + let mut stack = vec![dir]; + while let Some(d) = stack.pop() { + for entry in std::fs::read_dir(&d).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if path.is_dir() { + stack.push(path); + continue; + } + if !name.ends_with(".rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + for marker in ["Ps(\"", "ps_err(\""] { + for (at, _) in text.match_indices(marker) { + let rest = &text[at + marker.len()..]; + raised.insert(rest[..rest.find('"').unwrap()].to_string()); + } } } } diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index a2d24a07f..fc7d6e9d9 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -72,6 +72,17 @@ use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, R use crate::{latin, ps}; +mod encoding; +mod prolog; +mod writer; + +#[cfg(test)] +use encoding::writable; +use encoding::{gradient_def, latin_string}; +pub(crate) use prolog::PROCSET; +use prolog::{PROLOG, PS_HEADER, fonts}; +use writer::Writer; + /// How deep symbols and live objects may nest in one another (deeper art is left out). Groups and /// layers need no limit of their own: a document is never deeper than /// [`vectorcraft_doc::MAX_DEPTH`]. @@ -226,1255 +237,5 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result` when it links to a URL (Attributes panel). - fn node(&mut self, n: &Node) { - let layer = usize::from(n.is_layer()); - self.layer_nest += layer; - match n.url().filter(|_| n.visible && !self.anonymous) { - Some(url) => { - self.line(&format!("", xml_escape(url))); - self.depth += 1; - self.node_body(n); - self.depth -= 1; - self.line(""); - } - None => self.node_body(n), - } - self.layer_nest -= layer; - } - - fn node_body(&mut self, n: &Node) { - // Hidden layers and objects are left out, unless the options keep them (hidden). Template - // layers never print. - if !(n.visible || self.opts.hidden_layers) { - return; - } - if has_raster(&n.appearance.effects) { - return self.filtered(n); - } - if let Some(m) = n.extras().mask.as_deref() - && !m.disabled - { - return self.masked(n, m); - } - match &n.kind { - NodeKind::Layer { template: true, .. } => {} - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - let id = self.id_attr(n); - let a = self.attrs(&self.group_props(n)); - self.line(&format!("")); - self.depth += 1; - self.group_children(n, children); - self.depth -= 1; - self.line(""); - } - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - let Some((clip, rest)) = children.split_first() else { return }; - let cid = self.fresh_id("clip-path"); - self.def(1, &format!("")); - // The region every output clips to; with nothing to clip by, an empty clip path - // hides the clipped art. - if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { - let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(2, &format!("")); - } - self.def(1, ""); - let id = self.id_attr(n); - let a = self.attrs(&self.group_props(n)); - // The clipping path's fill paints behind the clipped art and its stroke over it, - // outside the clip (the group then wraps both). - let paint = clip.clip_paint(); - if paint.stroke.is_some() { - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - } else { - self.line(&format!("")); - } - self.depth += 1; - if let Some(fill) = &paint.fill { - self.node(fill); - } - self.group_children(n, rest); - self.depth -= 1; - self.line(""); - if let Some(stroke) = &paint.stroke { - // One element keeps the clipping path's id. - let anonymous = self.anonymous; - self.anonymous |= paint.fill.is_some(); - self.node(stroke); - self.anonymous = anonymous; - self.depth -= 1; - self.line(""); - } - } - NodeKind::Path { guide: true, .. } => {} - NodeKind::Path { path, rule, .. } => { - if path.is_empty() { - return; - } - let d = self.path_d(path, self.xf); - self.shape(n, &d, &[path], *rule, n.geometric_bounds()); - } - NodeKind::Compound { children, rule } => { - let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); - if paths.is_empty() { - return; - } - let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); - self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); - } - NodeKind::Text(t) => self.text_node(n, t), - NodeKind::Image(im) => { - let id = self.id_attr(n); - let m = self.matrix(self.xf * im.xf); - let a = self.attrs(&css::transparency(n)); - if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { - let shared = shared.clone(); - self.line(&format!("")); - return; - } - let Some(href) = self.image_href(im) else { return }; - self.line(&format!( - "", - im.width, - im.height, - xml_escape(&href) - )); - } - NodeKind::SymbolInstance { symbol, xf } => { - let doc = self.doc; - let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; - if self.symbol_nest > MAX_SYMBOL_NEST { - return; - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - if let Some(def) = self.symbol_use(sym, n, *xf) { - let m = self.xf * *xf; - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - self.line(&format!("")); - return; - } - // An instance of its own: the (stained) art moved by the instance, its pattern - // paints and unlinked masks left in place as on the canvas. - let mut art = vectorcraft_brush::instance_art(&sym.art, n); - let moved = self.instance_xf * *xf; - if moved.determinant().abs() > 1e-12 { - pin_to_page(&mut art, moved.inverse()); - } - let saved = (self.xf, self.instance_xf); - self.xf = saved.0 * *xf; - self.instance_xf = moved; - self.line(&format!("")); - self.depth += 1; - // The symbol's art is the instance's own picture, outside any knockout around it. - let knockout = std::mem::take(&mut self.knockout); - self.symbol_nest += 1; - self.node(&art); - self.symbol_nest -= 1; - self.knockout = knockout; - self.depth -= 1; - self.line(""); - (self.xf, self.instance_xf) = saved; - } - // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - self.share_images(&g); - self.node_body(&g); - } - } - } - - /// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written - /// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across - /// documents). - fn blob_href(&mut self, b: &ImageBlob) -> String { - if self.opts.images != ImageMode::Link { - return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); - } - let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); - if self.linked_names.insert(name.clone()) { - self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); - } - name - } - - /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is - /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a - /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled - /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour - /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size - /// in points); a linear or radial gradient at the document's raster effects resolution (at most - /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty - /// box. - #[allow(clippy::too_many_arguments)] - fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { - use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; - let Some(b) = painted_box(bounds) else { return }; - let freeform = g.gradient.kind == GradientKind::Freeform; - let (rgba, cols, rows): (Vec, u16, u16) = if freeform { - let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); - let field = g.freeform_on(b).field(spread_scale(b)); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) - } else { - let k = self.doc.raster_effects_ppi / 72.0; - let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; - let (cols, rows) = (along(b.width()), along(b.height())); - (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) - }; - let mut png = Vec::new(); - let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; - if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - return; - } - self.warn(if freeform { - "freeform gradients are written as images clipped to their shapes" - } else { - "linear and radial gradients drawn as pixels are written as images clipped to their shapes" - }); - let href = self.blob_href(&ImageBlob::new("image/png", png)); - let cid = self.fresh_id("clip-path"); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - for d in clip { - self.def(2, &format!("")); - } - self.def(1, ""); - let a = self.attrs(props); - let nested = !side.is_empty(); - if nested { - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - } else { - self.line(&format!("")); - } - self.depth += 1; - let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); - let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) }; - self.line(&format!( - "", - xml_escape(&href) - )); - self.depth -= 1; - self.line(""); - if nested { - self.depth -= 1; - self.line(""); - } - } - - /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first - /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved - /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). - fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { - // SVG Tiny has no ``. - if self.tiny() || vectorcraft_brush::stain(inst).is_some() { - return None; - } - if !self.symbols.contains_key(&sym.name) { - let reuse = Reuse::of(self.doc, &sym.art); - self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); - } - let def = self.symbols.get(&sym.name)?; - if !def.reuse.allows(xf) { - return None; - } - if let Some(id) = &def.id { - return Some(id.clone()); - } - // The art in symbol space, as its own picture: no knockout around it, and no object ids - // (every instance shows it). - let id = self.unique_id(&sanitize_id(&sym.name)); - let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); - (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); - self.symbol_nest += 1; - let art = self.detached(3, |w| w.node(&sym.art)); - self.symbol_nest -= 1; - (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; - // The symbol's name, when the id had to differ from it, comes back on import. - let name = if id == sym.name { - String::new() - } else if valid_xml_value(&sym.name) { - format!(" data-name=\"{}\"", xml_attr_escape(&sym.name)) - } else { - self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes"); - String::new() - }; - self.def(1, &format!("")); - self.defs.push_str(&art); - self.def(1, ""); - if let Some(def) = self.symbols.get_mut(&sym.name) { - def.id = Some(id.clone()); - } - Some(id) - } - - /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow - /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style - /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn - /// such objects into images first). - fn filtered(&mut self, n: &Node) { - let mut inner = n.clone(); - inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); - let opened = self.open_filters(n, &n.appearance.effects); - self.node_body(&inner); - self.close_filters(opened); - } - - /// Open one `` per visible raster effect of `effects` (an object's, or one fill or - /// stroke's) on `n`, outermost last effect; returns how many to close. - fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { - use vectorcraft_effects::RasterFx; - let fx = vectorcraft_effects::raster_effects(effects); - if self.tiny() { - if !fx.is_empty() { - self.warn("SVG Tiny 1.2 has no filters: shadows, glows, blurs and feathers are left out"); - } - return 0; - } - let bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); - // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. - let grow = 18.0 / self.doc.raster_ppi(); - let reach: f64 = - fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() - + 2.0; - let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); - let mut opened = 0; - for f in fx.iter().rev() { - if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { - self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); - continue; - } - let fid = self.fresh_id("filter"); - let region_attr = match region { - Some(r) => format!( - " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", - self.num(r.x0), - self.num(r.y0), - self.num(r.width()), - self.num(r.height()) - ), - None => String::new(), - }; - let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); - let flood = |c: &vectorcraft_color::Color, o: f32| { - format!("", c.to_hex(), fmt_num(o as f64, 3)) - }; - let body = match f { - // Darkness: the object blurred and moved, each color channel times 1 − darkness. - RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( - "", - sd(*blur), - self.num(*dx), - self.num(*dy), - fmt_num(*opacity as f64, 3), - keep = fmt_num(1.0 - *darkness as f64, 3) - ), - RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( - "{}", - sd(*blur), - self.num(*dx), - self.num(*dy), - flood(color, *opacity) - ), - // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. - RasterFx::OuterGlow { opacity, blur, color, .. } => format!( - "{}", - fmt_num(grow, 3), - sd(*blur), - flood(color, *opacity) - ), - RasterFx::InnerGlow { opacity, blur, color, center, .. } => { - // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. - let src = if *center { - "".replace("SD", &sd(*blur)) - } else { - format!( - "", - sd(*blur) - ) - }; - format!( - "{src}{}", - flood(color, *opacity) - ) - } - RasterFx::Feather { radius } => format!( - "", - sd(*radius) - ), - RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), - RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, - }; - // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. - let mode = match f { - RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } - if *mode != BlendMode::Normal => - { - format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) - } - _ => String::new(), - }; - self.def(1, &format!("{body}")); - self.line(&format!("")); - self.depth += 1; - opened += 1; - } - opened - } - - fn close_filters(&mut self, opened: usize) { - for _ in 0..opened { - self.depth -= 1; - self.line(""); - } - } - - /// The ``/`` properties of a character style. `space`: the text's layout bounds - /// and the map from text space to user space, which character gradients resolve against - /// (as on the canvas). - fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { - let decimals = self.opts.decimals; - let len = |v: f64| fmt_num(v, decimals); - let mut p = css::font_props(st, &len); - // Character paints resolve in the text's space (`to_user` maps it into user space). - let (bounds, to_user) = space; - let saved = std::mem::replace(&mut self.xf, to_user); - if let Paint::Gradient(g) = &st.fill - && g.gradient.dither - && g.gradient.kind != GradientKind::Freeform - { - self.warn("dithered gradients on characters are written without dither"); - } - let fill = self.paint(&st.fill, Some(bounds)); - p.push(("fill", fill)); - // An inside or outside stroke is drawn on the glyph outlines instead - // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. - if st.has_stroke() && !aligned(st) { - // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans - // the text grown by half the weight, as on the canvas. - let layer = st.stroke_layer(); - self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); - } - self.xf = saved; - p.extend(css::type_props(st, &len)); - p - } - - /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, - /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those - /// below the Characters row under the characters, the others over them. - /// - /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as - /// instances of their symbols (a `` of the symbol's def where it can be shared), all in - /// one group carrying the object's transparency. The live text skips their characters and - /// places the text after each one; type on a path with inline graphics is written as outlines - /// (a `` couldn't leave room for the art). - fn text_node(&mut self, n: &Node, t: &TextObject) { - let doc = self.doc; - let resolved = doc.inline_resolved(t); - let t = &*resolved; - let inline = t.runs.iter().any(|r| r.inline.is_some()); - // Ruby is written as outlines with its base text: a reader's can't place it. So are - // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). - let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) - || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); - let outlined = - self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; - let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; - let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); - // Live type with an inside or outside character stroke writes that stroke apart from the - // text, in the group. - let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); - if !painted && !inline && !apart { - return chars(self, n); - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - self.line(&format!("")); - self.depth += 1; - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let mut all = lay.to_bezpath(); - for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { - all.extend(bar.iter()); - } - let pd = PathData::from_bezpath(&all).transformed(t.xf); - let d = self.path_d(&pd, self.xf); - let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); - let (below, above) = n.appearance.split_contents(); - let glyphs = |w: &mut Self, items: &[AppearanceItem]| { - if !items.is_empty() { - let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; - w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); - } - }; - glyphs(self, below); - // The group carries the id and the object's transparency. - let mut bare = n.clone(); - (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); - let anonymous = std::mem::replace(&mut self.anonymous, true); - chars(self, &bare); - for ig in &lay.inlines { - let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; - let inst = Node::new( - NodeId(u64::MAX), - NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, - ); - self.node(&inst); - } - glyphs(self, above); - self.anonymous = anonymous; - self.depth -= 1; - self.line(""); - } - - /// Text as glyph outlines: one compound path per run, painted like the run (gradients span - /// the whole text), over its colour glyphs, as on the canvas. - fn text_outlines(&mut self, n: &Node, t: &TextObject) { - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; - // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted - // with the run of the text they annotate. - let bars = vectorcraft_text::decorations(&lay, db, t); - for (run, outline) in lay - .glyphs - .iter() - .map(|g| (g.run, &g.outline)) - .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) - .chain(bars.iter().map(|(r, b)| (*r, b))) - { - if let Some(bp) = runs.get_mut(run) { - bp.extend(outline.iter()); - } - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) - } else { - String::new() - }; - self.line(&format!("")); - self.depth += 1; - // The glyphs stay in text space; the text's transform joins the document's meanwhile. - let saved = self.xf; - self.xf = saved * t.xf; - for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { - // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as - // their images, COLR and SVG glyphs as their art. - for g in lay.glyphs.iter().filter(|g| g.run == i) { - if let Some((image, to_text)) = g.placed_image() { - let m = self.matrix(self.xf * to_text); - let href = self.blob_href(&image.blob); - self.line(&format!( - "", - image.width, - image.height, - xml_escape(&href) - )); - } - if let Some(art) = g.placed_colour(Affine::IDENTITY) { - self.node(&art); - } - } - if bp.elements().is_empty() { - continue; - } - let pd = PathData::from_bezpath(bp); - // An unnamed id: the pieces carry no id attribute (the group has it). - let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); - let d = self.path_d(&pd, self.xf); - self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); - } - self.xf = saved; - self.depth -= 1; - self.line(""); - } - - /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and - /// spacing come out as on the canvas. Centred and right-aligned lines are anchored - /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font - /// differs; other lines are placed where each style run (and, justified, each word) starts. - fn text(&mut self, n: &Node, t: &TextObject) { - // Live SVG text is written in logical order from left-aligned pieces: bidirectional text - // (and right-to-left paragraphs) keep their look as outlines until it is written with - // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose - // `direction="rtl"` would anchor their pieces at the right end. - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); - if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { - self.warn("bidirectional text is outlined to preserve shaping and visual order"); - return self.text_outlines(n, t); - } - if let TextKind::OnPath { path, start, end } = &t.kind { - // A reader lays a ``'s glyphs straight: joined glyphs bent along the path - // keep their look as outlines. - if lay.glyphs.iter().any(|g| g.bend.is_some()) { - self.warn("joined type bent along a path is outlined to keep its look"); - return self.text_outlines(n, t); - } - // `` has a start offset only: an end bracket, Align to Path and Spacing - // keep their look as outlines. - if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { - self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); - return self.text_outlines(n, t); - } - // It cannot hang glyphs outside the start bracket either. - if hangs_on_path(t, &lay) { - self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); - return self.text_outlines(n, t); - } - // A `` holds the characters of the text, not the bullets or numbers drawn - // before them. - if !lay.markers.is_empty() { - self.warn("type on a path with bullets or numbers is outlined to keep its look"); - return self.text_outlines(n, t); - } - return self.text_on_path(n, t, path, *start); - } - self.note_fonts(t, &lay); - let lines = text_lines(t, &lay, self.opts.fewer_tspans); - // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. - let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); - let drawn = fitted.as_ref().unwrap_or(t); - // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its - // stop, and so does the text after an inline graphic: lines with tabs, and text with - // inline graphics, are never anchored. - let has_inline = t.runs.iter().any(|r| r.inline.is_some()); - let anchors: Vec> = lines - .iter() - .map(|l| { - match t.para_at(l.para).justify { - Justify::Center => Some(("middle", 0.5)), - Justify::Right => Some(("end", 1.0)), - _ => None, - } - .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) - }) - .collect(); - // Every line anchored alike (the text's only paragraph style, typically): the - // carries the anchor. Otherwise each anchored line is a carrying its own. - let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); - // The element's user space is text space. - let space = (lay.bounds, Affine::IDENTITY); - let mut base_props = self.char_props(&drawn.first_style(), space); - // A `` cannot undo the decoration of the `` around it: with bullets, which - // have none, each run's `` carries its own. - if !lay.markers.is_empty() { - base_props.retain(|(k, _)| *k != "text-decoration"); - } - let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; - let mut props = base.props.clone(); - if let Some((a, _)) = uniform { - props.push(("text-anchor", a.into())); - } - props.extend(css::transparency(n)); - let id = self.id_attr(n); - let a = self.attrs(&props); - let m = self.xf * t.xf; - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); - // The `` carries the first line's position too, so readers that place text by its - // own x/y start where the first line does. - let at = match starts.iter().flatten().next() { - Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), - None => String::new(), - }; - let mut s = format!(""); - for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { - let Some(start) = start else { continue }; - match anchor.filter(|_| uniform.is_none()) { - // A line anchored on its own: one positioned with the anchor around it. - Some((a, _)) => { - s.push_str(&format!("", self.num(start.0), self.num(start.1))); - let inner = TextBase { anchored: true, ..base.clone() }; - self.text_line(&mut s, drawn, &line.segs, start, &inner, false); - s.push_str(""); - } - None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), - } - } - s.push_str(""); - self.line(&s); - self.aligned_char_strokes(t); - } - - /// The inside and outside character strokes of live type `t`, over its text (which goes - /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn - /// twice as wide and clipped or masked to its side as aligned path strokes are - /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. - fn aligned_char_strokes(&mut self, t: &TextObject) { - if !t.runs.iter().any(|r| aligned(&r.style)) { - return; - } - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let bars = vectorcraft_text::decorations(&lay, db, t); - // The glyphs stay in text space; the text's transform joins the document's meanwhile. - let saved = self.xf; - self.xf = saved * t.xf; - for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { - let mut bp = BezPath::new(); - for g in lay.glyphs.iter().filter(|g| g.run == i) { - bp.extend(g.outline.iter()); - } - for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { - bp.extend(bar.iter()); - } - if bp.elements().is_empty() { - continue; - } - let pd = PathData::from_bezpath(&bp); - let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; - let d = self.path_d(&pd, self.xf); - self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); - } - self.xf = saved; - } - - /// One line of laid-out text starting at `start`. Default: a positioned `` per segment - /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with - /// the style changes nested in it, positioned again only after tabs and inline graphics. - /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. - /// `positioned`: false when the caller already positioned the line (an outer ``). - fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { - let fewer = self.opts.fewer_tspans && positioned; - if fewer { - s.push_str(&format!("", self.num(start.0), self.num(start.1))); - } - let mut place = !fewer && positioned; - let mut first = true; - // The baseline shift the current text position carries. - let mut shift = 0.0; - for seg in line { - let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; - let marker_style; - let st = if seg.marker { - marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); - &marker_style - } else { - run_style - }; - let mut attrs = String::new(); - if place { - let (x, y) = if first { start } else { (seg.x, seg.y) }; - attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); - shift = 0.0; - } - first = false; - if st.baseline_shift != shift { - attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); - shift = st.baseline_shift; - } - // After a tab, an inline graphic or a justified word space, and before a character that a - // reader would draw elsewhere, the next segment is placed. - place = seg.brk || (!fewer && !base.anchored && positioned); - if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 { - attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale))); - } - if st.rotation != 0.0 { - attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); - } - let mut diff = run_diff(&base.props, self.char_props(st, base.space)); - diff.extend(direction_props(st)); - attrs.push_str(&self.attrs(&diff)); - let text = xml_text(&seg.text); - if attrs.is_empty() && fewer { - s.push_str(&text); - } else { - s.push_str(&format!("{text}")); - } - } - if fewer { - s.push_str(""); - } - } - - /// Record the characters of laid-out type `t` under the faces that supply them, for - /// [`Self::font_faces`] (when fonts are embedded). - fn note_fonts(&mut self, t: &TextObject, lay: &vectorcraft_text::TextLayout) { - if !self.opts.embed_fonts || self.opts.outline_text { - return; - } - if self.tiny() { - return self.warn("SVG Tiny 1.2 has no web fonts: fonts are not embedded"); - } - let db = FontDb::global(); - let plain = t.plain_text(); - let mut face: Option> = None; - for (i, g) in lay.glyphs.iter().enumerate() { - let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; - // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the - // viewer. - if g.image.is_some() || g.colour.is_some() { - self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); - continue; - } - if face.as_ref().is_none_or(|f| f.id() != g.font_id) { - face = db.face_by_id(g.font_id); - } - let Some(f) = &face else { continue }; - let (weight, italic) = font_descriptor(st); - let stretch = css::font_stretch(st).map(f64::to_bits); - let at = self - .fonts - .iter() - .position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family); - let at = at.unwrap_or_else(|| { - let family = st.font_family.clone(); - self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() }); - self.fonts.len() - 1 - }); - let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; - // As written: a list marker's characters, a hyphen where a line breaks, capitals for All - // Caps, the style's Digits. - let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); - for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { - if st.all_caps { - chars.extend(c.to_uppercase()); - } else { - chars.insert(c); - } - } - } - } - - /// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face - /// subset to its characters (whole when its licence forbids subsetting, left out with a - /// warning when it forbids embedding). Faces sharing a description cover their own - /// characters (`unicode-range`); where two of them share characters as well, a warning says - /// that browsers draw those in one of the faces. - fn font_faces(&mut self) -> Vec { - let uses = std::mem::take(&mut self.fonts); - let mut rules = Vec::new(); - fn described(u: &FontUse) -> (&str, u16, bool, Option) { - (&u.family, u.weight, u.italic, u.stretch) - } - let mut clashing: Vec<&String> = vec![]; - for (i, u) in uses.iter().enumerate() { - let clash = uses - .get(..i) - .unwrap_or_default() - .iter() - .any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars)); - if clash && !clashing.contains(&&u.family) { - clashing.push(&u.family); - } - } - for family in clashing { - self.warn(&format!( - "type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces" - )); - } - for u in &uses { - let chars: Vec = u.chars.iter().copied().collect(); - let name = format!("{} {}", u.face.family, u.face.style); - let Some(font) = u.face.embed(&chars) else { - self.warn(&format!("the licence of the font {name} doesn't allow embedding: viewers show its type in their own fonts")); - continue; - }; - if !font.subset { - self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); - } - let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; - let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; - let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default(); - let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") }; - rules.push(format!( - "@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}", - css_string(&u.family), - u.weight, - if u.italic { "italic" } else { "normal" }, - base64_encode(&font.data) - )); - } - rules - } - - /// Type on a path: a `` along the path (a def). - fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) { - let id = self.id_attr(n); - let pid = self.fresh_id("text-path"); - let d = self.path_d(path, self.xf * t.xf); - self.def(1, &format!("")); - // The element's user space is the document's; gradients span the laid-out text. - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - self.note_fonts(t, &lay); - let space = (lay.bounds, self.xf * t.xf); - let base = self.char_props(&t.first_style(), space); - let mut props = base.clone(); - match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), - Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), - _ => {} - } - props.extend(css::transparency(n)); - let a = self.attrs(&props); - let offset = fmt_num(start * 100.0, 3); - let mut s = format!(""); - for run in &t.runs { - let mut diff = run_diff(&base, self.char_props(&run.style, space)); - diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); - s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); - } - s.push_str(""); - self.line(&s); - self.aligned_char_strokes(t); - } -} - -/// Does a character style stroke inside or outside its glyphs (not centred)? -fn aligned(st: &CharStyle) -> bool { - st.has_stroke() && st.stroke_align != StrokeAlign::Center -} - -/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when -/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its -/// glyphs sit elsewhere with both off. -fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { - if !t.optical_margin && !t.para_at(0).roman_hanging { - return false; - } - let mut flush = t.clone(); - flush.optical_margin = false; - let mut styles = flush.paragraph_styles(); - for p in &mut styles { - p.roman_hanging = false; - } - flush.set_paragraph_styles(styles); - let flush = vectorcraft_text::layout(FontDb::global(), &flush); - lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) -} - -/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in -/// its Character Direction, and nothing without one. Each `` of characters with a direction -/// carries them, and the `` does not. Type with a run set Right to Left is written as -/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. -fn direction_props(st: &CharStyle) -> Props { - let Some(d) = st.char_direction else { return Props::new() }; - let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; - vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] -} - -/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the -/// properties that differ, and the initial value of each one `base` sets and the run doesn't. -fn run_diff(base: &Props, run: Props) -> Props { - let reset: Props = - base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); - run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() -} - -/// The initial value of an inherited property a run may leave unset (decorations can't be undone -/// on a ``). -fn initial_value(k: &str) -> Option<&'static str> { - Some(match k { - "font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal", - "stroke" | "stroke-dasharray" => "none", - "stroke-width" | "stroke-opacity" => "1", - "stroke-linecap" => "butt", - "stroke-linejoin" => "miter", - "stroke-miterlimit" => "4", - "stroke-dashoffset" | "letter-spacing" => "0", - "font-kerning" => "auto", - _ => return None, - }) -} - -/// A shape's paths as one path (document space). -fn doc_path(paths: &[&PathData]) -> BezPath { - let mut bp = BezPath::new(); - for p in paths { - bp.extend(p.to_bezpath()); - } - bp -} - -/// How closely stroke outlines that clip an image follow the stroke (points). -const OUTLINE_TOLERANCE: f64 = 0.01; - -/// Most pixels along a side of a linear or radial gradient's image. -const MAX_GRADIENT_PX: f64 = 4096.0; - -/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels -/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). -fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { - match p { - Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), - _ => None, - } -} - -/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? -fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { - effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) -} - -/// What the lines of one `` share. -#[derive(Clone)] -struct TextBase { - /// The `` element's character properties, which each `` differs from. - props: Props, - /// The text's layout bounds and the map from text space to user space (character paints). - space: (Rect, Affine), - /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. - anchored: bool, -} - -/// One laid-out line: its paragraph (index) and its segments. -struct Line { - para: usize, - segs: Vec, -} - -/// Characters of one style on one line, written as one ``. -struct Segment { - /// Index into `TextObject::runs`. - run: usize, - /// Where a reader starts drawing the segment (text space; `y` is the baseline). - x: f64, - y: f64, - text: String, - /// The width a reader gives the segment (tracking included). On a line that is justified or - /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi - /// ([`reader_advances`]), for horizontally scaled type also without its last character's - /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. - advance: f64, - /// The next segment needs its own position (after a tab, a word space of a justified line, or - /// the next segment starts with a character the layout draws elsewhere than a reader would). - brk: bool, - /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to - /// (`textLength`); 1 for other segments. - scale: f64, - /// A list marker's characters and tab, written in - /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. - marker: bool, -} - -/// How far a reader may draw a character from where the layout draws it before the character is -/// given its own position (points). -const PLACE_TOLERANCE: f64 = 1e-6; - -/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, -/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters -/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all -/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also -/// starts a new segment at each character the layout draws elsewhere than a reader continuing -/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between -/// the characters of a justified Japanese line, and the spacing Mojikumi changes. -fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { - let plain = t.plain_text(); - let mut lines = Vec::with_capacity(lay.lines.len()); - let (mut kerned, mut unkerned) = (None, None); - for line in &lay.lines { - let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); - let style = t.para_at(para); - let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); - // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. - // Justification…'s Desired word and letter spacing and glyph scaling move characters from - // where a reader draws them, on every line. - let spaced = !fewer && !style.justification.desired_is_default(); - let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { - Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) - } else { - None - }; - // Where a reader draws the next character of the last segment, and the byte offset and - // reader's advance of the last segment's last cluster. - let mut pen = 0.0; - let mut tail = None; - let mut segs: Vec = Vec::new(); - let mut cluster = None; - // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). - let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { - lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) - }; - let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); - // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. - if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { - while let Some((_, rest)) = - glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) - { - glyphs = rest; - } - } - let mut after_inline = false; - for (k, g) in glyphs.iter().enumerate() { - let Some(run) = t.runs.get(g.run) else { continue }; - // A list marker before an inline graphic is in the graphic's run, and is text. - if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { - // An inline graphic (written as art): the text after it is placed anew. - if let Some(last) = segs.last_mut() { - last.brk = true; - } - after_inline = true; - cluster = None; - continue; - } - // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). - let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); - if g.len > 0 && cluster == Some(g.byte) { - // Another glyph of the same cluster (a reader's advance covers the whole cluster). - if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { - last.advance += g.advance; - pen += g.advance; - } - continue; - } - // A glyph that stands for no character (a list marker before the paragraph's first - // character) starts no cluster. - cluster = (g.len > 0).then_some(g.byte); - let (advance, x) = match set { - Some(&(advance, offset)) => { - // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. - let x = g.xf.translation().x - offset; - (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) - } - None => (g.advance, g.origin.x), - }; - let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); - let piece = if run.style.all_caps { src.to_uppercase() } else { src }; - let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); - // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. - let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 }; - let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0); - // The layout draws the character elsewhere than a reader continuing the line would. - let moved = reader.is_some() - && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); - pen = x + advance; - // A list marker is a segment of its own, and so is the text after it. In horizontally - // scaled type a tab is a segment apart from the characters before it, so a reader - // stretching those characters to their width (`textLength`) stretches them alone. - let marker = lay.marker_of(line.glyph_start + k).is_some(); - let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; - match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { - last.text.push_str(&piece); - last.advance += advance; - last.brk = brk; - } - last => { - if let Some(last) = last { - last.brk |= moved || last.marker != marker; - end_segment(last, tail, t, lay, &mut unkerned); - } - segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); - } - } - tail = set.map(|_| (g.byte, advance)); - } - if let Some(last) = segs.last_mut() { - end_segment(last, tail, t, lay, &mut unkerned); - } - segs.retain(|s| !s.text.is_empty()); - if !segs.is_empty() { - lines.push(Line { para, segs }); - } - } - lines -} - -/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width -/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance -/// a reader gives it at the end of a ``, without the font's kerning against the character -/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. -fn end_segment( - seg: &mut Segment, - tail: Option<(usize, f64)>, - t: &TextObject, - lay: &vectorcraft_text::TextLayout, - unkerned: &mut Option>, -) { - let Some((byte, advance)) = tail else { return }; - if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { - return; - } - if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { - seg.advance += alone - advance; - } -} - -/// The advance a reader of live SVG text gives each character cluster of `t` and how far from -/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out -/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by -/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual -/// kerning leaves it out). -fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { - let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; - for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { - p.mojikumi = vectorcraft_doc::Mojikumi::None; - // A reader spaces words and letters as the font does. - p.justification = vectorcraft_doc::JustificationOptions::default(); - } - if !kerning { - for r in &mut set.runs { - r.style.kerning.get_or_insert(0.0); - } - } - let mut out: HashMap = HashMap::new(); - for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { - let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); - *advance += g.advance * lay.fit_scale; - } - out -} - -/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: -/// right end), that point of its width. `None` for an empty line. -fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { - let (first, last) = (line.first()?, line.last()?); - let x = match anchor { - Some(f) => first.x + f * (last.x + last.advance - first.x), - None => first.x, - }; - Some((x, first.y)) -} - -/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in -/// it)? Anything this can't tell is taken as not. -fn paints_opaque(n: &Node) -> bool { - use vectorcraft_doc::{AppearanceItem, NodeKind}; - let paint = |p: &Paint| match p { - Paint::None | Paint::Solid { .. } => true, - Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), - _ => false, - }; - let items = n.appearance.items.iter().all(|i| match i { - AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)), - // Brush art draws as pieces with opacities of their own (bristles vary theirs). - AppearanceItem::Stroke(s) => { - !s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint)) - } - }); - let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items; - own && match &n.kind { - NodeKind::Path { .. } => true, - NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)), - NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), - _ => false, - } -} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs new file mode 100644 index 000000000..69d700844 --- /dev/null +++ b/crates/svg/src/export/nodes.rs @@ -0,0 +1,705 @@ +use super::output::{sanitize_id, valid_xml_value, xml_attr_escape}; +use super::paint::{StrokePlan, has_raster, pixel_gradient}; +use super::text::OUTLINE_TOLERANCE; +use super::*; + +/// A symbol's `` def and the instance transforms that can `` it. +pub(super) struct SymbolDef { + /// The def's id, once written. + id: Option, + reuse: Reuse, +} + +/// Which instance transforms paint a symbol's art exactly as the def under that transform (the +/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked +/// masks stay put). +#[derive(Clone, Copy)] +pub(crate) struct Reuse { + /// Translations (no pattern paints or unlinked masks). + moves: bool, + /// Rotations and reflections (nor effects or brushes). + turns: bool, + /// Any other transform (nor strokes or live objects). + scales: bool, +} + +/// How deep symbols may sit in one another's art (deeper instances are left out). +const MAX_SYMBOL_NEST: u32 = 8; + +impl Reuse { + const NONE: Self = Self { moves: false, turns: false, scales: false }; + + /// What a def of symbol art `art` stands for. + pub(crate) fn of(doc: &Document, art: &Node) -> Self { + let mut r = Self { moves: true, turns: true, scales: true }; + r.scan(doc, art, 0); + r + } + + /// Narrow down to what `n` (inside symbols nested `depth` deep) allows. + fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); + let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); + if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { + *self = Self::NONE; + return; + } + let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); + if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) { + (self.turns, self.scales) = (false, false); + } + for i in &n.appearance.items { + if let AppearanceItem::Stroke(s) = i + && s.visible + && !s.paint.is_none() + && s.width > 0.0 + { + // Stroke weights don't scale; brushes can orient their art to the page. + self.scales = false; + self.turns &= s.brush.is_none(); + } + } + match &n.kind { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + self.scan(doc, &s.art, depth + 1); + } + } + _ => {} + } + if let Some(m) = &n.extras().mask { + self.scan(doc, &m.art, depth); + } + for c in n.children().into_iter().flatten() { + self.scan(doc, c, depth); + } + } + + /// Can the def stand for an instance with transform `xf`? + pub(crate) fn allows(self, xf: Affine) -> bool { + const EPS: f64 = 1e-9; + let [a, b, c, d, _, _] = xf.as_coeffs(); + if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { + return self.moves; + } + let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS; + if rigid { self.turns } else { self.scales } + } +} + +/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas +/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement +/// and the art of unlinked opacity masks. +fn pin_to_page(n: &mut Node, m: Affine) { + vectorcraft_doc::pattern::transform_pattern_paints(n, m); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { + let linked = mask.linked; + let art = std::sync::Arc::make_mut(&mut mask.art); + if !linked { + art.transform(m, false); + } + pin_to_page(art, m); + } + for c in n.children_mut().into_iter().flatten() { + pin_to_page(std::sync::Arc::make_mut(c), m); + } +} + +/// What SVG Tiny 1.2 does without masks. +const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; + +/// A region covering any artwork (mask and filter extents). +const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; + +/// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the +/// canvas and PDF take it) rather than from linearised ones. +pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; + +/// Attribute of an exported opacity mask listing its options that differ from the defaults +/// (clipping, not inverted): `noclip`, `invert`. +pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; + +impl Writer<'_> { + /// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document + /// space, for strokes that aren't plain (it may be empty when all are). + pub(super) fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option) { + let id = self.id_attr(n); + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }) + .collect(); + let mut bp = None; + let plans: Vec> = items + .iter() + .map(|i| match i { + AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)), + AppearanceItem::Fill(_) => None, + }) + .collect(); + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.len() - fills; + // A stroke's paint box is the bounds grown by half its weight, as on the canvas. + let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); + let simple = fills <= 1 + && strokes <= 1 + && !items.iter().any(|i| has_raster(i.effects())) + && items.iter().all(|i| i.blend() == BlendMode::Normal) + && !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some()) + && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); + if simple { + let mut p = Props::new(); + match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { + Some(f) => self.fill_props(f, rule, bounds, &mut p), + None => { + p.push(("fill", "none".into())); + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + } + if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { + self.stroke_props(s, s.width, paint_bounds(s), &mut p); + if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { + p.push(("paint-order", "stroke".into())); + } + } + p.extend(css::transparency(n)); + let a = self.attrs(&p); + self.line(&format!("")); + return; + } + let a = self.attrs(&css::transparency(n)); + self.line(&format!("")); + self.depth += 1; + for (it, plan) in items.into_iter().zip(plans) { + // The item's own raster effects filter that item alone. + let filters = self.open_filters(n, it.effects()); + let mut p = Props::new(); + match (it, plan) { + (AppearanceItem::Fill(f), _) => { + let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string())); + if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { + p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); + p.extend(blend); + self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); + } else { + self.fill_props(f, rule, bounds, &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + } + (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => { + // The brush art takes the stroke's opacity and blend mode as a group. + if s.opacity < 1.0 { + p.push(("opacity", fmt_num(s.opacity as f64, 3))); + } + if s.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(s.blend).into())); + } + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + for piece in &art { + self.node(piece); + } + self.depth -= 1; + self.line(""); + } + (AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => { + let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); + let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); + let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))); + match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) { + (WrittenShape::Stroke { width }, Some((g, b))) => { + // The stroke's outline clips the gradient's image. + let bp = bp.get_or_insert_with(|| doc_path(paths)); + let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE); + let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); + p.extend(opacity); + p.extend(blend); + self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); + } + (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { + let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + p.extend(opacity); + p.extend(blend); + self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); + } + (WrittenShape::Stroke { width }, _) => { + p.push(("fill", "none".into())); + self.stroke_props(s, *width, paint_bounds(s), &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + (WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines. + if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) { + p.extend(opacity); + p.extend(blend); + self.sliced(&ws, &p, &side); + } + } + (WrittenShape::Fill(outlines), _) => { + let paint = self.paint(&s.paint, paint_bounds(s)); + let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3)); + if let [one] = &ds[..] { + p.push(("fill", paint)); + p.extend(opacity.map(|o| ("fill-opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } else if !ds.is_empty() { + // The line and its arrowheads overlap: one group takes the opacity. + p.extend(opacity.map(|o| ("opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + let fill = self.attrs(&vec![("fill", paint)]); + for one in &ds { + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + } + } + } + } + (AppearanceItem::Stroke(_), None) => {} + } + self.close_filters(filters); + } + self.depth -= 1; + self.line(""); + } + + /// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with + /// `props` and the `side` attribute) of its slices, clipped to its outlines. + fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) { + self.warn("gradients along or across strokes are written as slices of linear gradients"); + let cid = self.fresh_id("clip-path"); + let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + let a = self.attrs(props); + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + self.depth += 1; + for (shape, paint) in &ws.slices { + let fill = self.paint(paint, None); + let fill = self.attrs(&vec![("fill", fill)]); + let d = self.path_d(&PathData::from_bezpath(shape), self.xf); + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + self.depth -= 1; + self.line(""); + } + + /// An object with an opacity mask: `` around the unmasked object. The mask + /// art goes in ``; no-clip adds a white backdrop and invert a colour-inverting filter. + fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { + if self.tiny() { + self.warn(TINY_MASKS); + let mut bare = n.clone(); + bare.extras_mut().mask = None; + return self.node_body(&bare); + } + let mid = self.fresh_id("mask"); + // Mask art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); + let art = self.detached(2, |w| w.node(&m.art)); + self.knockout = knockout; + let inv = m.invert.then(|| { + let fid = self.fresh_id("invert"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + fid + }); + // The mask's options, so import restores them (and its art without backdrop and filter). + let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); + let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; + self.def(1, &format!("")); + if let Some(fid) = &inv { + self.def(1, &format!("")); + } + if !m.clip || m.invert { + let c = if m.clip { "black" } else { "white" }; + self.def(1, &format!("")); + } + self.defs.push_str(&art); + if inv.is_some() { + self.def(1, ""); + } + self.def(1, ""); + self.line(&format!("")); + self.depth += 1; + let mut bare = n.clone(); + bare.extras_mut().mask = None; + self.node_body(&bare); + self.depth -= 1; + self.line(""); + } + + /// What `write` writes to the body, taken out of it (written at `depth`, for ``). + fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { + let (body, saved) = (std::mem::take(&mut self.body), self.depth); + self.depth = depth; + write(self); + self.depth = saved; + std::mem::replace(&mut self.body, body) + } + + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. + fn group_props(&self, n: &Node) -> Props { + let mut p = css::transparency(n); + if !n.isolate && n.knocks_out(self.knockout) { + p.push(("isolation", "isolate".into())); + } + p + } + + /// The children of group `n`, as the elements of a knockout group when it is one. + fn group_children(&mut self, n: &Node, children: &[std::sync::Arc]) { + let knockout = n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + self.children(children); + self.knockout = enclosing; + } + + /// Children of the group being written. SVG has no knockout groups: in one, each element is + /// drawn through a mask of where the elements above it don't paint (the same look for Normal + /// blending). The masks nest, so element i sits inside the masks of elements i+1…n. + pub(super) fn children(&mut self, children: &[std::sync::Arc]) { + if self.knockout && self.tiny() { + self.warn(TINY_MASKS); + } + if !self.knockout || self.tiny() { + for c in children { + self.node(c); + } + return; + } + let elements = Node::knockout_elements(children); + // Where every element over another covers it fully, drawing them in order knocks out as + // much: the masks would only add seams along the shapes' antialiased edges. + if elements.iter().skip(1).all(|c| paints_opaque(c)) { + for c in elements { + self.node(c); + } + return; + } + let Some((first, rest)) = elements.split_first() else { return }; + for c in rest.iter().rev() { + let mid = self.knockout_mask(c); + self.line(&format!("")); + self.depth += 1; + } + self.node(first); + for c in rest { + self.depth -= 1; + self.line(""); + self.node(c); + } + } + + /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object + /// opacity without its own mask, unless those define its shape). Returns the mask id. + fn knockout_mask(&mut self, c: &Node) -> String { + let filter = match &self.knockout_filter { + Some(f) => f.clone(), + None => { + let f = self.fresh_id("knockout-shape"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + self.knockout_filter = Some(f.clone()); + f + } + }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + let art = self.detached(3, |w| w.node(&shape)); + self.anonymous = anonymous; + let mid = self.fresh_id("knockout"); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.defs.push_str(&art); + self.def(2, ""); + self.def(1, ""); + mid + } + + /// An object, inside `` when it links to a URL (Attributes panel). + pub(super) fn node(&mut self, n: &Node) { + let layer = usize::from(n.is_layer()); + self.layer_nest += layer; + match n.url().filter(|_| n.visible && !self.anonymous) { + Some(url) => { + self.line(&format!("", xml_escape(url))); + self.depth += 1; + self.node_body(n); + self.depth -= 1; + self.line(""); + } + None => self.node_body(n), + } + self.layer_nest -= layer; + } + + pub(super) fn node_body(&mut self, n: &Node) { + // Hidden layers and objects are left out, unless the options keep them (hidden). Template + // layers never print. + if !(n.visible || self.opts.hidden_layers) { + return; + } + if has_raster(&n.appearance.effects) { + return self.filtered(n); + } + if let Some(m) = n.extras().mask.as_deref() + && !m.disabled + { + return self.masked(n, m); + } + match &n.kind { + NodeKind::Layer { template: true, .. } => {} + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + let id = self.id_attr(n); + let a = self.attrs(&self.group_props(n)); + self.line(&format!("")); + self.depth += 1; + self.group_children(n, children); + self.depth -= 1; + self.line(""); + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + let Some((clip, rest)) = children.split_first() else { return }; + let cid = self.fresh_id("clip-path"); + self.def(1, &format!("")); + // The region every output clips to; with nothing to clip by, an empty clip path + // hides the clipped art. + if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { + let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(2, &format!("")); + } + self.def(1, ""); + let id = self.id_attr(n); + let a = self.attrs(&self.group_props(n)); + // The clipping path's fill paints behind the clipped art and its stroke over it, + // outside the clip (the group then wraps both). + let paint = clip.clip_paint(); + if paint.stroke.is_some() { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + self.depth += 1; + if let Some(fill) = &paint.fill { + self.node(fill); + } + self.group_children(n, rest); + self.depth -= 1; + self.line(""); + if let Some(stroke) = &paint.stroke { + // One element keeps the clipping path's id. + let anonymous = self.anonymous; + self.anonymous |= paint.fill.is_some(); + self.node(stroke); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + } + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { path, rule, .. } => { + if path.is_empty() { + return; + } + let d = self.path_d(path, self.xf); + self.shape(n, &d, &[path], *rule, n.geometric_bounds()); + } + NodeKind::Compound { children, rule } => { + let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); + if paths.is_empty() { + return; + } + let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); + self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); + } + NodeKind::Text(t) => self.text_node(n, t), + NodeKind::Image(im) => { + let id = self.id_attr(n); + let m = self.matrix(self.xf * im.xf); + let a = self.attrs(&css::transparency(n)); + if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { + let shared = shared.clone(); + self.line(&format!("")); + return; + } + let Some(href) = self.image_href(im) else { return }; + self.line(&format!( + "", + im.width, + im.height, + xml_escape(&href) + )); + } + NodeKind::SymbolInstance { symbol, xf } => { + let doc = self.doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; + if self.symbol_nest > MAX_SYMBOL_NEST { + return; + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + if let Some(def) = self.symbol_use(sym, n, *xf) { + let m = self.xf * *xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + self.line(&format!("")); + return; + } + // An instance of its own: the (stained) art moved by the instance, its pattern + // paints and unlinked masks left in place as on the canvas. + let mut art = vectorcraft_brush::instance_art(&sym.art, n); + let moved = self.instance_xf * *xf; + if moved.determinant().abs() > 1e-12 { + pin_to_page(&mut art, moved.inverse()); + } + let saved = (self.xf, self.instance_xf); + self.xf = saved.0 * *xf; + self.instance_xf = moved; + self.line(&format!("")); + self.depth += 1; + // The symbol's art is the instance's own picture, outside any knockout around it. + let knockout = std::mem::take(&mut self.knockout); + self.symbol_nest += 1; + self.node(&art); + self.symbol_nest -= 1; + self.knockout = knockout; + self.depth -= 1; + self.line(""); + (self.xf, self.instance_xf) = saved; + } + // Live blends/envelopes/meshes export their evaluated (expanded) form. + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.share_images(&g); + self.node_body(&g); + } + } + } + + /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first + /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved + /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). + fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { + // SVG Tiny has no ``. + if self.tiny() || vectorcraft_brush::stain(inst).is_some() { + return None; + } + if !self.symbols.contains_key(&sym.name) { + let reuse = Reuse::of(self.doc, &sym.art); + self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); + } + let def = self.symbols.get(&sym.name)?; + if !def.reuse.allows(xf) { + return None; + } + if let Some(id) = &def.id { + return Some(id.clone()); + } + // The art in symbol space, as its own picture: no knockout around it, and no object ids + // (every instance shows it). + let id = self.unique_id(&sanitize_id(&sym.name)); + let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); + (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); + self.symbol_nest += 1; + let art = self.detached(3, |w| w.node(&sym.art)); + self.symbol_nest -= 1; + (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; + // The symbol's name, when the id had to differ from it, comes back on import. + let name = if id == sym.name { + String::new() + } else if valid_xml_value(&sym.name) { + format!(" data-name=\"{}\"", xml_attr_escape(&sym.name)) + } else { + self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes"); + String::new() + }; + self.def(1, &format!("")); + self.defs.push_str(&art); + self.def(1, ""); + if let Some(def) = self.symbols.get_mut(&sym.name) { + def.id = Some(id.clone()); + } + Some(id) + } +} + +/// A shape's paths as one path (document space). +pub(super) fn doc_path(paths: &[&PathData]) -> BezPath { + let mut bp = BezPath::new(); + for p in paths { + bp.extend(p.to_bezpath()); + } + bp +} + +/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in +/// it)? Anything this can't tell is taken as not. +fn paints_opaque(n: &Node) -> bool { + use vectorcraft_doc::{AppearanceItem, NodeKind}; + let paint = |p: &Paint| match p { + Paint::None | Paint::Solid { .. } => true, + Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), + _ => false, + }; + let items = n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)), + // Brush art draws as pieces with opacities of their own (bristles vary theirs). + AppearanceItem::Stroke(s) => { + !s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint)) + } + }); + let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items; + own && match &n.kind { + NodeKind::Path { .. } => true, + NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)), + NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), + _ => false, + } +} diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs new file mode 100644 index 000000000..bcbfe4715 --- /dev/null +++ b/crates/svg/src/export/output.rs @@ -0,0 +1,422 @@ +use super::*; + +/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value. +pub(super) fn xml_attr_escape(s: &str) -> String { + xml_escape(s).replace('\t', " ").replace('\n', " ").replace('\r', " ") +} + +/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line +/// and page breaks (U+000B, U+000C) other apps paste, which become spaces. +pub(super) fn xml_text(s: &str) -> String { + let s: String = s + .chars() + .filter_map(|c| match c { + '\u{b}' | '\u{c}' => Some(' '), + c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c), + _ => None, + }) + .collect(); + xml_escape(&s) +} + +pub(super) fn valid_xml_value(s: &str) -> bool { + s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) +} + +/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. +fn xml_ncname(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) +} + +/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. +fn xml_name_char(c: char) -> bool { + matches!(c, + 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' | + '\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' | + '\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' | + '\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' | + '\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' | + '\u{10000}'..='\u{effff}' + ) +} + +/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when +/// names fit Latin-1, so references occur in values, text or style sheets. +pub(super) fn latin1_refs(s: &mut String) { + if s.chars().all(|c| u32::from(c) < 0x100) { + return; + } + let mut o = String::with_capacity(s.len() + 64); + for c in s.chars() { + if u32::from(c) < 0x100 { + o.push(c); + } else { + let _ = write!(o, "&#x{:X};", u32::from(c)); + } + } + *s = o; +} + +/// A CSS declaration block as the literal value of an ``: quotes, `%` and markup +/// characters become references that still read as themselves inside the `style` attribute. +pub(super) fn entity_value(decl: &str) -> String { + let mut o = String::with_capacity(decl.len()); + for c in decl.chars() { + match c { + '"' => o.push_str("""), + '%' => o.push_str("%"), + // Doubly escaped: the entity's replacement text is parsed again where it is used. + '&' => o.push_str("&#38;"), + '<' => o.push_str("&#60;"), + _ => o.push(c), + } + } + o +} + +/// The Dublin Core terms File Info writes to ``: the format, title, author (creator), +/// description, each keyword (subject), copyright notice and URL (rights) and created date, the +/// empty ones left out. +fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { + let info = &doc.metadata; + let mut terms = vec![("format", "image/svg+xml".to_string())]; + let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); + let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; + let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); + terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); + terms +} + +/// Turn an object name into a valid, readable XML id. +pub(crate) fn sanitize_id(name: &str) -> String { + let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { + s.insert(0, '_'); + } + s +} + +impl Writer<'_> { + /// Writing the SVG Tiny 1.2 profile? + pub(super) fn tiny(&self) -> bool { + self.opts.profile == Profile::Tiny12 + } + + pub(super) fn num(&self, v: f64) -> String { + fmt_num(v, self.opts.decimals) + } + + pub(super) fn indent(&self, depth: usize) -> String { + if self.opts.minify { String::new() } else { " ".repeat(depth) } + } + + pub(super) fn line(&mut self, s: &str) { + let ind = self.indent(self.depth); + self.body.push_str(&ind); + self.body.push_str(s); + if !self.opts.minify { + self.body.push('\n'); + } + } + + pub(super) fn def(&mut self, depth: usize, s: &str) { + let ind = self.indent(depth + 1); + self.defs.push_str(&ind); + self.defs.push_str(s); + if !self.opts.minify { + self.defs.push('\n'); + } + } + + pub(super) fn fresh_id(&mut self, prefix: &str) -> String { + let next = self.next_fresh.entry(prefix.to_owned()).or_insert(1); + loop { + let id = format!("{}{prefix}-{next}", self.id_prefix); + *next += 1; + if self.used_ids.insert(id.clone()) { + return id; + } + } + } + + /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… + pub(super) fn unique_id(&mut self, base: &str) -> String { + let base = format!("{}{base}", self.id_prefix); + if self.used_ids.insert(base.clone()) { + return base; + } + let next = self.next_suffix.entry(base.clone()).or_insert(2); + loop { + let cand = format!("{base}-{next}"); + *next += 1; + if self.used_ids.insert(cand.clone()) { + return cand; + } + } + } + + /// Reserve ids for every named object up front so generated def ids never collide with them. + pub(super) fn assign_name_ids(&mut self) { + if self.opts.object_ids == ObjectIds::Minimal { + return; + } + let mut named: Vec<(NodeId, String)> = Vec::new(); + self.doc.walk(|n| { + if let Some(name) = &n.name { + named.push((n.id, sanitize_id(name))); + } + }); + for (id, base) in named { + let cand = self.unique_id(&base); + self.names.insert(id, cand); + } + } + + /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to + /// differ from it (spaces, punctuation, duplicates, the unique prefix). + /// The id of `n`'s element (with its name when the id isn't it), then the object's own data + /// as `data-*` attributes. Sublayers also retain their import marker. + pub(super) fn id_attr(&mut self, n: &Node) -> String { + if self.anonymous { + return String::new(); + } + let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id))); + if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref()) + && name != id + { + if valid_xml_value(name) { + out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name))); + } else { + self.warn("Object names containing invalid XML characters were left out of SVG name attributes"); + } + } + // Preserve sublayer identity independently of object data attributes. + if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { + out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); + } + let mut keys = HashSet::new(); + for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + // Ours (`name`, `vc-…`) are written by the export itself. + if k == "name" || k.starts_with("vc-") { + continue; + } + if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) { + self.warn("Data attributes with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is an ordered list. Keep the first valid value of each key. + if !keys.insert(k.as_str()) { + self.warn("Repeated data attribute keys were written only once in SVG"); + continue; + } + self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); + } + self.foreign_attrs(n, &mut out); + self.event_attrs(n, &mut out); + out + } + + /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. + /// Each namespace is declared once, on the root, under the prefix the file gave it unless the + /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). + fn foreign_attrs(&mut self, n: &Node, out: &mut String) { + let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); + let mut seen = HashSet::new(); + for a in attrs { + if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { + self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is a list: one attribute per namespace and name. + if !seen.insert((a.ns.as_str(), a.name.as_str())) { + self.warn("Repeated attributes of other applications were written only once in SVG"); + continue; + } + let prefix = self.foreign_prefix(&a.ns, &a.prefix); + self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); + } + } + + /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, + /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps + /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… + fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { + if let Some(p) = self.foreign_ns.get(ns) { + return p.clone(); + } + const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; + let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; + let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); + let mut prefix = base.to_string(); + let mut k = 2; + while taken(&prefix) { + prefix = format!("{base}{k}"); + k += 1; + } + self.foreign_ns.insert(ns.to_string(), prefix.clone()); + prefix + } + + /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to + /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. + fn event_attrs(&mut self, n: &Node, out: &mut String) { + let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice()); + if events.is_empty() { + return; + } + if self.tiny() { + self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out"); + return; + } + let mut seen = HashSet::new(); + for (event, script) in events { + // Native data can name anything: only the panel's events are written, once each. + if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) { + self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG"); + continue; + } + if !seen.insert(event.as_str()) { + self.warn("Repeated SVG Interactivity events were written only once in SVG"); + continue; + } + out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script))); + } + } + + pub(super) fn matrix(&self, m: Affine) -> String { + let c = m.as_coeffs(); + format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5])) + } + + /// Style properties as attributes, an inline style, or a CSS class, per the styling option. + pub(super) fn attrs(&mut self, props: &Props) -> String { + if props.is_empty() { + return String::new(); + } + // SVG Tiny has presentation attributes alone. + let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling }; + match styling { + Styling::PresentationAttributes => { + // CSS-only properties go in a style attribute (SVG Tiny has none: left out). + let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| { + matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-variation-settings" | "font-kerning") + }); + let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); + if !style.is_empty() { + if self.tiny() { + self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out"); + } else { + s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style)))); + } + } + s + } + Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))), + Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))), + Styling::InternalCss => { + let i = self.class(css::declarations(props)); + format!(" class=\"{}cls-{i}\"", self.id_prefix) + } + } + } + + /// The 1-based number of a distinct declaration block (a CSS class or a style entity). + fn class(&mut self, decl: String) -> usize { + if let Some(&i) = self.class_ids.get(decl.as_str()) { + return i; + } + let i = self.classes.len() + 1; + self.class_ids.insert(decl.clone(), i); + self.classes.push(decl); + i + } + + /// ``: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the + /// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup + /// around this element. + pub(super) fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) { + if !self.opts.metadata && native.is_none() { + return; + } + let nl = if self.opts.minify { "" } else { "\n" }; + let mut line = |depth: usize, s: &str| { + out.push_str(&self.indent(depth)); + out.push_str(s); + out.push_str(nl); + }; + line(1, ""); + if self.opts.metadata { + line(2, ""); + line(3, ""); + for (term, value) in dublin_core(self.doc) { + line(4, &format!("{}", xml_escape(&value))); + } + line(3, ""); + line(2, ""); + } + if let Some((bytes, hash)) = native { + line( + 2, + &format!( + "", + base64_encode(bytes) + ), + ); + } + line(1, ""); + } + + pub(super) fn path_d(&self, p: &PathData, m: Affine) -> String { + let mut s = String::new(); + // Each point as "x y", appended in place (no string per number or per point). + let dec = self.opts.decimals; + let pt = |s: &mut String, p: Point| { + let p = m * p; + crate::push_num(s, p.x, dec); + s.push(' '); + crate::push_num(s, p.y, dec); + }; + for sp in &p.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + if !s.is_empty() { + s.push(' '); + } + s.push('M'); + pt(&mut s, sp.anchors[0].p); + for i in 0..sp.segment_count() { + let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); + if sp.segment_is_line(i) { + if sp.closed && i == n - 1 { + break; + } + s.push_str(" L"); + pt(&mut s, b.p); + } else { + s.push_str(" C"); + pt(&mut s, a.h_out); + s.push(' '); + pt(&mut s, b.h_in); + s.push(' '); + pt(&mut s, b.p); + } + } + if sp.closed { + s.push_str(" Z"); + } + } + s + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } +} diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs new file mode 100644 index 000000000..412098b72 --- /dev/null +++ b/crates/svg/src/export/paint.rs @@ -0,0 +1,503 @@ +use super::nodes::{MASK, doc_path}; +use super::output::sanitize_id; +use super::*; + +/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a +/// CMYK TIFF) as PNG. +fn web_image(b: &ImageBlob) -> std::borrow::Cow<'_, ImageBlob> { + use std::borrow::Cow; + if matches!(b.mime.as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/svg+xml") { + return Cow::Borrowed(b); + } + let png = image::load_from_memory(&b.bytes).ok().and_then(|img| { + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + }); + png.map_or(Cow::Borrowed(b), |png| Cow::Owned(ImageBlob::png(png))) +} + +/// The usual file extension of an image MIME type. +fn image_ext(mime: &str) -> &str { + match mime { + "image/jpeg" => "jpg", + "image/svg+xml" => "svg", + m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"), + } +} + +/// How one stroke is written. +pub(super) enum StrokePlan { + /// Its brush art (document space). + Brush(Vec), + Written(Written), +} + +impl Writer<'_> { + /// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are + /// missing), else its bytes. `None` when it has neither. + pub(super) fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { + let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); + match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { + (Some(l), _) => Some(l.path.clone()), + (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), + _ => im.link.as_ref().map(|l| l.path.clone()), + } + } + + /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs + /// once; each piece then draws them with ``. + pub(super) fn share_images(&mut self, art: &Node) { + // Each image's count and its first piece, which shows what they all show. + let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; + } + }); + let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); + repeated.sort_by(|a, b| a.0.cmp(&b.0)); + for ((key, width, height), (_, im)) in repeated { + let Some(href) = self.image_href(&im) else { continue }; + let id = self.fresh_id("image"); + self.def( + 0, + &format!( + "", + xml_escape(&href) + ), + ); + self.shared_images.insert((key, width, height), id); + } + } + + /// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space + /// [`Self::xf`] maps into user space, as the renderer does). + pub(super) fn paint(&mut self, p: &Paint, bounds: Option) -> String { + match p { + Paint::None => "none".into(), + Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) { + Some(id) => format!("url(#{id})"), + None => "none".into(), + }, + Paint::Solid { color, .. } => color.to_hex(), + Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))), + } + } + + /// A `` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling, + /// with every instance that reaches into it. + fn pattern_def(&mut self, name: &str, xf: Affine) -> Option { + let doc = self.doc; + let def = doc.pattern(name)?; + let m = self.xf * xf; + let key = format!("{name}|{:?}", m.as_coeffs()); + if let Some(id) = self.patterns.get(&key) { + return Some(id.clone()); + } + if self.pattern_nest > 4 { + return None; + } + let id = self.fresh_id("pattern"); + self.patterns.insert(key, id.clone()); + let (pw, ph) = def.period(); + let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth); + self.depth = 3; + self.pattern_nest += 1; + for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) { + self.xf = def.instance_xf(o); + for a in &def.art { + self.node(a); + } + } + self.pattern_nest -= 1; + let content = std::mem::replace(&mut self.body, saved_body); + self.xf = saved_xf; + self.depth = saved_depth; + let head = format!( + "", + self.num(pw), + self.num(ph), + self.matrix(m) + ); + self.def(1, &head); + self.defs.push_str(&content); + self.def(1, ""); + Some(id) + } + + /// The id of a ``/`` def for `g` resolved against `bounds`. + /// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its + /// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint` + /// (what import turns back into a midpoint). + fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { + if g.gradient.kind == GradientKind::Freeform { + // Object fills and strokes are images (see `raster_paint`); characters' paints aren't. + self.warn("freeform gradients on characters are written as linear gradients"); + } + let mut geom = g.resolve(bounds); + geom.transform(self.xf, g.gradient.kind); + let (s, e) = (geom.start, geom.end); + // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. + let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y)); + let (tag, prefix, attrs) = match g.gradient.kind { + GradientKind::Radial => { + let r = self.num((e - s).hypot()); + let attrs = if (geom.aspect - 1.0).abs() < 1e-9 { + let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default(); + format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y)) + } else { + let v = e - s; + let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); + let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default(); + format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m)) + }; + ("radialGradient", "radial-gradient", attrs) + } + GradientKind::Linear | GradientKind::Freeform => ( + "linearGradient", + "linear-gradient", + format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), + ), + }; + let stops: Vec = g + .gradient + .expanded() + .map(|(offset, c, o, mid)| { + let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; + let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default(); + format!("", fmt_num(offset as f64, 4), c.to_hex()) + }) + .collect(); + let key = format!("{tag}{attrs}{}", stops.concat()); + if let Some(id) = self.gradients.get(&key) { + return id.clone(); + } + let id = match &g.swatch { + Some(name) => self.unique_id(&sanitize_id(name)), + None => self.fresh_id(prefix), + }; + self.gradients.insert(key, id.clone()); + self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">")); + for stop in &stops { + self.def(2, stop); + } + self.def(1, &format!("")); + id + } + + pub(super) fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option, p: &mut Props) { + let paint = self.paint(&f.paint, bounds); + p.push(("fill", paint)); + if f.opacity < 1.0 { + p.push(("fill-opacity", fmt_num(f.opacity as f64, 3))); + } + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + + pub(super) fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option, p: &mut Props) { + let paint = self.paint(&s.paint, bounds); + p.push(("stroke", paint)); + if s.opacity < 1.0 { + p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3))); + } + if (width - 1.0).abs() > 1e-9 { + p.push(("stroke-width", self.num(width))); + } + match s.cap { + LineCap::Butt => {} + LineCap::Round => p.push(("stroke-linecap", "round".into())), + LineCap::Square => p.push(("stroke-linecap", "square".into())), + } + match s.join { + LineJoin::Miter => { + if (s.miter_limit - 4.0).abs() > 1e-9 { + p.push(("stroke-miterlimit", self.num(s.miter_limit))); + } + } + LineJoin::Round => p.push(("stroke-linejoin", "round".into())), + LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), + } + if let Some(d) = &s.dash + && d.is_dashed() + { + p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); + if d.offset != 0.0 { + p.push(("stroke-dashoffset", self.num(d.offset))); + } + } + } + + /// How stroke `st` is written (`bp`: the shape in document space, built on first use from + /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas. + pub(super) fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option, paths: &[&PathData]) -> StrokePlan { + if stroke::is_plain(st) { + return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st)); + } + let bp = bp.get_or_insert_with(|| doc_path(paths)); + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { + Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), + None => StrokePlan::Written(stroke::for_writer(bp, st)), + } + } + + /// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its + /// side of the shape `d`; `reach` (document space) is what the stroke covers. + pub(super) fn side_attr(&mut self, side: Option, d: &str, rule: FillRule, reach: Rect) -> String { + match side { + None | Some(StrokeAlign::Center) => String::new(), + Some(StrokeAlign::Inside) => { + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" clip-path=\"url(#{cid})\"") + } + Some(StrokeAlign::Outside) if self.tiny() => { + // No masks: clipped by everything around the shape instead. + let cid = self.fresh_id("clip-path"); + let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); + let (x0, y0, x1, y1) = (self.num(b.x0), self.num(b.y0), self.num(b.x1), self.num(b.y1)); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" clip-path=\"url(#{cid})\"") + } + Some(StrokeAlign::Outside) => { + let mid = self.fresh_id("mask"); + let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" mask=\"url(#{mid})\"") + } + } + } + + /// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written + /// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across + /// documents). + pub(super) fn blob_href(&mut self, b: &ImageBlob) -> String { + if self.opts.images != ImageMode::Link { + return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); + } + let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); + if self.linked_names.insert(name.clone()) { + self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); + } + name + } + + /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is + /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a + /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled + /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour + /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size + /// in points); a linear or radial gradient at the document's raster effects resolution (at most + /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty + /// box. + #[allow(clippy::too_many_arguments)] + pub(super) fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { + use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let freeform = g.gradient.kind == GradientKind::Freeform; + let (rgba, cols, rows): (Vec, u16, u16) = if freeform { + let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); + let field = g.freeform_on(b).field(spread_scale(b)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) + } else { + let k = self.doc.raster_effects_ppi / 72.0; + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; + let (cols, rows) = (along(b.width()), along(b.height())); + (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) + }; + let mut png = Vec::new(); + let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; + if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + return; + } + self.warn(if freeform { + "freeform gradients are written as images clipped to their shapes" + } else { + "linear and radial gradients drawn as pixels are written as images clipped to their shapes" + }); + let href = self.blob_href(&ImageBlob::new("image/png", png)); + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + for d in clip { + self.def(2, &format!("")); + } + self.def(1, ""); + let a = self.attrs(props); + let nested = !side.is_empty(); + if nested { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + self.depth += 1; + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) }; + self.line(&format!( + "", + xml_escape(&href) + )); + self.depth -= 1; + self.line(""); + if nested { + self.depth -= 1; + self.line(""); + } + } + + /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` + /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow + /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style + /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn + /// such objects into images first). + pub(super) fn filtered(&mut self, n: &Node) { + let mut inner = n.clone(); + inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); + let opened = self.open_filters(n, &n.appearance.effects); + self.node_body(&inner); + self.close_filters(opened); + } + + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or + /// stroke's) on `n`, outermost last effect; returns how many to close. + pub(super) fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { + use vectorcraft_effects::RasterFx; + let fx = vectorcraft_effects::raster_effects(effects); + if self.tiny() { + if !fx.is_empty() { + self.warn("SVG Tiny 1.2 has no filters: shadows, glows, blurs and feathers are left out"); + } + return 0; + } + let bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach: f64 = + fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() + + 2.0; + let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); + let mut opened = 0; + for f in fx.iter().rev() { + if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { + self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); + continue; + } + let fid = self.fresh_id("filter"); + let region_attr = match region { + Some(r) => format!( + " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", + self.num(r.x0), + self.num(r.y0), + self.num(r.width()), + self.num(r.height()) + ), + None => String::new(), + }; + let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); + let flood = |c: &vectorcraft_color::Color, o: f32| { + format!("", c.to_hex(), fmt_num(o as f64, 3)) + }; + let body = match f { + // Darkness: the object blurred and moved, each color channel times 1 − darkness. + RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( + "", + sd(*blur), + self.num(*dx), + self.num(*dy), + fmt_num(*opacity as f64, 3), + keep = fmt_num(1.0 - *darkness as f64, 3) + ), + RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( + "{}", + sd(*blur), + self.num(*dx), + self.num(*dy), + flood(color, *opacity) + ), + // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. + RasterFx::OuterGlow { opacity, blur, color, .. } => format!( + "{}", + fmt_num(grow, 3), + sd(*blur), + flood(color, *opacity) + ), + RasterFx::InnerGlow { opacity, blur, color, center, .. } => { + // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. + let src = if *center { + "".replace("SD", &sd(*blur)) + } else { + format!( + "", + sd(*blur) + ) + }; + format!( + "{src}{}", + flood(color, *opacity) + ) + } + RasterFx::Feather { radius } => format!( + "", + sd(*radius) + ), + RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, + }; + // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. + let mode = match f { + RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } + if *mode != BlendMode::Normal => + { + format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) + } + _ => String::new(), + }; + self.def(1, &format!("{body}")); + self.line(&format!("")); + self.depth += 1; + opened += 1; + } + opened + } + + pub(super) fn close_filters(&mut self, opened: usize) { + for _ in 0..opened { + self.depth -= 1; + self.line(""); + } + } +} + +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; + +/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels +/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). +pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { + match p { + Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), + _ => None, + } +} + +/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? +pub(super) fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { + effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs new file mode 100644 index 000000000..7b5e55d84 --- /dev/null +++ b/crates/svg/src/export/text.rs @@ -0,0 +1,806 @@ +use super::output::xml_text; +use super::*; + +/// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`). +fn unicode_range(chars: &BTreeSet) -> String { + let mut runs: Vec<(u32, u32)> = Vec::new(); + for c in chars.iter().map(|c| u32::from(*c)) { + match runs.last_mut() { + Some((_, end)) if c == *end + 1 => *end = c, + _ => runs.push((c, c)), + } + } + let one = |(a, b): (u32, u32)| if a == b { format!("U+{a:X}") } else { format!("U+{a:X}-{b:X}") }; + runs.into_iter().map(one).collect::>().join(",") +} + +/// The characters type uses from one face, under one `@font-face` description: the family the +/// type names and the weight, italic and width its style asks for. +pub(super) struct FontUse { + family: String, + weight: u16, + italic: bool, + /// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width. + stretch: Option, + face: Arc, + chars: BTreeSet, +} + +impl Writer<'_> { + /// The ``/`` properties of a character style. `space`: the text's layout bounds + /// and the map from text space to user space, which character gradients resolve against + /// (as on the canvas). + fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { + let decimals = self.opts.decimals; + let len = |v: f64| fmt_num(v, decimals); + let mut p = css::font_props(st, &len); + // Character paints resolve in the text's space (`to_user` maps it into user space). + let (bounds, to_user) = space; + let saved = std::mem::replace(&mut self.xf, to_user); + if let Paint::Gradient(g) = &st.fill + && g.gradient.dither + && g.gradient.kind != GradientKind::Freeform + { + self.warn("dithered gradients on characters are written without dither"); + } + let fill = self.paint(&st.fill, Some(bounds)); + p.push(("fill", fill)); + // An inside or outside stroke is drawn on the glyph outlines instead + // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. + if st.has_stroke() && !aligned(st) { + // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans + // the text grown by half the weight, as on the canvas. + let layer = st.stroke_layer(); + self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); + } + self.xf = saved; + p.extend(css::type_props(st, &len)); + p + } + + /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, + /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those + /// below the Characters row under the characters, the others over them. + /// + /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as + /// instances of their symbols (a `` of the symbol's def where it can be shared), all in + /// one group carrying the object's transparency. The live text skips their characters and + /// places the text after each one; type on a path with inline graphics is written as outlines + /// (a `` couldn't leave room for the art). + pub(super) fn text_node(&mut self, n: &Node, t: &TextObject) { + let doc = self.doc; + let resolved = doc.inline_resolved(t); + let t = &*resolved; + let inline = t.runs.iter().any(|r| r.inline.is_some()); + // Ruby is written as outlines with its base text: a reader's can't place it. So are + // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). + let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) + || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + let outlined = + self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; + let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; + let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); + // Live type with an inside or outside character stroke writes that stroke apart from the + // text, in the group. + let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); + if !painted && !inline && !apart { + return chars(self, n); + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + self.line(&format!("")); + self.depth += 1; + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut all = lay.to_bezpath(); + for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { + all.extend(bar.iter()); + } + let pd = PathData::from_bezpath(&all).transformed(t.xf); + let d = self.path_d(&pd, self.xf); + let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); + let (below, above) = n.appearance.split_contents(); + let glyphs = |w: &mut Self, items: &[AppearanceItem]| { + if !items.is_empty() { + let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; + w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); + } + }; + glyphs(self, below); + // The group carries the id and the object's transparency. + let mut bare = n.clone(); + (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); + let anonymous = std::mem::replace(&mut self.anonymous, true); + chars(self, &bare); + for ig in &lay.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let inst = Node::new( + NodeId(u64::MAX), + NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, + ); + self.node(&inst); + } + glyphs(self, above); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + + /// Text as glyph outlines: one compound path per run, painted like the run (gradients span + /// the whole text), over its colour glyphs, as on the canvas. + fn text_outlines(&mut self, n: &Node, t: &TextObject) { + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; + // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted + // with the run of the text they annotate. + let bars = vectorcraft_text::decorations(&lay, db, t); + for (run, outline) in lay + .glyphs + .iter() + .map(|g| (g.run, &g.outline)) + .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) + .chain(bars.iter().map(|(r, b)| (*r, b))) + { + if let Some(bp) = runs.get_mut(run) { + bp.extend(outline.iter()); + } + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group + // names its text for screen readers and search. + let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { + format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) + } else { + String::new() + }; + self.line(&format!("")); + self.depth += 1; + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as + // their images, COLR and SVG glyphs as their art. + for g in lay.glyphs.iter().filter(|g| g.run == i) { + if let Some((image, to_text)) = g.placed_image() { + let m = self.matrix(self.xf * to_text); + let href = self.blob_href(&image.blob); + self.line(&format!( + "", + image.width, + image.height, + xml_escape(&href) + )); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art); + } + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(bp); + // An unnamed id: the pieces carry no id attribute (the group has it). + let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); + let d = self.path_d(&pd, self.xf); + self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; + self.depth -= 1; + self.line(""); + } + + /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and + /// spacing come out as on the canvas. Centred and right-aligned lines are anchored + /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font + /// differs; other lines are placed where each style run (and, justified, each word) starts. + fn text(&mut self, n: &Node, t: &TextObject) { + // Live SVG text is written in logical order from left-aligned pieces: bidirectional text + // (and right-to-left paragraphs) keep their look as outlines until it is written with + // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose + // `direction="rtl"` would anchor their pieces at the right end. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { + self.warn("bidirectional text is outlined to preserve shaping and visual order"); + return self.text_outlines(n, t); + } + if let TextKind::OnPath { path, start, end } = &t.kind { + // A reader lays a ``'s glyphs straight: joined glyphs bent along the path + // keep their look as outlines. + if lay.glyphs.iter().any(|g| g.bend.is_some()) { + self.warn("joined type bent along a path is outlined to keep its look"); + return self.text_outlines(n, t); + } + // `` has a start offset only: an end bracket, Align to Path and Spacing + // keep their look as outlines. + if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { + self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); + return self.text_outlines(n, t); + } + // It cannot hang glyphs outside the start bracket either. + if hangs_on_path(t, &lay) { + self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); + return self.text_outlines(n, t); + } + // A `` holds the characters of the text, not the bullets or numbers drawn + // before them. + if !lay.markers.is_empty() { + self.warn("type on a path with bullets or numbers is outlined to keep its look"); + return self.text_outlines(n, t); + } + return self.text_on_path(n, t, path, *start); + } + self.note_fonts(t, &lay); + let lines = text_lines(t, &lay, self.opts.fewer_tspans); + // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. + let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); + let drawn = fitted.as_ref().unwrap_or(t); + // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its + // stop, and so does the text after an inline graphic: lines with tabs, and text with + // inline graphics, are never anchored. + let has_inline = t.runs.iter().any(|r| r.inline.is_some()); + let anchors: Vec> = lines + .iter() + .map(|l| { + match t.para_at(l.para).justify { + Justify::Center => Some(("middle", 0.5)), + Justify::Right => Some(("end", 1.0)), + _ => None, + } + .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) + }) + .collect(); + // Every line anchored alike (the text's only paragraph style, typically): the + // carries the anchor. Otherwise each anchored line is a carrying its own. + let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); + // The element's user space is text space. + let space = (lay.bounds, Affine::IDENTITY); + let mut base_props = self.char_props(&drawn.first_style(), space); + // A `` cannot undo the decoration of the `` around it: with bullets, which + // have none, each run's `` carries its own. + if !lay.markers.is_empty() { + base_props.retain(|(k, _)| *k != "text-decoration"); + } + let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; + let mut props = base.props.clone(); + if let Some((a, _)) = uniform { + props.push(("text-anchor", a.into())); + } + props.extend(css::transparency(n)); + let id = self.id_attr(n); + let a = self.attrs(&props); + let m = self.xf * t.xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); + // The `` carries the first line's position too, so readers that place text by its + // own x/y start where the first line does. + let at = match starts.iter().flatten().next() { + Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), + None => String::new(), + }; + let mut s = format!(""); + for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { + let Some(start) = start else { continue }; + match anchor.filter(|_| uniform.is_none()) { + // A line anchored on its own: one positioned with the anchor around it. + Some((a, _)) => { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + let inner = TextBase { anchored: true, ..base.clone() }; + self.text_line(&mut s, drawn, &line.segs, start, &inner, false); + s.push_str(""); + } + None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), + } + } + s.push_str(""); + self.line(&s); + self.aligned_char_strokes(t); + } + + /// The inside and outside character strokes of live type `t`, over its text (which goes + /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn + /// twice as wide and clipped or masked to its side as aligned path strokes are + /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. + fn aligned_char_strokes(&mut self, t: &TextObject) { + if !t.runs.iter().any(|r| aligned(&r.style)) { + return; + } + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let bars = vectorcraft_text::decorations(&lay, db, t); + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { + let mut bp = BezPath::new(); + for g in lay.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { + bp.extend(bar.iter()); + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(&bp); + let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; + let d = self.path_d(&pd, self.xf); + self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; + } + + /// One line of laid-out text starting at `start`. Default: a positioned `` per segment + /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with + /// the style changes nested in it, positioned again only after tabs and inline graphics. + /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. + /// `positioned`: false when the caller already positioned the line (an outer ``). + fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { + let fewer = self.opts.fewer_tspans && positioned; + if fewer { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + } + let mut place = !fewer && positioned; + let mut first = true; + // The baseline shift the current text position carries. + let mut shift = 0.0; + for seg in line { + let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let marker_style; + let st = if seg.marker { + marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); + &marker_style + } else { + run_style + }; + let mut attrs = String::new(); + if place { + let (x, y) = if first { start } else { (seg.x, seg.y) }; + attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); + shift = 0.0; + } + first = false; + if st.baseline_shift != shift { + attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); + shift = st.baseline_shift; + } + // After a tab, an inline graphic or a justified word space, and before a character that a + // reader would draw elsewhere, the next segment is placed. + place = seg.brk || (!fewer && !base.anchored && positioned); + if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 { + attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale))); + } + if st.rotation != 0.0 { + attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); + } + let mut diff = run_diff(&base.props, self.char_props(st, base.space)); + diff.extend(direction_props(st)); + attrs.push_str(&self.attrs(&diff)); + let text = xml_text(&seg.text); + if attrs.is_empty() && fewer { + s.push_str(&text); + } else { + s.push_str(&format!("{text}")); + } + } + if fewer { + s.push_str(""); + } + } + + /// Record the characters of laid-out type `t` under the faces that supply them, for + /// [`Self::font_faces`] (when fonts are embedded). + fn note_fonts(&mut self, t: &TextObject, lay: &vectorcraft_text::TextLayout) { + if !self.opts.embed_fonts || self.opts.outline_text { + return; + } + if self.tiny() { + return self.warn("SVG Tiny 1.2 has no web fonts: fonts are not embedded"); + } + let db = FontDb::global(); + let plain = t.plain_text(); + let mut face: Option> = None; + for (i, g) in lay.glyphs.iter().enumerate() { + let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; + // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the + // viewer. + if g.image.is_some() || g.colour.is_some() { + self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); + continue; + } + if face.as_ref().is_none_or(|f| f.id() != g.font_id) { + face = db.face_by_id(g.font_id); + } + let Some(f) = &face else { continue }; + let (weight, italic) = font_descriptor(st); + let stretch = css::font_stretch(st).map(f64::to_bits); + let at = self + .fonts + .iter() + .position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family); + let at = at.unwrap_or_else(|| { + let family = st.font_family.clone(); + self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() }); + self.fonts.len() - 1 + }); + let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; + // As written: a list marker's characters, a hyphen where a line breaks, capitals for All + // Caps, the style's Digits. + let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); + for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { + if st.all_caps { + chars.extend(c.to_uppercase()); + } else { + chars.insert(c); + } + } + } + } + + /// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face + /// subset to its characters (whole when its licence forbids subsetting, left out with a + /// warning when it forbids embedding). Faces sharing a description cover their own + /// characters (`unicode-range`); where two of them share characters as well, a warning says + /// that browsers draw those in one of the faces. + pub(super) fn font_faces(&mut self) -> Vec { + let uses = std::mem::take(&mut self.fonts); + let mut rules = Vec::new(); + fn described(u: &FontUse) -> (&str, u16, bool, Option) { + (&u.family, u.weight, u.italic, u.stretch) + } + let mut clashing: Vec<&String> = vec![]; + for (i, u) in uses.iter().enumerate() { + let clash = uses + .get(..i) + .unwrap_or_default() + .iter() + .any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars)); + if clash && !clashing.contains(&&u.family) { + clashing.push(&u.family); + } + } + for family in clashing { + self.warn(&format!( + "type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces" + )); + } + for u in &uses { + let chars: Vec = u.chars.iter().copied().collect(); + let name = format!("{} {}", u.face.family, u.face.style); + let Some(font) = u.face.embed(&chars) else { + self.warn(&format!("the licence of the font {name} doesn't allow embedding: viewers show its type in their own fonts")); + continue; + }; + if !font.subset { + self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); + } + let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; + let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; + let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default(); + let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") }; + rules.push(format!( + "@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}", + css_string(&u.family), + u.weight, + if u.italic { "italic" } else { "normal" }, + base64_encode(&font.data) + )); + } + rules + } + + /// Type on a path: a `` along the path (a def). + fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) { + let id = self.id_attr(n); + let pid = self.fresh_id("text-path"); + let d = self.path_d(path, self.xf * t.xf); + self.def(1, &format!("")); + // The element's user space is the document's; gradients span the laid-out text. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + self.note_fonts(t, &lay); + let space = (lay.bounds, self.xf * t.xf); + let base = self.char_props(&t.first_style(), space); + let mut props = base.clone(); + match t.para_at(0).justify { + Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), + Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), + _ => {} + } + props.extend(css::transparency(n)); + let a = self.attrs(&props); + let offset = fmt_num(start * 100.0, 3); + let mut s = format!(""); + for run in &t.runs { + let mut diff = run_diff(&base, self.char_props(&run.style, space)); + diff.extend(direction_props(&run.style)); + let a = self.attrs(&diff); + s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); + } + s.push_str(""); + self.line(&s); + self.aligned_char_strokes(t); + } +} + +/// Does a character style stroke inside or outside its glyphs (not centred)? +fn aligned(st: &CharStyle) -> bool { + st.has_stroke() && st.stroke_align != StrokeAlign::Center +} + +/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when +/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its +/// glyphs sit elsewhere with both off. +fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { + if !t.optical_margin && !t.para_at(0).roman_hanging { + return false; + } + let mut flush = t.clone(); + flush.optical_margin = false; + let mut styles = flush.paragraph_styles(); + for p in &mut styles { + p.roman_hanging = false; + } + flush.set_paragraph_styles(styles); + let flush = vectorcraft_text::layout(FontDb::global(), &flush); + lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) +} + +/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` does not. Type with a run set Right to Left is written as +/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. +fn direction_props(st: &CharStyle) -> Props { + let Some(d) = st.char_direction else { return Props::new() }; + let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; + vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] +} + +/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the +/// properties that differ, and the initial value of each one `base` sets and the run doesn't. +fn run_diff(base: &Props, run: Props) -> Props { + let reset: Props = + base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); + run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() +} + +/// The initial value of an inherited property a run may leave unset (decorations can't be undone +/// on a ``). +fn initial_value(k: &str) -> Option<&'static str> { + Some(match k { + "font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal", + "stroke" | "stroke-dasharray" => "none", + "stroke-width" | "stroke-opacity" => "1", + "stroke-linecap" => "butt", + "stroke-linejoin" => "miter", + "stroke-miterlimit" => "4", + "stroke-dashoffset" | "letter-spacing" => "0", + "font-kerning" => "auto", + _ => return None, + }) +} + +/// How closely stroke outlines that clip an image follow the stroke (points). +pub(super) const OUTLINE_TOLERANCE: f64 = 0.01; + +/// What the lines of one `` share. +#[derive(Clone)] +struct TextBase { + /// The `` element's character properties, which each `` differs from. + props: Props, + /// The text's layout bounds and the map from text space to user space (character paints). + space: (Rect, Affine), + /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. + anchored: bool, +} + +/// One laid-out line: its paragraph (index) and its segments. +struct Line { + para: usize, + segs: Vec, +} + +/// Characters of one style on one line, written as one ``. +struct Segment { + /// Index into `TextObject::runs`. + run: usize, + /// Where a reader starts drawing the segment (text space; `y` is the baseline). + x: f64, + y: f64, + text: String, + /// The width a reader gives the segment (tracking included). On a line that is justified or + /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi + /// ([`reader_advances`]), for horizontally scaled type also without its last character's + /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. + advance: f64, + /// The next segment needs its own position (after a tab, a word space of a justified line, or + /// the next segment starts with a character the layout draws elsewhere than a reader would). + brk: bool, + /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to + /// (`textLength`); 1 for other segments. + scale: f64, + /// A list marker's characters and tab, written in + /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. + marker: bool, +} + +/// How far a reader may draw a character from where the layout draws it before the character is +/// given its own position (points). +const PLACE_TOLERANCE: f64 = 1e-6; + +/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, +/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters +/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all +/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also +/// starts a new segment at each character the layout draws elsewhere than a reader continuing +/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between +/// the characters of a justified Japanese line, and the spacing Mojikumi changes. +fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { + let plain = t.plain_text(); + let mut lines = Vec::with_capacity(lay.lines.len()); + let (mut kerned, mut unkerned) = (None, None); + for line in &lay.lines { + let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); + let style = t.para_at(para); + let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. + // Justification…'s Desired word and letter spacing and glyph scaling move characters from + // where a reader draws them, on every line. + let spaced = !fewer && !style.justification.desired_is_default(); + let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { + Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) + } else { + None + }; + // Where a reader draws the next character of the last segment, and the byte offset and + // reader's advance of the last segment's last cluster. + let mut pen = 0.0; + let mut tail = None; + let mut segs: Vec = Vec::new(); + let mut cluster = None; + // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). + let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { + lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) + }; + let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); + // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. + if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { + while let Some((_, rest)) = + glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) + { + glyphs = rest; + } + } + let mut after_inline = false; + for (k, g) in glyphs.iter().enumerate() { + let Some(run) = t.runs.get(g.run) else { continue }; + // A list marker before an inline graphic is in the graphic's run, and is text. + if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { + // An inline graphic (written as art): the text after it is placed anew. + if let Some(last) = segs.last_mut() { + last.brk = true; + } + after_inline = true; + cluster = None; + continue; + } + // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). + let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); + if g.len > 0 && cluster == Some(g.byte) { + // Another glyph of the same cluster (a reader's advance covers the whole cluster). + if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { + last.advance += g.advance; + pen += g.advance; + } + continue; + } + // A glyph that stands for no character (a list marker before the paragraph's first + // character) starts no cluster. + cluster = (g.len > 0).then_some(g.byte); + let (advance, x) = match set { + Some(&(advance, offset)) => { + // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. + let x = g.xf.translation().x - offset; + (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) + } + None => (g.advance, g.origin.x), + }; + let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); + let piece = if run.style.all_caps { src.to_uppercase() } else { src }; + let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); + // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. + let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 }; + let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0); + // The layout draws the character elsewhere than a reader continuing the line would. + let moved = reader.is_some() + && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); + pen = x + advance; + // A list marker is a segment of its own, and so is the text after it. In horizontally + // scaled type a tab is a segment apart from the characters before it, so a reader + // stretching those characters to their width (`textLength`) stretches them alone. + let marker = lay.marker_of(line.glyph_start + k).is_some(); + let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; + match segs.last_mut() { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { + last.text.push_str(&piece); + last.advance += advance; + last.brk = brk; + } + last => { + if let Some(last) = last { + last.brk |= moved || last.marker != marker; + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); + } + } + tail = set.map(|_| (g.byte, advance)); + } + if let Some(last) = segs.last_mut() { + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.retain(|s| !s.text.is_empty()); + if !segs.is_empty() { + lines.push(Line { para, segs }); + } + } + lines +} + +/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width +/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance +/// a reader gives it at the end of a ``, without the font's kerning against the character +/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. +fn end_segment( + seg: &mut Segment, + tail: Option<(usize, f64)>, + t: &TextObject, + lay: &vectorcraft_text::TextLayout, + unkerned: &mut Option>, +) { + let Some((byte, advance)) = tail else { return }; + if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { + return; + } + if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { + seg.advance += alone - advance; + } +} + +/// The advance a reader of live SVG text gives each character cluster of `t` and how far from +/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out +/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by +/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual +/// kerning leaves it out). +fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; + for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { + p.mojikumi = vectorcraft_doc::Mojikumi::None; + // A reader spaces words and letters as the font does. + p.justification = vectorcraft_doc::JustificationOptions::default(); + } + if !kerning { + for r in &mut set.runs { + r.style.kerning.get_or_insert(0.0); + } + } + let mut out: HashMap = HashMap::new(); + for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); + *advance += g.advance * lay.fit_scale; + } + out +} + +/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: +/// right end), that point of its width. `None` for an empty line. +fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { + let (first, last) = (line.first()?, line.last()?); + let x = match anchor { + Some(f) => first.x + f * (last.x + last.advance - first.x), + None => first.x, + }; + Some((x, first.y)) +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index aa827d762..fea237c61 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -22,6 +22,22 @@ pub use depth::MAX_ELEMENT_DEPTH; pub(crate) use depth::xml_too_deep; pub(crate) use fx::BLEND; +mod convert; +mod importer; +mod layers; +mod prepass; + +pub(super) use convert::href; +use convert::{aff, made_up, mask_flags, midpoint_stops, only_clips}; +pub(super) use importer::PT_PER_IN; +use importer::SymbolMade; +pub(super) use layers::DEFAULT_FONT_SIZE; +pub(crate) use layers::SUBLAYER; +use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name}; +pub(super) use prepass::Edits; +pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; +use prepass::{labels, prepass}; + mod css; mod css_transform; mod depth; @@ -204,162 +220,6 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec` 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). -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. -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. -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. -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(super) 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). -struct RootUnits { - /// Pixels per inch usvg (and the text pass) convert absolute lengths at. - dpi: f64, - /// Points per user unit of the root, along both axes. - k: f64, - /// Document units: those of the root `width` (pixels when it has none). - unit: Unit, -} - -impl RootUnits { - 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. -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. -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) -} -/// How many clip paths deep (``) an object's clip is read. -const MAX_CLIP_NEST: usize = 16; - -/// Points per inch. -pub(super) const PT_PER_IN: f64 = 72.0; -/// CSS pixels per inch. -const CSS_PX_PER_IN: f64 = 96.0; - struct Importer { doc: Document, warnings: Vec, @@ -401,1189 +261,3 @@ struct Importer { /// stroke's width is measured in. screen: f64, } - -/// 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. -struct SymbolMade { - name: String, - art: Node, - reuse: Reuse, -} - -/// `n` with every object id (its descendants' and its mask art's too) zeroed, to compare art. -fn without_ids(n: &Node) -> Node { - let mut n = n.clone(); - n.id = vectorcraft_doc::NodeId(0); - if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - m.art = Arc::new(without_ids(&m.art)); - } - for c in n.children_mut().into_iter().flatten() { - *c = Arc::new(without_ids(c)); - } - n -} - -/// `base`, or `base 2`, `base 3`… : the first that isn't `taken`. -fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { - let mut name = base.to_string(); - let mut k = 2; - while taken(&name) { - name = format!("{base} {k}"); - k += 1; - } - name -} - -/// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop, -/// the midpoint it stands for. -fn midpoint_stops(xml: &roxmltree::Document) -> HashMap>> { - xml.descendants() - .filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient")) - .filter_map(|g| { - let id = g.attribute("id")?; - let marks: Vec> = g - .children() - .filter(|c| c.tag_name().name() == "stop") - .map(|c| c.attribute("data-vc-midpoint").and_then(|v| v.parse().ok())) - .collect(); - marks.iter().any(Option::is_some).then(|| (id.to_string(), marks)) - }) - .collect() -} - -/// A gradient's stops, with the midpoint stops `marks` names folded back into the midpoints of -/// the stops before them. -fn gradient_stops(g: &usvg::BaseGradient, marks: Option<&[Option]>) -> Vec { - let marks = marks.filter(|m| m.len() == g.stops().len()); - let mut out: Vec = Vec::with_capacity(g.stops().len()); - for (i, s) in g.stops().iter().enumerate() { - if let (Some(mid), Some(prev)) = (marks.and_then(|m| m.get(i).copied().flatten()), out.last_mut()) { - prev.midpoint = mid.clamp(0.0, 1.0); - continue; - } - let c = s.color(); - out.push(GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }); - } - out -} - -/// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: -/// (clip, invert). -fn mask_flags(xml: &roxmltree::Document) -> HashMap { - let attr = crate::export::MASK_FLAGS; - if !xml.input_text().contains(attr) { - return HashMap::new(); - } - let flags = |n: roxmltree::Node| { - let has = |f: &str| n.attribute(attr).is_some_and(|v| v.split_whitespace().any(|x| x == f)); - Some((n.attribute("id")?.to_string(), (!has("noclip"), has("invert")))) - }; - xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() -} - -/// Prefix of the ids [`prepass`] gives the elements it has to find again that have none. -const MADE_UP_ID: &str = "vectorcraft-id-"; - -/// Is `id` one [`prepass`] made up (not a name)? -fn made_up(id: &str) -> bool { - id.starts_with(MADE_UP_ID) -} - -/// The URL an `` element links to. -pub(super) fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> { - a.attribute("href").or_else(|| a.attribute(("http://www.w3.org/1999/xlink", "href"))).filter(|h| !h.is_empty()) -} - -/// What usvg would lose, found in the XML before it runs ([`prepass`]). -#[derive(Default)] -struct Found { - /// The URL of each ``'s group, by its id. - links: HashMap, - /// Ids of the undisplayed objects shown for usvg: they come back hidden. - hidden: HashSet, - /// The `` id each `` of a symbol shows, by the ``'s id. - uses: HashMap, - /// The files `` elements link to. - files: files::Files, - /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). - non_scaling: HashSet, - /// The `data-*` attributes of elements, by id ([`user_data`]). - data: HashMap>, - /// The event attributes of elements, by id ([`user_events`]). - events: HashMap>, - /// The attributes of elements in other applications' namespaces, by id ([`foreign`]). - foreign: HashMap>, - /// The JavaScript files `